阅读 562

Farrow 初探:与 Express/Koa/GraphQL 等框架对比

几个月前我的 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 的类型中。

image.png

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 编写后端接口,并生成代码给前端使用

接口入参与返回值:简单的接口实现

我这个项目内容非常简单,没有动态的数据变更,完全可以做成静态页面,但我依旧把它做成了支持 SSR 的 SPA 项目。希望各位看官不要在意这一点,多关注 farrow 的特性。

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 apiclient api 查看完整实现。客户端同步代码的实现则在 syncClient.ts

也可以访问 Farrow 项目,了解更多。

Farrow 使用总结

  • 最明显的感受,类型衔接真的很流畅,asany@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
文章分类
前端
文章标签