Wait the light to fall

编写HTTP客户端和服务器

焉知非鱼

Write HTTP clients & servers

有什么意义呢?

  • HTTP 协议允许客户端和服务器进行通信。
  • dart:io 包有编写 HTTP 程序的类。
  • 服务器监听主机和端口上的请求。
  • 客户端使用 HTTP 方法请求发送请求。
  • http_server 包提供了更高级别的构件。

前提条件: HTTP 服务器和客户端严重依赖 future 和流,本教程中没有解释这些内容。你可以从异步编程 codelab流教程中了解它们。

HTTP(超文本传输协议)是一种通信协议,用于通过互联网将数据从一个程序发送到另一个程序。数据传输的一端是服务器,另一端是客户端。客户端通常是基于浏览器的(用户在浏览器中输入或在浏览器中运行的脚本),但也可能是一个独立的程序。

服务器与主机和端口绑定(它与一个IP地址和一个端口号建立专属连接)。然后服务器监听请求。由于 Dart 的异步性,服务器可以同时处理很多请求,具体如下。

  • 服务器监听
  • 客户端连接
  • 服务器接受并接收请求(并继续监听)
  • 服务器可以继续接受其他请求
  • 服务器写入请求的响应或几个请求,可能是交错的请求
  • 服务器最终结束(关闭)响应

在 Dart 中,dart:io 库包含了编写 HTTP 客户端和服务器所需的类和函数。此外,http_server 包包含了一些更高层次的类,使其更容易编写客户端和服务器。

重要:基于浏览器的程序不能使用 dart:io 库。

dart:io 库中的 API 只适用于独立的命令行程序。它们不能在浏览器中工作。要从基于浏览器的客户端发出 HTTP 请求,请参考 dart:html HttpRequest 类。

本教程提供了几个例子,说明编写 Dart HTTP 服务器和客户端是多么容易。从服务器的 hello world 开始,你将学习如何编写服务器的代码,从绑定和监听到响应请求。你还可以学习到客户端:提出不同类型的请求(GET 和 POST),编写基于浏览器和命令行的客户端。

获取源码 #

  • 获取 Dart 教程的示例代码
  • 查看 httpserver 目录,其中包含本教程所需的源码。

运行 hello world 服务器 #

本节的示例文件:hello_world_server.dart

让我们从一个小型的服务器开始,用字符串 Hello, world 来响应所有的请求。

在命令行中,运行 hello_world_server.dart 脚本:

$ cd httpserver
$ dart bin/hello_world_server.dart
listening on localhost, port 4040

在任何浏览器中,访问 localhost:4040。浏览器会显示 Hello, world!

img

在这种情况下,服务器是一个 Dart 程序,客户端是你使用的浏览器。然而,你可以用 Dart 编写客户端程序-无论是基于浏览器的客户端脚本,还是独立的程序。

快速浏览一下代码 #

hello world 服务器的代码中,一个 HTTP 服务器与主机和端口绑定,监听 HTTP 请求,并写入响应。需要注意的是,该程序导入了 dart:io 库,其中包含了服务器端程序和客户端程序的 HTTP 相关类(但不包含 Web 应用)。

import 'dart:io';

Future main() async {
  var server = await HttpServer.bind(
    InternetAddress.loopbackIPv4,
    4040,
  );
  print('Listening on localhost:${server.port}');

  await for (HttpRequest request in server) {
    request.response.write('Hello, world!');
    await request.response.close();
  }
}

接下来的几节内容包括服务器端绑定、发出客户端 GET 请求、监听和响应。

将服务器绑定到主机和端口 #

本节示例:hello_world_server.dart

main() 中的第一条语句使用 HttpServer.bind() 创建一个 HttpServer 对象,并将其绑定到主机和端口。

var server = await HttpServer.bind(
  InternetAddress.loopbackIPv4,
  4040,
);

该代码使用 await 异步调用 bind 方法。

主机名 #

bind() 的第一个参数是指定主机名。你可以用一个字符串来指定一个特定的主机名或IP地址,也可以用 InternetAddress 类提供的这些预定义的值来指定主机。

用例
回环 IPv4 或 loopbackIPv6 服务器在 loopback 地址上监听客户端活动,该地址实际上是 localhost。使用IP协议的4或6版本。这些主要用于测试。我们建议您使用这些值而不是 localhost127.0.0.1
任何 IPv4 或 anyIPv6 服务器监听任何 IP 地址上指定端口上的客户端活动。使用IP协议的4或6版本。

默认情况下,当使用V6互联网地址时,也会使用V4监听器。

端口 #

bind() 的第二个参数是指定端口的整数。端口唯一地标识主机上的服务。1024 以下的端口号为标准服务保留(0除外)。例如,FTP 数据传输通常在端口20上运行,每日报价在端口17上运行,HTTP 在端口80上运行。你的程序应该使用1024以上的端口号。如果端口已经在使用中,你的服务器的连接将被拒绝。

侦听请求 #

服务器使用 await for 开始监听 HTTP 请求。每收到一个请求,代码就会发送一个 “Hello, world!” 的响应。

await for (HttpRequest request in server) {
  request.response.write('Hello, world!');
  await request.response.close();
}

你将在监听和处理请求一节中了解更多关于 HttpRequest 对象包含的内容以及如何编写响应。但首先,让我们看看客户端产生请求的一种方式。

使用 HTML 表单发出 GET 请求 #

本节的示例文件:number_thinker.dartmake_a_guess.html

本节介绍了一个命令行服务器,它可以随机选择一个0到9之间的数字。客户端是一个基本的 HTML 网页,make_a_guess.html,你可以用它来猜数字。

试试吧

  1. 运行数字思考者服务器

在命令行,运行 number_thinker.dart server。你应该看到类似下面的东西:

$ cd httpserver
$ dart bin/number_thinker.dart
I'm thinking of a number: 6
  1. 启动网络服务器

从应用程序的顶部目录运行 webdev serve

更多信息:webdev 文档

  1. 打开 HTML 页面

在浏览器中,进入 localhost:8080/make_a_guess.html

  1. 做一个猜测

选择一个数字,然后按猜测按钮。

img

在客户端中没有涉及到 Dart 代码。客户端请求是通过浏览器向 Dart 服务器发出的,在 make_a_guess.html 中的 HTML 表单,它提供了一个自动制定和发送客户端 HTTP 请求的方法。该表单包含下拉列表和按钮。该表单还指定了 URL,其中包括端口号,以及请求的种类(请求方法)。它还可能包含建立查询字符串的元素。

下面是 make_a_guess.html 中的表单 HTML。

<form action="http://localhost:4041" method="GET">
  <select name="q">
    <option value="0">0</option>
    <option value="1">1</option>
    <option value="2">2</option>
    <!-- ··· -->
    <option value="9">9</option>
  </select>
  <input type="submit" value="Guess">
</form>

下面是表单的工作原理:

  • 表单的 action 属性被分配给发送请求的 URL
  • 表单的 method 属性定义了请求的类型,这里是 GET。其他常见的请求类型包括 POST、PUT 和 DELETE。
  • 表单中任何有名称(name)的元素,比如 <select> 元素,都会成为查询字符串中的一个参数。
  • 当按下提交按钮(<input type="submit"...>)时,提交按钮会根据表单的内容制定请求并发送。

一个 RESTful GET 请求 #

REST(REpresentational State Transfer)是一套设计 Web 服务的原则。乖巧的 HTTP 客户端和服务器遵守为 GET 请求定义的 REST 原则。

一个 GET 请求:

  • 只检索数据
  • 不会改变服务器的状态
  • 有长度限制
  • 可以在请求的 URL 中发送查询字符串

在这个例子中,客户端发出了一个符合 REST 的 GET 请求。

监听和处理请求 #

本节的示例文件: number_thinker.dartmake_a_guess.html

现在你已经看到这个基于浏览器的客户端的例子,让我们看看数字思维服务器的 Dart 代码,从 main() 开始。

再一次,服务器绑定了一个主机和端口。在这里,每收到一个请求都会调用顶层的 handleRequest() 方法。因为 HttpServer 实现了 Stream,所以可以使用 await for 来处理请求。

import 'dart:io';
import 'dart:math' show Random;

Random intGenerator = Random();
int myNumber = intGenerator.nextInt(10);

Future main() async {
  print("I'm thinking of a number: $myNumber");

  HttpServer server = await HttpServer.bind(
    InternetAddress.loopbackIPv4,
    4041,
  );
  await for (var request in server) {
    handleRequest(request);
  }
}

当一个 GET 请求到达时,handleRequest() 方法会调用 handleGet() 来处理该请求。

void handleRequest(HttpRequest request) {
  try {
    if (request.method == 'GET') {
      handleGet(request);
    } else {
      // ···
    }
  } catch (e) {
    print('Exception in handleRequest: $e');
  }
  print('Request handled.');
}

一个 HttpRequest 对象有很多属性,提供了关于请求的信息。下表列出了一些有用的属性。

属性 信息
method ‘GET’, ‘POST’, ‘PUT’ 等方法中的一个。
uri 一个 Uri 对象:scheme、host、port、query string 和其他关于请求资源的信息。
response 一个 HttpResponse 对象:服务器将其响应写入其中。
headers 一个 HttpHeaders 对象:请求的头信息,包括 ContentType、内容长度、日期等。

使用方法属性 #

下面的数想器例子中的代码使用 HttpRequest 的 method 属性来确定收到了什么样的请求。这个服务器只处理 GET 请求。

if (request.method == 'GET') {
  handleGet(request);
} else {
  request.response
    ..statusCode = HttpStatus.methodNotAllowed
    ..write('Unsupported request: ${request.method}.')
    ..close();
}

使用 uri 属性 #

在浏览器中输入一个 URL 会产生一个 GET 请求,它只是简单地从指定的资源中请求数据。它可以通过附加在 URI 上的查询字符串随请求发送少量数据。

void handleGet(HttpRequest request) {
  final guess = request.uri.queryParameters['q'];
  // ···
}

使用 HttpRequest 对象的 uri 属性来获取一个 Uri 对象,这个 Uri 对象包含了用户输入的 URL 的信息。Uri 对象的 queryParameters 属性是一个 Map,包含查询字符串的组件。通过名称来引用所需的参数。本例使用 q 来标识猜测的数字。

设置响应的状态码 #

服务器应该设置状态码来表示请求的成功或失败。前面看到数想家将状态码设置为 methodNotAllowed 来拒绝非 GET 请求。在后面的代码中,为了表示请求成功,响应完成,数想家服务器将 HttpResponse 状态码设置为 HttpStatus.ok

void handleGet(HttpRequest request) {
  final guess = request.uri.queryParameters['q'];
  final response = request.response;
  response.statusCode = HttpStatus.ok;
  // ···
}

HttpStatus.okHttpStatus.methodNotAllowedHttpStatus 类中许多预定义状态码中的两个。另一个有用的预定义状态码是 HttpStatus.notFound(经典的 404)。

除了状态码(statusCode),HttpResponse 对象还有其他有用的属性:

属性 信息
contentLength 响应的长度,-1 表示事先不知道长度。
cookies 要在客户端设置的 Cookies 列表。
encoding 编写字符串时使用的编码,如 JSON 和 UTF-8。
headers 响应头,是一个 HttpHeaders 对象。

将响应写到 HttpResponse 对象 #

每个 HttpRequest 对象都有一个对应的 HttpResponse 对象。服务器通过响应对象将数据发回给客户端。

使用 HttpResponse 写方法之一(write()writeln()writeAll()writeCharCodes())将响应数据写入 HttpResponse 对象。或者通过 addStreamHttpResponse 对象连接到一个流,并写入流。响应完成后关闭对象。关闭 HttpResponse 对象会将数据发回给客户端。

void handleGet(HttpRequest request) {
  // ···
  if (guess == myNumber.toString()) {
    response
      ..writeln('true')
      ..writeln("I'm thinking of another number.")
      ..close();
    // ···
  }
}

从独立的客户端进行 POST 请求 #

本节的示例文件:basic_writer_server.dartbasic_writer_client.dart

hello worldnumber thinker 的例子中,浏览器生成了简单的 GET 请求,对于更复杂的 GET 请求和其他类型的请求,如 POST、PUT 或 DELETE,你需要写一个客户端程序,其中有两种。

让我们看看一个独立的客户端,basic_writer_client.dart 和它的服务器 basic_writer_server.dart。客户端发出一个 POST 请求,将 JSON 数据保存到服务器端的文件中。服务器接受请求并保存文件。

试试吧 #

在命令行上运行服务器和客户端。

  1. 首先,运行服务器:
cd httpserver
$ dart bin/basic_writer_server.dart
  1. 在一个新的终端中,运行客户端:
$ cd httpserver
$ dart bin/basic_writer_client.dart
Wrote data for Han Solo.

看看服务器写入 file.txt 的 JSON 数据:

{"name":"Han Solo","job":"reluctant hero","BFF":"Chewbacca","ship":"Millennium Falcon","weakness":"smuggling debts"}

客户端创建一个 HttpClient 对象,并使用 post() 方法进行请求。发起一个请求涉及两个 Future。

  • post() 方法建立与服务器的网络连接并完成第一个 Future,返回一个 HttpClientRequest 对象。
  • 客户端组成请求对象并关闭它。close() 方法将请求发送到服务器并返回第二个 Future,它以一个 HttpClientResponse 对象完成。
import 'dart:io';
import 'dart:convert';

String _host = InternetAddress.loopbackIPv4.host;
String path = 'file.txt';

Map jsonData = {
  'name': 'Han Solo',
  'job': 'reluctant hero',
  'BFF': 'Chewbacca',
  'ship': 'Millennium Falcon',
  'weakness': 'smuggling debts'
};

Future main() async {
  HttpClientRequest request = await HttpClient().post(_host, 4049, path) /*1*/
    ..headers.contentType = ContentType.json /*2*/
    ..write(jsonEncode(jsonData)); /*3*/
  HttpClientResponse response = await request.close(); /*4*/
  await utf8.decoder.bind(response /*5*/).forEach(print);
}

/1/ post() 方法需要主机、端口和请求资源的路径。除了 post() 之外,HttpClient 类还提供了其他类型的请求函数,包括 postUrl()get()open()

/2/ 一个 HttpClientRequest 对象有一个 HttpHeaders 对象,它包含了请求头的信息。对于一些请求头,比如 contentType,HttpHeaders 有一个针对该请求头的属性。对于其他的请求头,使用 set() 方法将该请求头放入 HttpHeaders 对象中。

/3/ 客户端使用 write() 向请求对象写入数据。编码,在这个例子中是 JSON,与 ContentType 头中指定的类型相匹配。

/4/ close() 方法将请求发送到服务器,完成后返回一个 HttpClientResponse 对象。

/5/ 来自服务器的 UTF-8 响应将被解码。使用在 dart:convert 库中定义的转换器将数据转换为常规的 Dart 字符串格式。

一个 RESTful POST 请求 #

与 GET 请求类似,REST 为 POST 请求提供了指导方针。

一个 POST 请求:

  • 创建一个资源(在这个例子中,一个文件)
  • 使用一个 URI,其结构与文件和目录路径名相似;例如,URI 没有查询字符串。
  • 以 JSON 或 XML 格式传输数据
  • 没有状态,也不会改变服务器的状态。
  • 无长度限制

这个例子中的客户端发出 REST 兼容的 POST 请求。

要想看到使 REST 兼容的 GET 请求的客户端代码,请看 number_guesser.dart。它是一个独立的客户端,用于数字思考者服务器,定期进行猜测,直到猜对为止。

在服务器中处理一个 POST 请求 #

本节的示例文件:basic_writer_server.dartbasic_writer_client.dart

一个 HttpRequest 对象是一个字节列表流(Stream<List<int>)。要获得客户端发送的数据,就要监听 HttpRequest 对象上的数据。

如果来自客户端的请求包含了大量的数据,数据可能会以多个分块的形式到达。你可以使用 Stream 中的 join() 方法来连接这些分块的字符串值。

img

basic_writer_server.dart 文件实现了一个遵循这种模式的服务器。

import 'dart:io';
import 'dart:convert';

String _host = InternetAddress.loopbackIPv4.host;

Future main() async {
  var server = await HttpServer.bind(_host, 4049);
  await for (var req in server) {
    ContentType contentType = req.headers.contentType;
    HttpResponse response = req.response;

    if (req.method == 'POST' &&
        contentType?.mimeType == 'application/json' /*1*/) {
      try {
        String content =
            await utf8.decoder.bind(req).join(); /*2*/
        var data = jsonDecode(content) as Map; /*3*/
        var fileName = req.uri.pathSegments.last; /*4*/
        await File(fileName)
            .writeAsString(content, mode: FileMode.write);
        req.response
          ..statusCode = HttpStatus.ok
          ..write('Wrote data for ${data['name']}.');
      } catch (e) {
        response
          ..statusCode = HttpStatus.internalServerError
          ..write('Exception during file I/O: $e.');
      }
    } else {
      response
        ..statusCode = HttpStatus.methodNotAllowed
        ..write('Unsupported request: ${req.method}.');
    }
    await response.close();
  }
}

/1/ 该请求有一个 HttpHeaders 对象。记得客户端将 contentType 头设置为 JSON(application/json)。该服务器拒绝不是 JSON 编码的请求。

/2/ 一个 POST 请求对它可以发送的数据量没有限制,数据可能会以多块形式发送。此外,JSON 是 UTF-8,而 UTF-8 字符可以在多个字节上进行编码。join() 方法将这些分块放在一起。

/3/ 客户端发送的数据是 JSON 格式的。服务器使用 dart:convert 库中的 JSON 编解码器对其进行解码。

/4/ 请求的 URL 是 localhost:4049/file.txt。代码 req.uri.pathSegments.last 从 URI 中提取文件名: file.txt

关于 CORS 头的说明 #

如果你想为运行在不同源头(不同主机或端口)的客户端提供服务,你需要添加 CORS 头。下面的代码,取自 note_server.dart,允许从任何来源的 POST 和 OPTIONS 请求。谨慎使用 CORS 头文件,因为它们会给你的网络带来安全风险。

void addCorsHeaders(HttpResponse response) {
  response.headers.add('Access-Control-Allow-Origin', '*');
  response.headers
      .add('Access-Control-Allow-Methods', 'POST, OPTIONS');
  response.headers.add('Access-Control-Allow-Headers',
      'Origin, X-Requested-With, Content-Type, Accept');
}

更多信息,请参考维基百科的跨源资源共享一文。

使用 http_server 包 #

本节的示例文件:mini_file_server.dartstatic_file_server.dart

对于一些更高层次的构件,我们推荐你尝试 http_server pub 包,它包含了一组类,与 dart:io 库中的 HttpServer 类一起,使得实现 HTTP 务器更加容易。

在本节中,我们比较了一个只使用 dart:io 的 API 编写的服务器和一个使用 dart:io 和 http_server 一起编写的具有相同功能的服务器。

你可以在 mini_file_server.dart 中找到第一个服务器。它通过从 web 目录返回 index.html 文件的内容来响应所有请求。

试试吧 #

在命令行中运行服务器:

$ cd httpserver
$ dart bin/mini_file_server.dart

在浏览器中输入 localhost:4044。服务器会显示一个 HTML 文件。

img

这是迷你文件服务器的代码:

import 'dart:io';

File targetFile = File('web/index.html');

Future main() async {
  Stream<HttpRequest> server;

  try {
    server = await HttpServer.bind(InternetAddress.loopbackIPv4, 4044);
  } catch (e) {
    print("Couldn't bind to port 4044: $e");
    exit(-1);
  }

  await for (HttpRequest req in server) {
    if (await targetFile.exists()) {
      print("Serving ${targetFile.path}.");
      req.response.headers.contentType = ContentType.html;
      try {
        await req.response.addStream(targetFile.openRead());
      } catch (e) {
        print("Couldn't read file: $e");
        exit(-1);
      }
    } else {
      print("Can't open ${targetFile.path}.");
      req.response.statusCode = HttpStatus.notFound;
    }
    await req.response.close();
  }
}

这段代码确定文件是否存在,如果存在,则打开文件,并将文件内容管道化到HttpResponse对象。

第二个服务器,你可以在 basic_file_server.dart 中找到它的代码,使用 http_server 包。

试试吧 #

在命令行中运行服务器:

$ cd httpserver
$ dart bin/basic_file_server.dart

在浏览器中输入 localhost:4046。服务器显示与之前相同的 index.html 文件。

img

在这个服务器中,处理请求的代码要短得多,因为 VirtualDirectory 类处理服务文件的细节。

import 'dart:io';
import 'package:http_server/http_server.dart';

File targetFile = File('web/index.html');

Future main() async {
  VirtualDirectory staticFiles = VirtualDirectory('.');

  var serverRequests =
      await HttpServer.bind(InternetAddress.loopbackIPv4, 4046);
  await for (var request in serverRequests) {
    staticFiles.serveFile(targetFile, request);
  }
}

这里,请求的资源 index.html 是由 VirtualDirectory 类中的 serviceFile() 方法提供的。你不需要写代码来打开一个文件并将其内容用管道传送到请求中。

另一个文件服务器 static_file_server.dart 也使用 http_server 包。这个服务器可以服务于服务器目录或子目录中的任何文件。

运行 static_file_server.dart,用 localhost:4048 这个 URL 进行测试。

下面是 static_file_server.dart 的代码:

import 'dart:io';
import 'package:http_server/http_server.dart';

Future main() async {
  var staticFiles = VirtualDirectory('web');
  staticFiles.allowDirectoryListing = true; /*1*/
  staticFiles.directoryHandler = (dir, request) /*2*/ {
    var indexUri = Uri.file(dir.path).resolve('index.html');
    staticFiles.serveFile(File(indexUri.toFilePath()), request); /*3*/
  };

  var server = await HttpServer.bind(InternetAddress.loopbackIPv4, 4048);
  print('Listening on port 4048');
  await server.forEach(staticFiles.serveRequest); /*4*/
}

/1/ 允许客户端请求服务器目录内的文件。

/2/ 一个匿名函数,处理对目录本身的请求,即 URL 不包含文件名。该函数将这些请求重定向到 index.html

/3/ serveFile 方法为一个文件提供服务,在这个例子中,它为目录请求服务index.html。

/4/ VirtualDirectory 类提供的 serviceRequest 方法处理指定文件的请求。

使用 bindSecure() 的 https 方法 #

本节的示例:hello_world_server_secure.dart

你可能已经注意到,HttpServer 类定义了一个叫做 bindSecure() 的方法,它使用 HTTPS(Hyper Text Transfer Protocol with Secure Sockets Layer)提供安全连接。要使用 bindSecure() 方法,你需要一个证书,这个证书由证书颁发机构(CA)提供。有关证书的更多信息,请参考什么是 SSL 和什么是证书

为了说明问题,下面的服务器 hello_world_server_secure.dart 使用 Dart 团队创建的证书调用 bindSecure() 进行测试。你必须为你的服务器提供自己的证书。

import 'dart:io';

String certificateChain = 'server_chain.pem';
String serverKey = 'server_key.pem';

Future main() async {
  var serverContext = SecurityContext(); /*1*/
  serverContext.useCertificateChain(certificateChain); /*2*/
  serverContext.usePrivateKey(serverKey, password: 'dartdart'); /*3*/

  var server = await HttpServer.bindSecure(
    'localhost',
    4047,
    serverContext, /*4*/
  );
  print('Listening on localhost:${server.port}');
  await for (HttpRequest request in server) {
    request.response.write('Hello, world!');
    await request.response.close();
  }
}

/1/ 安全网络连接的可选设置在 SecurityContext 对象中指定,有一个默认的对象 SecurityContext.defaultContext,包括知名证书机构的可信根证书。

/2/ 一个包含从服务器证书到签名机关根证书链的文件,格式为 PEM

/3/ 一个包含(加密的)服务器证书私钥的文件,PEM 格式

/4/ 在服务器上,上下文参数是必需的,对客户端来说是可选的。如果省略它,则使用默认的内置可信根的上下文。

其他资源 #

请访问这些 API 文档,了解本教程中讨论的类和库的更多细节。

Dart 类 目的
HttpServer 一个 HTTP 服务器
HttpClient 一个 HTTP 客户端
HttpRequest 一个服务器端请求对象
HttpResponse 一个服务器端响应对象
HttpClientRequest 一个客户端请求对象
HttpClientResponse 一个客户端响应对象
HttpHeaders 请求头
HttpStatus 响应的状态
InternetAddress 一个互联网地址
SecurityContext 包含安全连接的证书、密钥和信任信息。
http_server 一个具有较高级别的 HTTP 类的包

下一步该怎么做? #