最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Farrow 初探:与 Express/Koa/GraphQL 等框架对比

    正文概述 掘金(MTQ)   2021-03-31   934

    几个月前我的 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 初探:与 Express/Koa/GraphQL 等框架对比

    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 使用总结

    • 最明显的感受,类型衔接真的很流畅,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 。

    起源地下载网 » Farrow 初探:与 Express/Koa/GraphQL 等框架对比

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元