vlambda博客
学习文章列表

探索类型友好的 Node.js Web 框架 — Farrow

本篇文章是我对在 2021年08月01日深圳 GOTC 大会的《探索类型友好的 Node.js Web 框架》分享内容的整理与总结。

大家好,我是来自 ByteDance Web Infra 团队的马天琦。上次和大家见面还是以 Farrow 使用者的身份,向大家展示 Farrow 的优秀的特性,而现在我很有幸成为了 Farrow 的 Contributor,也为大家带来了更多更新的内容。

随着 TypeScript 的流行,类型系统也逐步进入了大家的视野,类型安全相关的问题也受到了更多人的关注,那今天我就以这个角度带大家感受 Farrow 在类型安全方面优秀方案的设计与思考,希望对大家能有所启发。

在本篇文章中,我将为大家带来以下的内容:

  • 类型安全 What & Why?
  • 当前 Node.js 主流 Web 框架现状
  • 当下的 API 设计中的类型问题
  • Farrow 类型安全方案
  • Farrow 未来规划

好,那我们现在开始。

类型安全

关于类型安全,可能很多同学已经有所了解,也了解过 Soundness[1]这个词,但也该也有许多同学不甚了解。

不了解的同学,你可以暂且将它简单的理解为:

变量的行为与它的类型相匹配,不存在运行时的类型错误。

在 JavaScript 中进行下面几个操作:

  • 访问 null 的属性
  • 将 string 类型当作 number 类型做运算
  • 调用对象不存在的方法

都会在运行时抛出类型错误。

那我们为什么要追求类型安全?

Well typed programs cannot go wrong. —— By Robin Milner 1978《A Theory of Type Polymorphism in Programming》[2]

正如上面这句话说的,类型系统可以有效的提升程序的正确性:

  • 尽可能在编译期通过类型检查提前捕获可能的程序错误,提高代码的健壮性
  • 配合编辑器类型提示,类型检查是比单元测试反馈更快、更早、覆盖更全面的实时测试
  • 符合类型安全准则的代码,往往是设计更合理、质量更高、编写更优雅的、表达更清晰的

类型检查的优势不用多说,要让我们的代码达到类型安全的状态,往往需要我们对要解决的问题进行很好的建模,所以从这个角度看,类型系统也可以帮助我们写出设计更合理、质量更高的代码。

主流框架现状

之前我们在实际的项目开发中遇到过 Node.js 框架选型的问题,经过调研,我们发现主流的 Node.js 框架:Express.jsKoaEggJSHapiRestifyFastify等都是用 JavaScript 实现的,他们充分发挥了 Javascript 的能力,但从类型安全的视角看,当前 Web 框架的设计存在诸多问题。

API 设计类型问题

接下来,我们就以 Express 为例来看一下。

请求意外挂起(Hanging Request)

我们发现以 Express 这样的中间件设计,它允许请求可以不被响应,也无法通过 TypeScript 的类型检查得到约束和提示。

app.use((req, res, next) => {
  // do nothing
});

错误的相应内容(Wrong Response)

同样的,我们无法保证 header -> body 这样正确的的响应次序

app.use((req, res, next) => {
  // body 提前发送
  res.end('xxx');
  // header 必须在 body 之前发送,这里报错或 warning
  res.header('Content-Type''text/html');
});

也无法在编译期约束只发送一次 body

app.use((req, res, next) => {
  // body 提前发送
  res.end('xxx');
  // body 只能发送一次,这里报错或 warning
  res.json({ errortrue });
});

而这些都会导致错误的响应内容。

篡改对象属性(Monkey Patching)

在 JavaScript 中,我们可以任意的修改对象的属性,但修改 req/res 或 ctx 污染全链路中间件的类型。这会导致,在任意一个中间件中,你都无法知道传到当前中间件的对象中到底有哪些属性和方法。

app.use((req, res, next) => {
  // what type of res.locals
  res.locals.a = 1;
  next();
});

app.use((req, res, next) => {
  // what type of res.locals
  console.log(res.locals.a);
});

当然有些框架支持一些类型标注的方案,来解决类型提示上的问题,但这并没有从根本上解决问题,从类型系统的角度来看,动态追加的属性或方法,与静态标注的类型有本质矛盾,正确的方式是让静态类型决定能否赋值属性,而非属性赋值决定是否包含特定类型。

无运行时验证(No Runtime Validation)

在当前 TypeScript 官方提供的工具链中,TypeScript 类型在编译后都被抹去,的确在大多数场景下,编译阶段前类型系统就完成了它的任务,但这也导致了一个严重的问题,请求的内容是未知的,通常需要手动进行校验。

app.use((req, res, next) => {
  req.body; // body type is any/unknown
  req.query; // query type is any/unknown
  const body = req.body as MyBodyType; // type wll be eliminated
});

如果请求参数比较复杂,通常需要编写很复杂的校验逻辑。

不友好的类型推导(Poor Type Inference)

现有框架在请求内容和响应内容的类型方面基本没有提供比较好的类型推导方案,很多时候我们需要手动的类型校验 + 类型转换。

app.get('/user/:userId', (req, res, next) => {
  req.params.userId; // no type infer
  const params = req.params as { userId: string };
  const userId = Number(params.userId); // 必须每次手动 transform
});

问题

到现在,我们已经提到了 5 条现有 API 设计中的类型问题:

  1. Hanging Request(请求意外挂起)
  2. Wrong Response(错误响应内容)
  3. Monkey Patching(篡改对象属性)
  4. No Runtime Validation(无运行时验证)
  5. Poor Type Inference(不友好的类型推导)

虽然这些框架不是使用 TypeScript 实现,但它们都提供了 @types/* 的类型包,但依旧存在诸多类型上的问题,可见只靠 *.d.ts,并不能获得充分的类型友好和类型安全特性。

我们对这些问题和现有的 Node.js 框架进行了系统性的调研和考量,发现:

  • 基于 Express/Koa 可以用打补丁的方式解决一两种类型问题,但不能从根本上解决问题
  • Fastify 提供了基于 JSON Schema 的运行时校验请求内容的方案,但方案与类型系统不贴合
  • 要充分解决系统性问题,则需要基于 TypeScript 做全盘的思考
  • Type-First Development 类型优先开发
  • Type-Driven Development 类型驱动开发

为了做到这些和解决上面提到的问题,我们就需要一个新的类型安全的服务端框架。

类型安全的服务端框架设计目标

根据之前的问题,我们可以得到类型安全的服务端框架设计目标:

  • Prevent Hanging Request(阻止请求意外挂起)
  • Refuse Wrong Response(拒绝错误响应内容)
  • No need to Monkey-Patching(无需篡改对象属性)
  • Embedded Runtime-Validation(内置运行时验证)
  • Excellent Type Inference(出色的类型推导)

从而就有了 Farrow 这样一个框架,接下来我就向大家介绍一下,Farrow 的一些设计和它是如何做到上面所说的事情。

Farrow-Http 设计

Prevent Hanging Request & Refuse Wrong Response

首先,为了可以做到 阻止请求意外挂起 和 拒绝错误响应内容,Farrow 重新设计了中间件,取消了响应参数,通过返回值表达响应结果。

import { Http, Response } from 'farrow-http';

const http = Http();

http.use((request, next) => {
  // response is return type
  return Response.text(request.pathname);
});

这样 TypeScript 也可以检查函数返回值类型是否满足约束,没有响应或者响应错误类型都会类型报错。

探索类型友好的 Node.js Web 框架 — Farrow

Prevent Wrong Response

为了进一步解决错误的相应内容的问题, Farrow 设计了 Virtual Response 虚拟响应对象,Response.text 等方法构造了朴素数据,类似 Virtual DOM,并未直接产生作用,多次使用将 merged 到一起,Farrow 框架内部最终统一按照正确顺序处理 header -> body 的次序和类型。

import { Http, Response } from 'farrow-http';

const http = Http();

http.use((request, next) => {
  // response is return type
  return Response.text(request.pathname)
    .header('abc''efg')
    .text('changed text');
});

No need to Monkey-Patching(Request)

为了解决 Monkey-Patching 的问题,即不再推荐和引导开发者去修改 req 请求对象,Farrow 设计了 Virtual Request 虚拟请求对象,所以传入中间件的请求对象不是原生 req 对象,而是从中提取的 plain data,所以可以通过 next(newRequest) 向后传递新的 request 对象,无需修改原对象。

import { Http, Response } from 'farrow-http';

const http = Http();

http.use((request, next) => {
  return next({
    ...request,
    pathname: '/another/pathname',
  });
});

http.use((request, next) => {
  request.pathname; // is equal to /another/pathname
});

No need to Monkey-Patching(Response)

为了进一步解决 Monkey-Patching 的问题,Farrow 重新设计了中间件的管理机制,next 将会返回下游中间件的 response 对象,可以加以后续处理,这样就可以做到无需修改 res/ctx.body ,immutable 比 mutable 更加类型友好, prefer immutable。

import { Http, Response } from 'farrow-http';

const http = Http();

http.use((request, next) => {
  let response = await next();
  // 合并,组合,过滤,拼装新的 response
  return Response.header('abc''efg').merge(response);
});

http.use((request, next) => {
  return Response.text('hello world!');
});

No need to Monkey-Patching(Middleware)

虽然之前的方面解决了修改请求对象的问题,但中间件间共享变量的需求依旧没有被解决,所以 Farrow 提供了 Context + Hooks 的方案,他们的工作机制类似 React Context 和 React Hooks,类似跨组件传递数据那样,跨中间件传递 Context Data,这样中间件间的共享变量就无需挂载到 req 对象上了,并且得益于 Node.js 的新特性 Async hooks,Farrow 能够提供按需的、分布式的、细粒度的、关注度分离的、类型安全的 Context Passing 机制。

import { Http, Response, createContext } from 'farrow-http';

const http = Http();

// 创建 Context
const AuthContext = createContext<Auth | null>(null);

// 更新 Context
http.use(async (request, next) => {
  AuthContext.set(await getAuth(request));
  return next();
});

// 不管中间插入多少中间件,request/response 类型都不会污染

// 消费 Context
http.use((request, next) => {
  // 跨中间件访问 context 数据
  let auth = AuthContext.get();
  return Response.text('hello world!');
});

Embedded Runtime-Validation & Excellent Type Inference(Schema)

为了提供运行时验证和更友好的类型推导能力,Farrow 设计了一套对 TypeScript 开发者非常友好的 Schema Builder,从而基于 Schema 提供了 Runtime Validation 机制,允许开发者使用 Schema Builder 去描述请求的形状,基于这个形状 Farrow 会自动推导出请求对象的类型,这样就保证了在运行时请求对象对象的值将会满足 Schema 所描述的形状。这样我们就同时提供了运行时校验和友好的类型推导。

import { Http, Response } from 'farrow-http';
import { Int } from 'farrow-schema';

const http = Http();

http
  .match({
    pathname: '/user',
    method: 'post',
    body: {
      userId: Int,
      userName: String,
      userAge: Int,
    },
  })
  .use((request, next) => {
    // request.body is { userId, userName, userAge }
    console.log('userId', request.body.userId);
    console.log('userName', request.body.userName);
    console.log('userAge', request.body.userAge);
  });

Embedded Runtime-Validation & Excellent Type Inference(URL)

后来我们发现很多时候我们又好像并不需要这么复杂的数据结构,所以 Farrow 提供了一种更简单的描述方式:

import { Http, Response } from 'farrow-http';
import { Int } from 'farrow-schema';

const http = Http();

http
  .get('/greet/<name:string>?<age:int>&farrow=type-safety')
  .use((request, next) => {
    // type infer for request from url
    console.log('name', request.params.name);
    console.log('age', request.query.age);
    console.log('farrow', request.query.farrow);
  });

它是基于 TypeScript 4.1 发布的 Template literal type 特性实现的,从 URL 中提取 TypeScript 类型,然后自动识别是 params 参数还是 query 参数,自动将 String 转换成标记的 Int 、Boolean 等 Schema 类型,基于这个我们也可以同时提供了运行时校验和友好的类型推导。

以上 farrow-http 运用 Type-First Development 思想和 React 启发的函数式/immutable 理念,系统性地提升了 Web Framework 的类型安全水平,解决了以下的问题:

  • Prevent Hanging Request(阻止请求意外挂起)√
  • Refuse Wrong Response(拒绝错误响应内容)√
  • No need to Monkey-Patching(无需篡改对象属性)√
  • Embedded Runtime-Validation(内置运行时验证)√
  • Excellent Type Inference(出色的类型推导)√

新的挑战:端到端类型同步

farrow-Http 优化了 Service Side 的类型安全,只解决了一半问题,End-to-end typing 将是一个新的问题 Client Side 如何复用 Service Side 的类型?Client Side 类型如何跟 Service Side 保持一致和同步?所以我们重新思考:BFF 应该为前端提供什么?

  • 传统 BFF:为前端提供 data
  • 现代 BFF:为前端提供 data 和 type
  • 后现代 BFF:为前端提供 data,type 和 code

为了做到这些,Farrow 提供了一个新的方案:farrow-api。

Farrow-API 设计

Farrow 采用了 Introspection + Codegen 的方式来实现为前端提供 data,type 和 code。提供了类似 GraphQL 的 Introspection 机制,支持拉取 farrow-api 的 Schema 数据,然后通过 Code Generation 生成 TypeScript Type 和 HTTP Client Code。

探索类型友好的 Node.js Web 框架 — Farrow

在服务器端,描述请求和响应的形状,然后聚合成 Farrow API

探索类型友好的 Node.js Web 框架 — Farrow

然后为该 API 实现请求处理函数

探索类型友好的 Node.js Web 框架 — Farrow

然后启动 Server,在客户端就可以生成下面的代码

探索类型友好的 Node.js Web 框架 — Farrow

而在客户端开发者只需要引入生成的函数,然后调用

探索类型友好的 Node.js Web 框架 — Farrow

除此之外,farrow-api 还支持其他描述 API 的属性,比如 @deprecated 标记

至此 Farrow 实现了服务器端的类型安全,也解决了 C/S 模型下的类型同步问题。

Farrow 蓝图和未来展望

优势

除了类型安全之外,Farrow 的设计还带来了另外的一些优势,拥有 Schema 之后可以形成接口的知识库,知识库可以用来做很多事情,函数级别的接口监控、测试和版本控制。

未来规划

Farrow 目前的规划中有两个主要的方向:

首先是生态,因为目前 Farrow 的开发团队比较小,所以不管是一些基础的工具库还是文档、最佳实践都是缺失和不完善的,但这些内容缺失导致很少的开发者能够了解 Farrow 并使用它,所以这将是接下来 Farrow 团队的比较主要的工作方向。除此之外,是基础能力。Farrow 目前还不够系统,我们还没有将它的潜力完全发挥出来,所以也会有一大部分精力投入在继续探索它能力的边界。

Bonus: farrow-express & farrow-koa

需要告诉大家的一个好消息是:Farrow 现在已经可以通过 adapter 复用 Express/Koa 等生态:

farrow-express:将 farrow-http 运行在 Express App 上 farrow-koa:将 farrow-http 运行在 Koa App 上

总结

在本篇文章中

  • 我们了解了类型安全的定义及其价值
  • 我们看到了当前 Node.js Web 框架中存在的类型问题
  • 我们看到了 Farrow-HTTP 如何通过类型优先和函数式的思路,系统性地改善类型问题
  • 我们看到了 Farrow-API 如何贯通前后端类型
  • 我们了解了现在立刻能就在 Express/Koa 等应用中使用 Farrow 的方式
  • 我们了解了 Farrow 以及其它追求类型安全的框架将来要解决的问题

参考资料

[1]

Soundness: https://en.wikipedia.org/wiki/Soundness

[2]

《A Theory of Type Polymorphism in Programming》: https://homepages.inf.ed.ac.uk/wadler/papers/papers-we-love/milner-type-polymorphism.pdf

- END -
往期精彩回顾





Nodejs技术栈
聚集所有 Nodejs 爱好者,共建互帮互助的 Nodejs 技术栈交流平台
112篇原创内容
Official Account