NodeJS与Rust在HTTP请求和解析JSON上比较

这次我们将学习如何使用 Node 和 Rust 发送 HTTP 请求和解析JSON。

b1、发送 HTTP 请求/b
我们将在 GitHub API 上进行 GET 以获取用户。在 Node 示例中,我们从其他示例中常用的 TypeScript 设置开始。

由于 GitHubs API 是通过 https 提供的,因此我们将使用 Node 的 https 模块。我们本可以使用超级代理(superagent)之类的第三方库,因为对于 Rust 而言,我们实际上会使用一个名为 hyper 的第三方库,但在试用了 hyper 之后,我认为最好还是使用 Node 内置的 https 进行比较,因为它们都同样低级。

codeimport { get } from 'https';

const host = 'api.github.com';
const path = '/users/donaldpipowitch';

get({ host, path }, (res) => {
  let buf = '';
  res.on('data', (chunk) => (buf = buf + chunk));
  res.on('end', () => console.log(`Response: ${buf}`));
}).on('error', (err) => {
  throw `Couldn't send request.`;
});/code

我们从 https 导入 get 函数。我们声明主机和路径(由于已经使用了 https 模块,所以无需设置协议)。
然后,我们调用 get 并传递一个选项对象(包含主机和路径)和回调,回调接受一个响应对象(res)作为第一个参数。是的,get 并不遵循 Node 通常的回调模式,即第一个参数是错误,第二个参数是结果。它比这更低级。相反,我们有一个请求对象(get 的返回值)和一个响应对象(res),它们都是事件发射器。我们监听请求对象上的错误事件,如果出现错误,我们就抛出 "无法发送请求 "来退出程序。

我们会监听响应对象上的数据事件,并将每块新数据收集到名为 buf 的字符串中。如果响应对象上发生结束事件,我们就会知道我们拥有整个响应正文和日志 buf。

code$ npm run -s start
Response: Request forbidden by administrative rules. Please make sure your request has a User-Agent header (http://developer.github.com/v3/user-agent-required). Check https://developer.github.com for other possible causes./code

这是怎么回事?
原来,我们需要在 HTTP 头信息中设置用户代理,这样 GitHub 才能允许我们对其 API 进行 GET。如果点击错误中的链接,就能读到用户代理应该是我们的 GitHub 账户名或应用程序名。

但还不止这些。这个响应实际上是一个错误,因为它的状态代码是 403。但我们的错误事件监听器并没有捕捉到它。为什么呢?错误事件监听器只有在我们的请求无法完成时才会被调用。在这种情况下,我们实际上是提出了请求。从技术角度来说,请求是正确的。但服务器说我们的请求包含错误(缺少用户代理)。如果我们对客户端或服务器错误感兴趣,就需要手动抛出这些错误。在添加用户代理之前,我们先这样做。为此,我引入了两个小的辅助函数:

codeimport { get } from 'https';

const host = 'api.github.com';
const path = '/users/donaldpipowitch';

+function isClientError(statusCode: number) {
+  return statusCode >= 400 && statusCode < 500;
+}

+function isServerError(statusCode: number) {
+  return statusCode >= 500;
+}

get({ host, path }, (res) => {
  let buf = '';
  res.on('data', (chunk) => buf = buf + chunk);

-  res.on('end', () => console.log(`Response: ${buf}`));
+  res.on('end', () => {
+    console.log(`Response: ${buf}`);
+
+    if (isClientError(res.statusCode)) {
+      throw `Got client error: ${res.statusCode}`;
+    }
+    if (isServerError(res.statusCode)) {
+      throw `Got server error: ${res.statusCode}`;
+    }
+  });
}).on('error', (err) => {
  throw `Couldn't send request.`;
});/code

再次测试:
code$ npm run -s start
Response: Request forbidden by administrative rules. Please make sure your request has a User-Agent header (http://developer.github.com/v3/user-agent-required). Check https://developer.github.com for other possible causes.


/Users/pipo/workspace/rust-for-node-developers/http-requests/node/dist/index.js:21
            throw "Got client error: " + res.statusCode;
            ^
Got client error: 403/code

程序正确退出并记录状态代码。虽然不美观,但却能正常运行。

现在我们只需添加头文件。我们使用该版本库的名称作为用户代理:Mercateo/rust-for-node-developers.

codeimport { get } from 'https';

const host = 'api.github.com';
const path = '/users/donaldpipowitch';

function isClientError(statusCode: number) {
  return statusCode >= 400 && statusCode < 500;
}

function isServerError(statusCode: number) {
  return statusCode >= 500;
}

+const headers = {
+  'User-Agent': 'Mercateo/rust-for-node-developers'
+};

-get({ host, path }, (res) => {
+get({ host, path, headers }, (res) => {
  let buf = '';
  res.on('data', (chunk) => (buf = buf + chunk));

  res.on('end', () => {
    console.log(`Response: ${buf}`);

    if (isClientError(res.statusCode)) {
      throw `Got client error: ${res.statusCode}`;
    }
    if (isServerError(res.statusCode)) {
      throw `Got server error: ${res.statusCode}`;
    }
  });
}).on('error', (err) => {
  throw `Couldn't send request.`;
});/code

运行:
$ npm run -s start
Response: {"login":"donaldpipowitch","id":1152805, ...

成功了!你会看到一大团 JSON 作为我们的响应。现在这样就可以了。我将在下一个示例中介绍如何处理 JSON。

是时候转到 Rust 了。

bRust/b
我们将在 Rust 示例中使用一个名为  url=http://hyper.rs/hyper/url 的第三方库。它是在 Rust 中处理 HTTP(S) 的事实标准。不过,我得告诉你一些关于 Rust 中异步 API(如进行网络请求)的事情。

你可能知道 JavaScript 是单线程语言,所有异步 API 都由事件循环驱动。你可能还知道 Promises,它允许我们对异步控制流进行建模,而函数的异步/等待语法就是建立在 Promises 的基础之上。(免责声明:除了使用 Promises 之外,还有更多处理异步的方法。

Rust 的进展基本上也是如此:Rust 将有一个 async 关键字和一个 await! 宏,其行为类似于 JavaScripts 的 async/await。还有一个与 Promises 类似的概念,叫做 Futures,但还没有完全标准化。您可以在 "Are we async yet?但 Rust 与 JavaScript 也有一些关键区别:Rust 是多线程的,没有内置事件循环。

因此,您现在看到的一切都有可能在未来发生变化。请谨慎对待。

要使用 hyper,我们需要在 Cargo.toml 中添加这一部分。这还将添加 HTTPS 所需的 hyper-tls。看起来这是模块化设计,以便采用不同的解决策略来获取证书。

codedependencies
hyper = "0.12.21"
hyper-tls = "0.3.1"/code

让我们先来了解一下这次文件的基本情况:

codeuse hyper::rt::{run, Future, Stream};
use hyper::{Client, Request};
use hyper_tls::HttpsConnector;
use std::str::from_utf8;

fn main() {
    run(get());
}

fn get() -> impl Future<Item = (), Error = ()> {
    // more code here
}/code

据我所知,hyper 的 run 函数解决的问题与 JavaScript 中的事件循环类似。我们可以向 run 传递一个 Future,这样它就能知道异步 API 何时 "完成 "其工作。在 Promise 加入 hyper 语言之前,我们在 JavaScript 世界中使用过(有时仍在使用)蓝鸟(Bluebird)等第三方 Promise 库)。

正如你所看到的,我创建了一个名为 get 的自定义函数,它返回植入了 Future 的 "东西"。就像 Promises 可以解析或拒绝一样,我们的 Future 可以代表成功(Item)或错误(Error)的结果。但在这两种情况下,我对结果都不感兴趣,所以我只返回()。

现在我们只需研究一下 get 函数。

codefn get() -> impl Future<Item = (), Error = ()> {
    // 4 是阻塞 DNS 线程的数量
    let https = HttpsConnector::new(4).unwrap();

    let client = Client::builder().build(https);

    let req = Request::get("https://api.github.com/users/donaldpipowitch")
        .header("User-Agent", "Mercateo/rust-for-node-developers")
        .body(hyper::Body::empty())
        .unwrap();

    // more coded here
}/code

首先,我们创建 HttpsConnector,它最多允许 4 个阻塞 DNS 线程。(如果你不明白这一点,没关系。我们不需要从技术上理解这部分,就能大致理解这个示例。HttpsConnector 只是让我们能发出 HTTPS 请求)。正如你所看到的,我在这里使用了 unwrap() 而不是我们的 ? 操作符。目前,我们还无法在返回 Future 的函数中使用?遗憾的是,我找不到 RFC 或关于此主题的讨论,我想知道如何更优雅地处理这种情况。

我们接下来要创建的是一个客户端,它将发出实际请求。它使用了构建器模式,根据我的经验,这种模式在 Rust 生态系统中非常流行。在这种情况下,我们只需将 HttpsConnector 实例传递给构建器,就能得到一个客户端。

最后,我们配置请求。它将是一个 GET 请求(这就是为什么我们使用 Request::get),我们传递一个 url,设置 User-Agent 头信息,并设置一个空的 body。

现在,我们需要将配置好的请求传递给客户端,这样它才能真正执行请求,我们才能处理响应。

codefn get() -> impl Future<Item = (), Error = ()> {
    // previous code

    client
        .request(req)
        .and_then(|res| {
            let status = res.status();

            let buf = res.into_body().concat2().wait().unwrap();
            println!("Response: {}", from_utf8(&buf).unwrap());

            if status.is_client_error() {
                panic!("Got client error: {}", status.as_u16());
            }
            if status.is_server_error() {
                panic!("Got server error: {}", status.as_u16());
            }

            Ok(())
        })
        .map_err(|_err| panic!("Couldn't send request."))
}/code

客户端收到我们的请求,然后我们处理响应(通过使用 and_then,类似于 Promise 中的 then),或者处理错误(通过使用 map_err,类似于 Promise 中的 catch)。在出错的情况下,我们只是惊慌失措我们将对所有错误情形都这样做,这就是为什么我在函数签名中只写了 Error = () 的原因,因为我们不会返回有用的错误信息。

如果可以发出请求,我们将得到响应(res)。响应有几个有用的方法来提取状态(status())和正文(into_body().concat2().wait(),因为正文是分块的--类似于我们的 Node 示例)。通过 status.is_client_error() 和 status.is_server_error(),我们可以轻松检查 4xx 和 5xx 错误代码。status.as_u16() 返回纯状态代码(如 403),不含典型原因(如 Forbidden)。请注意,我们返回的是 Ok(()) 而不是 () ,因为 () 没有实现 Future 特性,而 Result 实现了 Future 特性。

如果您现在运行程序,应该会得到与 Node 示例中相同的输出结果。
$ cargo -q run
Response: {"login":"donaldpipowitch","id":1152805, ...

这很好,但实际上我向你们隐瞒了一个问题。在我最初的代码中,我是这样写的

code-            let status = res.status();
-
-            let buf = res.into_body().concat2().wait().unwrap();
-            println!("Response: {}", from_utf8(&buf).unwrap());

+            let buf = res.into_body().concat2().wait().unwrap();
+            println!("Response: {}", from_utf8(&buf).unwrap());
+
+            let status = res.status();/code

在我看来,这样做更合理,因为我是在使用了 buf 之后才使用状态的。但这会导致编译器出错:
code26 |             let buf = res.into_body().concat2().wait().unwrap();
   |                        --- value moved here
...
29 |             let status = res.status();
   |                          ^^^ value borrowed here after move/code

当一个变量的内容被 "移动moved "到其他地方后,如果试图使用该变量,就会发生这个错误。

这就是 Rust 的所有权模型。对我来说,这是迄今为止 Rust 中最难理解的新概念:
b在一个时间点上,某些内容或数据只能有一个 "所有者"。/b
list
*最初 res 保存的是与响应正文相对应的数据,但通过调用 res.into_body(),所有权被转移,并在最后交给了我们的 buf 变量。
*这一行之后,任何人都不能再访问 res。
/list

如果我们能通过调用 res.body()(类似于提供状态引用的 res.status())来创建对正文的引用,那就不成问题了,但我不确定是否有可能从引用的正文中以字符串形式获取实际的正文内容。

在下一个示例中,我将向您展示如何实际处理 JSON 响应。

b2、解析JSON/b
在解析JSON示例中,我们将请求特定 GitHub 用户的存储库,并记录存储库的名称和描述以及它是否已分叉。

bNodeJS/b
NodeJS不需要做太多改变就可以实现这一点:
codeimport { get } from 'https';

const host = 'api.github.com';
-const path = '/users/donaldpipowitch';
+const path = '/users/donaldpipowitch/repos';

function isClientError(statusCode: number) {
  return statusCode >= 400 && statusCode < 500;
}

function isServerError(statusCode: number) {
  return statusCode >= 500;
}

const headers = {
  'User-Agent': 'Mercateo/rust-for-node-developers'
};

+type Repository = {
+  name: string;
+  description: string | null;
+  fork: boolean;
+};

get({ host, path, headers }, (res) => {
  let buf = '';
  res.on('data', (chunk) => (buf = buf + chunk));

  res.on('end', () => {
-    console.log(`Response: ${buf}`);

    if (isClientError(res.statusCode)) {
      throw `Got client error: ${res.statusCode}`;
    }
    if (isServerError(res.statusCode)) {
      throw `Got server error: ${res.statusCode}`;
    }

+    const repositories: Repository = JSON.parse(buf).map(
+      ({ name, description, fork }) => ({ name, description, fork })
+    );
+    console.log('Result is:\n', repositories);
  });
}).on('error', (err) => {
  throw `Couldn't send request.`;
});/code
list
*我们可以使用全局 JSON 对象及其解析方法轻松解析存储在 buf 中的原始响应。
*然后,我们映射返回的数组,只提取名称、描述和 fork 字段。(请注意,我们只是假设 JSON.parse(buf) 返回一个数组。
*我们在这里是乐观的,因为我们认为自己了解 GitHub API,但为了真正安全起见,我们应该检查解析后的响应是否真的是一个数组。
*我们假设 name、description 和 fork 都存在,并且是字符串、布尔值,如果是 description,可能也是空值!这还是有点乐观。GitHub 可能会向我们发送不同的数据。作为开发者,您可以自行决定在这里进行多少次安全检查。
/list

我们还添加了一个名为 Repository 的类型来描述我们的响应格式。解析后的响应具有 Repository 类型(这意味着它是一个包含 Repository 的数组,也可以写成 Array<Repository>),并保存在存储库中。

在这种情况下,告诉 TypeScript 资源库的类型并不是强制性的,但它会使进一步使用资源库变得更容易、更安全,因为 TypeScript 现在会检查资源库的不正确使用。如果不明确添加类型,TypeScript 会默认将资源库视为任何类型,这将导致我们在使用资源库时根本不进行类型检查。

在本示例中,无需进行更多的运行时检查。让我们测试一下我们的程序:

code$ npm run -s start
Result is:
  { name: 'afpre',
    description: ' CLI for the AWS Federation Proxy',
    fork: true },
  { name: 'ajv',
    description: 'The fastest JSON schema Validator. Supports v5 proposals',
    fork: true },
  .../code


它可以工作!没什么复杂的,如果你经常使用应用程序接口,可能已经做过无数次了。

bRust/b
将字符串反序列化为 JSON 的最先进方法是使用 serde 和 serde_json 工具箱。

将这三个板块都添加到你的 Cargo.toml 中:

codepackage
-name = "http-requests"
+name = "parse-json"
version = "1.0.0"
publish = false

dependencies
hyper = "0.12.21"
hyper-tls = "0.3.1"
+serde = { version = "1.0", features = "derive" }
+serde_json = "1.0"/code

您在这里看到的是在 Cargo.toml 中配置单个板条箱的可能性。在本例中,我们启用了一项名为 serde 派生的功能,该功能默认情况下并未启用。这允许我们自动将 JSON 字符串反序列化为自定义结构体。

我们使用一种名为属性(attributes)的语言结构来实现这一功能。属性会改变所应用的项的含义。例如,项目可以是结构声明。它们被写成 test 或 #!test。test将应用于下一个项目,而 #!例如

codehello
struct SomeStruct;

fn some_function() {
    #!world
}/code

我们可以将附加数据传递给属性(inline(always))或键和值(cfg(target_os = "macos"))。
list
*我们感兴趣的属性叫做 derive:它会自动为自定义数据结构(本例中为 struct)实现某些特性。我们要派生的特质名为 Deserialize,来自 serde crate。我们还将派生内置的 Debug 特性,这样我们就可以打印我们的结构了。
*可以使用 struct 关键字创建自定义结构体。在我们的例子中,它有三个字段:name(字符串)、fork(bool)和 description(字符串)。
*要表达一个可能不可用的值,我们可以使用 Option。Option 有点像 Result,因为它显示了两种可能的结果:Result 有成功(Ok)和失败(Err)两种情况,而 Option 要么没有值(None 情况),要么有值(Some 情况)。
/list

我们就是这样定义名为 Repository 的自定义结构的:
codederive(Deserialize, Debug)
struct Repository {
    name: String,
    description: Option<String>,
    fork: bool,
}/code

让我们完整示例展示解析我们的字符串:
codeuse hyper::rt::{run, Future, Stream};
use hyper::{Client, Request};
use hyper_tls::HttpsConnector;
+use serde::Deserialize;
use std::str::from_utf8;

+derive(Deserialize, Debug)
+struct Repository {
+    name: String,
+    description: Option<String>,
+    fork: bool,
+}

fn main() {
    run(get());
}

fn get() -> impl Future<Item = (), Error = ()> {
    // 4 is number of blocking DNS threads
    let https = HttpsConnector::new(4).unwrap();

    let client = Client::builder().build(https);

-    let req = Request::get("https://api.github.com/users/donaldpipowitch")
+    let req = Request::get("https://api.github.com/users/donaldpipowitch/repos")
        .header("User-Agent", "Mercateo/rust-for-node-developers")
        .body(hyper::Body::empty())
        .unwrap();

    client
        .request(req)
        .and_then(|res| {
            let status = res.status();

-            let buf = res.into_body().concat2().wait().unwrap();
-            println!("Response: {}", from_utf8(&buf).unwrap());

            if status.is_client_error() {
                panic!("Got client error: {}", status.as_u16());
            }
            if status.is_server_error() {
                panic!("Got server error: {}", status.as_u16());
            }

+            let buf = res.into_body().concat2().wait().unwrap();
+            let json = from_utf8(&buf).unwrap();
+            let repositories: Vec<Repository> = serde_json::from_str(&json).unwrap();
+            println!("Result is:\n{:#?}", repositories);

            Ok(())
        })
        .map_err(|_err| panic!("Couldn't send request."))
}/code
在这里可以看到两个新事物。

我们在这里使用了 Vec 类型,因为我们从响应中获得了多个 Repository。

另一个新功能是在 println!
到目前为止,当我们记录一个值时,我们使用的 println! 宏是这样的:println!("Log: {}", some_value);。要做到这一点,some_value 实际上需要实现 Display 特性。

从 JavaScript 背景出发,你可以认为实现 Display 特性就是在自定义数据结构上提供格式化良好的 toString。
遗憾的是,Display 不能自动派生。但如果结构体中的所有字段都实现了 Debug,我们就可以为自定义结构体自动派生它。这就是我们在这里使用它的原因。这是一种记录自定义结构的简单方法。

与 println! 的用法略有不同。我们使用 {:?} 代替 {}。如果使用 {:#?},输出结果将被打印出来。(如果你对 Rust 中的字符串格式感到好奇,你还可以做更酷的事情,比如打印带前导零的数字)。

让我们试试我们的程序:

code$ cargo -q run
Result is:
Result is:

    Repository {
        name: "afpre",
        description: Some(
            " CLI for the AWS Federation Proxy"
        ),
        fork: true
    },
    Repository {
        name: "ajv",
        description: Some(
            "The fastest JSON schema Validator. Supports v5 proposals"
        ),
        fork: true
    },
    ...
/code