几个月前我的 Mentor 也就是 @工业聚 开发了一个新的 Node.js Web 框架: Farrow。
这篇文章的内容就是围绕这个框架展开,如果你还不了解 Farrow,可以移步 @工业聚 大大介绍 Farrow 的文章。
可以简单概括这个框架的特点:友好的 TypeScript 类型和函数式风格。
作为 TypeScript 和函数式编程的爱好者,在这个框架完成之初,我就成为了第一个吃螃蟹的人。
我基于这个框架重构了我的个人博客项目,也趁此机会谈一下我体会到的 Farrow 相对现有的 Express/Koa/GraphQL 的优势,以及不足。
虽然我有一定的 Express/Koa/GraphQL 的使用经验,碍于职业生涯的长度,不管是在框架设计方面涉及的广度和深度还是开发经验来说,我都尚有不足的。因此文章中出现错误和偏颇,在所难免,希望各位看官手下留情。
好,话不多说,我们开始吧。
现有框架的问题
已有框架的能力已经非常强大,基本可以覆盖所有场景,因此问题不在能力方面,而是易用程度。
在使用 Express/Koa/GraphQL 时遇到的共同的问题是类型闭合,也叫类型安全,即 @工业聚 在文章中说的 type safe。
可能由于 Express/Koa/GraphQL 出现的时候,TypeScript 还有没有兴起,所以这个问题在它们中都比较明显。
主要出在三个地方:共享上下文(Context)、接口入参类型(input type)和接口返回值类型(output type)。
共享上下文(Context)
在不同的框架中,它们都是如何实现 requestHandler/resolver 间变量共享的?
GraphQL 挂载在 ctx
上
Koa 挂载在 ctx
上
Express 挂载在 req
上
可以看出,目前的解决方案都是将变量挂载在一个会被传递给每一个 requestHandle/resolver 的对象上。
这种方案的问题就是类型,一个requestHandle/resolver 它如何知道传递给它的那个对象中有没有挂载哪个变量?
它无法预测,而在实际的场景中,常用的做法是 @ts-ignore
或者构造一个有那个变量的 ctx/req
类型,然后使用 as
。
我不知道其他人如何评价这两种方案,我个人认为它们应该是后门(backdoor),不应频繁使用。
接口入参(input type)
这里的期望接口入参是 type-safe 类型安全的,是指校验入参类型是否正确,并体现在类型上。
GraphQL 做了类型校验,Express/Koa 等框架需要引入 joi/ajv/superstruct 等库配合。 GraphQL 的类型校验难以体现到 TypeScript 类型上,在客户端开发的时候依旧需要使用 as,也并不便利。
RESTful 支持通过URL 传参,然而对 URL 中参数的类型校验,并且体现在语言的类型上,也是必要的。这里可以参考一个 Rust 的 Web 框架:Rocket。
接口返回值(output type)
在不同的框架中,他们都是如何设置接口的返回值的?
Express 通过调用 res.send
Koa 通过设置 ctx.body
GraphQL 通过 resolver 函数的返回值
而这些设置方式,都无法反映在语言层面的类型上,进行语言层面的类型约束(GraphQL 可以做到,但需要做很多事情)。
在服务器端无法在语言层面约束返回值类型,只能在逻辑层面去约束。客户端请求时的问题,客户端无法知道接口返回值的类型,不管是使用 Express、Koa 还是 GraphQL,客户端都只能进行假设。
在使用 GraphQL 时,接口入参和接口返回值类型约束的部分,我做过一些尝试。在服务器端将 GraphQL schema 转成 TypeScript 类型,并将之对应到不同的 Query,同步到客户端,但遇到了如下问题:
- GraphQL Schema 的类型系统和 TypeScript 的类型系统不是同构的,即无法完美的互相转换,导致使用时体验很差。
- 因为 GraphQL 支持请求合并和数据切片,所以如果要有完整的体验,在客户端编写 Query 语句的时候还要做一些其他事情,当然,这一部分是可以做到的(如 facebook 的 relay 框架通过 compiler 去提取)。但考虑到第一个问题,即使这一部分做好了,使用体验也会是差强人意。
GraphQL 同时也会增加代码量。同样功能的接口,除了业务逻辑处理部分的代码 GraphQL 需要的代码量和 RESTful 根本不是一个数量级,使用 GraphQL 完成一次请求需要写 2 份类型(服务器端的 Schema 、客户端的 Query),如果使用 TypeScript,则将会变成 4 份(加上服务器端的入参类型、客户端的入参类型与返回值类型),这是一个不小得负担。
Farrow 提供的解决方式
友好的变量共享:Context
在 Farrow 中内置了类似 React Context 的工具,创建 Context,然后可以在所有中间件中通过 Hook 拿到 Context 中的值,而不必通过参数。不必重复标记 ctx 参数类型。
Farrow Context 可以做到同一个请求的中间件和 requestHandler 间的变量共享,还可以做到不同的请求之间的变量共享,这个工具的具体细节可以查看 Farrow 文档,大佬可以直接看源码。
接口入参与返回值校验(input type & output type)
在上面的讨论中可以发现,Express 和 Koa 的方案没有内置这个校验功能,而 GraphQL尽管内置了,但有类型系统跟 TS 的同步问题。
Farrow 的方式是,自己实现了一套与 TypeScript 类型同构的类型系统,一次请求只需要实现一份类型,其他的通过推导、生成的方式(introspection)完成,从而也规避了 GraphQL 代码量增加的问题。
除此之外,基于 TypeScript 4.1 发布的特性——Template Literal Types,Farrow 实现了 Rocket 框架所实现的对 URL 中参数的校验并映射到了 TypeScript 的类型中。
Farrow 实战
现在结合我重构个人博客项目:me 的具体场景,来呈现一下上述方案的具体使用方式。
技术栈使用了 react-torch(我基于 React 和 Webpack 实现的简单的 SSR 框架)、farrow。
通过 Farrow Context 跨多个中间件共享变量
原因:webpack-dev-middleware 会在 webpack 打包完成之后将打包的信息 stats
挂载在 res.locals.webpack
上,而这些信息在 SSR 的时候会用到,当然这个不重要,你现在只需要知道后面的一个 requestHandler 需要用到一个变量,而这个变量需要在这个中间件中设置好,即需要共享变量。
像 React 那样创建 Context 和 Hook
import { createContext } from 'farrow-pipeline'
const WebpackContext = createContext<WebpackContextType | null>(null)
export const useWebpackCTX = (): WebpackContextType => {
let ctx = WebpackContext.use()
if (ctx.value === null) {
throw new Error(`assest not found`)
}
return ctx.value
}
编写 Farrow 中间件,动态更新 Context value
const ctx: WebpackContextType = {
assets: {},
}
export const webpackDevMiddleware = (
compiler: Compiler
): HttpMiddleware => {
compile(compiler, ctx)
return async (_, next) => {
const userCtx = WebpackContext.use()
userCtx.value = ctx
return next()
}
}
const compile = (compiler: Compiler, context: WebpackContextType) => {
...
function done(stats: Stats) {
...
context.assets = webpackStats.assets
...
}
compiler.hooks.done.tap('WebpackDevMiddleware', done)
...
}
挂载中间件到 farrow-http pipeline
import { Http } from 'farrow-http'
import webpack from 'webpack'
const http = Http()
const config = { ... }
const compiler = webpack(config)
http.use(webpackDevMiddleware(compiler))
然后,在任意中间件里,通过 hooks 访问 Context value。不用修改参数。也不用标记类型。
http.use(async (req) => {
const webpackCTX = useWebpackCTX()
// 拿到变量
const assets = webpackCTX.assets
...
})
...
到此我们就完整的实现了这个功能,而且实现过程类型安全。为了演示,我删除了部分不相关的代码,这个功能的完整实现可以去 webpackHook.ts 查看。
使用 Farrow-Api 编写后端接口,并生成代码给前端使用
接口入参与返回值:简单的接口实现
1)用 farrow-schema 定义数据类型:model type
import { Int, List, ObjectType, Type, Literal, TypeOf } from 'farrow-schema'
export const Numbers = List(Number)
export class Note extends ObjectType {
id = {
description: `Note id`,
[Type]: Int,
}
title = {
description: `Note title`,
[Type]: String,
}
...
tags = {
description: `Note tags`,
[Type]: Numbers,
}
}
2)定义接口入参与返回值类型(这里和 GraphQL 的 Schema 很像):input type 和 output type
import { ObjectType, Type, Literal, Union } from 'farrow-schema'
// get notes 不需要参数,因此留空
export const GetNotesInput = {}
export const NoteList = List(Note)
export class GetNotesSuccess extends ObjectType {
type = Literal('GetNotesSuccess')
notes = {
description: 'Note list',
[Type]: NoteList,
}
}
export class SystemError extends ObjectType {
type = Literal('SystemError')
message = {
description: 'SystemError message',
[Type]: String,
}
}
export const GetNotesOutput = Union(GetNotesSuccess, SystemError)
3)有了 input type 和 output type,可以构建 API 函数
import { Api } from 'farrow-api'
export const getNotes = Api({
description: 'get notes',
input: GetNotesInput,
output: GetNotesOutput,
})
4)为 getNotes API 函数实现 handler
getNotes.use(() => {
try {
const notes = require(path.resolve(process.cwd(), './data/notes.json'))
return {
type: 'GetNotesSuccess',
notes,
}
} catch (err) {
return {
type: 'SystemError',
message: JSON.stringify(err),
}
}
})
5)合并 API 为 Service,以便挂载到 http pipeline 里。
import { ApiService } from 'farrow-api-server'
export const entries = {
getNotes,
// 这里可以添加其他的 API
}
export const notesService = ApiService({ entries })
6)挂载 Service
import { Http } from 'farrow-http'
const http = Http()
http.route('/api').use(notesService)
...
7)配置客户端代码生成规则
// 启动服务器,运行以下代码
import { createApiClients } from 'farrow/dist/api-client'
export const syncClient = () => {
const client = createApiClients({
services: [
{
src: `http://localhost:3000/api`,
dist: `${__dirname}/src/api/model.ts`,
alias: '/api',
},
],
})
return client.sync()
}
生成给客户端使用的代码如下:
import { apiPipeline } from 'farrow-api-client'
/**
* {@label SystemError}
*/
export type SystemError = {
type: 'SystemError'
/**
* @remarks SystemError message
*/
message: string
}
/**
* {@label Note}
*/
export type Note = {
/**
* @remarks Note id
*/
id: number
...
/**
* @remarks Note tags
*/
tags: number[]
}
/**
* {@label GetNotesSuccess}
*/
export type GetNotesSuccess = {
type: 'GetNotesSuccess'
/**
* @remarks Note list
*/
notes: Note[]
}
export const url = '/api'
export const api = {
/**
* @remarks get notes
*/
getNotes: (input: {}) =>
apiPipeline.invoke(url, { path: ['getNotes'], input }) as Promise<
GetNotesSuccess | SystemError
>,
}
在客户端,我们不必再从头编写如何 fetch 接口数据的代码。而是直接 import 前面生成的代码文件。直接接口调用,如下所示:
api.getNotes({}).then((res) => {
switch (res.type) {
case 'GetNotesSuccess': {
store.dispatch({
type: 'SET_NOTES',
payload: res.notes,
})
break
}
case 'SystemError': {
store.dispatch({
type: 'SET_ERRORS',
payload: [res.message],
})
break
}
}
})
这一部分的详细实现已经开源,可以点击 server api 或 client api 查看完整实现。客户端同步代码的实现则在 syncClient.ts。
也可以访问 Farrow 项目,了解更多。
Farrow 使用总结
- 最明显的感受,类型衔接真的很流畅,
as
、any
、@ts-ignore
不存在的。对于有强迫症的 TypeScript 开发者来说,简直就是福音。 - 类型系统相对 GraphQL 好了很多,没有那么多的东西需要写。
- 在项目重构的过程中,我还向 Farrow 项目提了许多 issue 和 PR,并对项目中用的 webpack-dev-middleware 进行了重构。
在使用 Farrow-Api 重构个人项目后,我还发现 Farrow-Api 也可以像 GraphQL 那样 batch 多个接口请求,将它们合并为一次,从而减少前后端 http request 数量,提升性能。后续我将尝试实现它,验证一下,然后提 Pull-Request。
个人结论
Farrow 的优势:
- 类型安全。类型系统和 TypeScript 类型生成(introspection)优势很大,如果结合 sequelize ,应该可以实现从数据库到前端应用的类型安全。
Farrow 的不足:
- 生态不健全。在实践过程中需要做好自己造轮子的准备,这无疑增加了工作量,对开发者也是一种考验。
- 只针对 Node.js 技术栈,目前没有支持其他语言的计划。
对 Farrow 的未来展望
- 类型系统还有提升空间。如果类型系统做成像 GraphQL Schema 一样语言无关的 DSL,然后服务器端和客户端都根据这个生成部分代码,有望支持更多语言。
- 支持 Deno 。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!