概述
这次 Node.js 服务框架的调研将着点于各框架功能、请求流程的组织和介入方式,以对前端 Node.js 服务设计和对智联 Ada 架构改进提供参考,不过多关注具体实现。
最终选取了以下具有代表性的框架:
- Next.js、Nuxt.js:它们是分别与特定前端技术 React、Vue 绑定的前端应用开发框架,有一定的相似性,可以放在一起进行调研对比。
- Nest.js:是“Angular 的服务端实现”,基于装饰器。可以使用任何兼容的 http 提供程序,如 Express、Fastify 替换底层内核。可用于 http、rpc、graphql 服务,对提供更多样的服务能力有一定参考价值。
- Fastify:一个使用插件模式组织代码且支持并基于 schema 做了运行效率提升的比较纯粹的偏底层的 web 框架。
Next.js、Nuxt.js
这两个框架的重心都在 Web 部分,对 UI 呈现部分的代码的组织方式、服务器端渲染功能等提供了完善的支持。
- Next.js:React Web 应用框架,调研版本为 12.0.x。
- Nuxt.js:Vue Web 应用框架,调研版本为 2.15.x。
功能
首先是路由部分:
页面路由:
- 相同的是两者都遵循文件即路由的设计。默认以 pages 文件夹为入口,生成对应的路由结构,文件夹内的所有文件都会被当做路由入口文件,支持多层级,会根据层级生成路由地址。同时如果文件名为 index 则会被省略,即 /pages/users 和 /pages/users/index 文件对应的访问地址都是 users。
不同的是,根据依赖的前端框架的不同,生成的路由配置和实现不同:
- Next.js:由于 React 没有官方的路由实现,Next.js 做了自己的路由实现。
- Nuxt.js:基于 vue-router,在编译时会生成 vue-router 结构的路由配置,同时也支持子路由,路由文件同名的文件夹下的文件会变成子路由,如 article.js,article/a.js,article/b.js,a 和 b 就是 article 的子路由,可配合
api 路由:
- Next.js:在 9.x 版本之后添加了此功能的支持,在 pages/api/ 文件夹下(为什么放在pages文件夹下有设计上的历史包袱)的文件会作为 api 生效,不会进入 React 前端路由中。命名规则相同,pages/api/article/[id].js -> /api/article/123。其文件导出模块与页面路由导出不同,但不是重点。
- Nuxt.js:官方未提供支持,但是有其他实现途径,如使用框架的 serverMiddleware 能力。
动态路由:两者都支持动态路由访问,但是命名规则不同:
- Next.js:使用中括号命名,/pages/article/[id].js -> /pages/article/123。
- Nuxt.js:使用下划线命名,/pages/article/_id.js -> /pages/article/123。
- 路由加载:两者都内建提供了 link 类型组件(
Link
和NuxtLink
),当使用这个组件替代标签进行路由跳转时,组件会检测链接是否命中路由,如果命中,则组件出现在视口后会触发对对应路由的 js 等资源的加载,并且点击跳转时使用路由跳转,不会重新加载页面,也不需要再等待获取渲染所需 js 等资源文件。
- 出错兜底:两者都提供了错误码响应的兜底跳转,只要 pages 文件夹下提供了 http 错误码命名的页面路由,当其他路由发生响应错误时,就会跳转到到错误码路由页面。
在根据文件结构生成路由配置之后,我们来看下在代码组织方式上的区别:
路由组件:两者没有区别,都是使用默认导出组件的方式决定路由渲染内容,React 导出 React 组件,Vue 导出 Vue 组件:
Next.js:一个普普通通的 React 组件:
export default function About() { return
About us}Nuxt.js:一个普普通通的 Vue 组件:
About us
文档:即 html 模板,两者的 html 模板都是唯一的,会对整个应用生效:
Next.js:改写 pages 根路径下唯一的 _document.js,会对所有页面路由生效,使用组件的方式渲染资源和属性:
import Document, { Html, Head, Main, NextScript } from 'next/document' class MyDocument extends Document { render() { return (
) } } export default MyDocument Nuxt.js:改写根目录下唯一的 App.html,会对所有页面路由生效,使用占位符的方式渲染资源和属性:
{{ HEAD }} {{ APP }}
head 部分:除了在 html 模板中直接写 head 内容的方式,如何让不同的页面渲染不同的 head 呢,我们知道 head 是在组件之外的,那么两者都是如何解决这个问题的呢?
Next.js:可以在页面路由组件中使用内建的 Head 组件,内部写 title、meta 等,在渲染时就会渲染在 html 的 head 部分:
import Head from 'next/head' function IndexPage() { return (
My page title Hello world!
Nuxt.js:同样可以在页面路由组件中配置,同时也支持进行应用级别配置,通用的 script、link 资源可以写在应用配置中:
在页面路由组件配置:使用 head 函数的方式返回 head 配置,函数中可以使用 this 获取实例:
{{ title }}
nuxt.config.js 进行应用配置:
export default { head: { title: 'my website title', meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { hid: 'description', name: 'description', content: 'my website description' } ], link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] } }
除去基本的 CSR(客户端渲染),SSR(服务器端渲染)也是必须的,我们来看下两者都是怎样提供这种能力的,在此之外又提供了哪些渲染能力?
服务器端渲染:众所周知的是服务器端渲染需要进行数据预取,两者的预取用法有何不同?
Next.js:
可以在页面路由文件中导出 getServerSideProps 方法,Next.js 会使用此函数返回的值来渲染页面,返回值会作为 props 传给页面路由组件:
export async function getServerSideProps(context) { // 发送一些请求 return { props: {} } }
- 上文提到的容器组件也有自己的方法,不再介绍。
- 渲染过程的最后,会生成页面数据与页面构建信息,这些内容会写在
中渲染到客户端,并被在客户端读取。
Nuxt.js:数据预取方法有两个,分别是 asyncData、fetch:
- asyncData:组件可导出 asyncData 方法,返回值会和页面路由组件的 data 合并,用于后续渲染,只在页面路由组件可用。
- fetch:在 2.12.x 中增加,利用了 Vue SSR 的 serverPrefetch,在每个组件都可用,且会在服务器端和客户端同时被调用。
- 渲染过程的最后,页面数据与页面信息写在 window.__NUXT__ 中,同样会在客户端被读取。
静态页面生成 SSG:在构建阶段会生成静态的 HTML 文件,对于访问速度提升和做 CDN 优化很有帮助:
Next.js:在两种条件下都会触发自动的 SSG:
- 页面路由文件组件没有 getServerSideProps 方法时;
页面路由文件中导出 getStaticProps 方法时,当需要使用数据渲染时可以定义这个方法:
export async function getStaticProps(context) { const res = await fetch(`https://.../data`) const data = await res.json() if (!data) { return { notFound: true, } } return { props: { data } } }
- Nuxt.js:提供了命令 generate 命令,会对整站生成完整的 html。
- 不论是那种渲染方式,在客户端呈现时,页面资源都会在头部通过 rel="preload" 的方式提前加载,以提前加载资源,提升渲染速度。
在页面渲染之外的流程的其他节点,两者也都提供了的介入能力:
- Next.js:可以在 pages 文件夹下的各级目录建立 _middleware.js 文件,并导出中间件函数,此函数会对同级目录下的所有路由和下级路由逐层生效。
Nuxt.js:中间件代码有两种组织方式:
- 写在 middleware 文件夹下,文件名将会成为中间件的名字,然后可以在应用级别进行配置或 Layout 组件、页面路由组件中声明使用。
- 直接在 Layout 组件、页面路由组件写 middleware 函数。
应用级别:在 middleware 中创建同名的中间件文件,这些中间件将会在路由渲染前执行,然后可以在 nuxt.config.js 中配置:
// middleware/status.js 文件 export default function ({ req, redirect }) { // If the user is not authenticated // if (!req.cookies.authenticated) { // return redirect('/login') // } }
// nuxt.config.js export default { router: { middleware: 'stats' } }
组件级别:可以在 layout或页面组件中声明使用那些 middleware:
export default { middleware: ['auth', 'stats'] }
也可以直接写全新的 middleware:
在编译构建方面,两者都是基于 webpack 搭建的编译流程,并在配置文件中通过函数参数的方式暴露了 webpack 配置对象,未做什么限制。其他值得注意的一点是 Next.js 在 v12.x.x 版本中将代码压缩代码和与原本的 babel 转译换为了 swc,这是一个使用 Rust 开发的更快的编译工具,在前端构建方面,还有一些其他非基于 JavaScript 实现的工具,如 ESbuild。
在扩展框架能力方面,Next.js 直接提供了较丰富的服务能力,Nuxt.js 则设计了模块和插件系统来进行扩展。
Nest.js
Nest.js 是“Angular 的服务端实现”,基于装饰器。Nest.js 与其他前端服务框架或库的设计思路完全不同。我们通过查看请求生命周期中的几个节点的用法来体验下 Nest.js 的设计方式。
先来看下 Nest.js 完整的的生命周期:
- 收到请求
中间件
- 全局绑定的中间件
- 路径中指定的 Module 绑定的中间件
守卫
- 全局守卫
- Controller 守卫
- Route 守卫
拦截器(Controller 之前)
- 全局
- Controller 拦截器
- Route 拦截器
管道
- 全局管道
- Controller 管道
- Route 管道
- Route 参数管道
- Controller(方法处理器)
- 服务
拦截器(Controller 之后)
- Router 拦截器
- Controller 拦截器
- 全局拦截器
异常过滤器
- 路由
- 控制器
- 全局
- 服务器响应
可以看到根据功能特点拆分的比较细,其中拦截器在 Controller 前后都有,与 Koa 洋葱圈模型类似。
功能设计
首先看下路由部分,即最中心的 Controller:
路径:使用装饰器装饰 @Controller 和 @GET 等装饰 Controller 类,来定义路由解析规则。如:
import { Controller, Get, Post } from '@nestjs/common' @Controller('cats') export class CatsController { @Post() create(): string { return 'This action adds a new cat' } @Get('sub') findAll(): string { return 'This action returns all cats' } }
定义了 /cats post 请求和 /cats/sub get 请求的处理函数。
响应:状态码、响应头等都可以通过装饰器设置。当然也可以直接写。如:
@HttpCode(204) @Header('Cache-Control', 'none') create(response: Response) { // 或 response.setHeader('Cache-Control', 'none') return 'This action adds a new cat' }
参数解析:
@Post() async create(@Body() createCatDto: CreateCatDto) { return 'This action adds a new cat' }
- 请求处理的其他能力方式类似。
再来看看生命周期中其中几种其他的处理能力:
中间件:声明式的注册方法:
@Module({}) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer // 应用 cors、LoggerMiddleware 于 cats 路由 GET 方法 .apply(LoggerMiddleware) .forRoutes({ path: 'cats', method: RequestMethod.GET }) } }
异常过滤器(在特定范围捕获特定异常并处理),可作用于单个路由,整个控制器或全局:
// 程序需要抛出特定的类型错误 throw new HttpException('Forbidden', HttpStatus.FORBIDDEN)
// 定义 @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp() const response = ctx.getResponse
() const request = ctx.getRequest () const status = exception.getStatus() response .status(status) .json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, }) } } // 使用,此时 ForbiddenException 错误就会被 HttpExceptionFilter 捕获进入 HttpExceptionFilter 处理流程 @Post() @UseFilters(new HttpExceptionFilter()) async create() { throw new ForbiddenException() } 守卫:返回 boolean 值,会根据返回值决定是否继续执行后续声明周期:
// 声明时需要使用 @Injectable 装饰且实现 CanActivate 并返回 boolean 值 @Injectable() export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { return validateRequest(context); } }
// 使用时装饰 controller、handler 或全局注册 @UseGuards(new AuthGuard()) async create() { return 'This action adds a new cat' }
管道(更侧重对参数的处理,可以理解为 controller 逻辑的一部分,更声明式):
- 校验:参数类型校验,在使用 TypeScript 开发的程序中的运行时进行参数类型校验。
转化:参数类型的转化,或者由原始参数求取二级参数,供 controllers 使用:
@Get(':id') findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) { // 使用 id param 通过 UserByIdPipe 读取到 UserEntity return userEntity }
我们再来简单的看下 Nest.js 对不同应用类型和不同 http 提供服务是怎样做适配的:
- 不同应用类型:Nest.js 支持 Http、GraphQL、Websocket 应用,在大部分情况下,在这些类型的应用中生命周期的功能是一致的,所以 Nest.js 提供了上下文类
ArgumentsHost
、ExecutionContext
,如使用host.switchToRpc()
、host.switchToHttp()
来处理这一差异,保障生命周期函数的入参一致。 - 不同的 http 提供服务则是使用不同的适配器,Nest.js 的默认内核是 Express,但是官方提供了 FastifyAdapter 适配器用于切换到 Fastify。
Fastify
有这么一个框架依靠数据结构和类型做了不同的事情,就是 Fastify。它的官方说明的特点就是“快”,它提升速度的实现是我们关注的重点。
我们先来看看开发示例:
const routes = require('./routes')
const fastify = require('fastify')({
logger: true
})
fastify.register(tokens)
fastify.register(routes)
fastify.listen(3000, function (err, address) {
if (err) {
fastify.log.error(err)
process.exit(1)
}
fastify.log.info(`server listening on ${address}`)
})
class Tokens {
constructor () {}
get (name) {
return '123'
}
}
function tokens (fastify) {
fastify.decorate('tokens', new Tokens())
}
module.exports = tokens
// routes.js
class Tokens {
constructor() { }
get(name) {
return '123'
}
}
const options = {
schema: {
querystring: {
name: { type: 'string' },
},
response: {
200: {
type: 'object',
properties: {
name: { type: 'string' },
token: { type: 'string' }
}
}
}
}
}
function routes(fastify, opts, done) {
fastify.decorate('tokens', new Tokens())
fastify.get('/', options, async (request, reply) => {
reply.send({
name: request.query.name,
token: fastify.tokens.get(request.query.name)
})
})
done()
}
module.exports = routes
可以注意到的两点是:
- 在路由定义时,传入了一个请求的 schema,在官方文档中也说对响应的 schema 定义可以让 Fastify 的吞吐量上升 10%-20%。
- Fastify 使用 decorate 的方式对 Fastify 能力进行增强,也可以将 decorate 部分提取到其他文件,使用 register 的方式创建全新的上下文的方式进行封装。
没体现到的是 Fastify 请求介入的支持方式是使用生命周期 Hook,由于这是个对前端(Vue、React、Webpack)来说很常见的做法就不再介绍。
我们重点再来看一下 Fastify 的提速原理。
如何提速
有三个比较关键的包,按照重要性排分别是:
- fast-json-stringify
- find-my-way
- reusify
fast-json-stringify:
const fastJson = require('fast-json-stringify') const stringify = fastJson({ title: 'Example Schema', type: 'object', properties: { firstName: { type: 'string' }, lastName: { type: 'string' } } }) const result = stringify({ firstName: 'Matteo', lastName: 'Collina', })
- 与 JSON.stringify 功能相同,在负载较小时,速度更快。
其原理是在执行阶段先根据字段类型定义提前生成取字段值的字符串拼装的函数,如:
function stringify (obj) { return `{"firstName":"${obj.firstName}","lastName":"${obj.lastName}"}` }
相当于省略了对字段值的类型的判断,省略了每次执行时都要进行的一些遍历、类型判断,当然真实的函数内容比这个要复杂的多。那么引申而言,只要能够知道数据的结构和类型,我们都可以将这套优化逻辑复制过去。
- find-my-way:将注册的路由生成了压缩前缀树的结构,根据基准测试的数据显示是速度最快的路由库中功能最全的。
- reusify:在 Fastify 官方提供的中间件机制依赖库中,使用了此库,可复用对象和函数,避免创建和回收开销,此库对于使用者有一些基于 v8 引擎优化的使用要求。在 Fastify 中主要用于上下文对象的复用。
总结
- 在路由结构的设计上,Next.js、Nuxt.js 都采用了文件结构即路由的设计方式。Ada 也是使用文件结构约定式的方式。
- 在渲染方面 Next.js、Nuxt.js 都没有将根组件之外的结构的渲染直接体现在路由处理的流程上,隐藏了实现细节,但是可以以更偏向配置化的方式由根组件决定组件之外的结构的渲染(head 内容)。同时渲染数据的请求由于和路由组件联系紧密也都没有分离到另外的文件,不论是 Next.js 的路由文件同时导出各种数据获取函数还是 Nuxt.js 的在组件上直接增加 Vue options 之外的配置或函数,都可以看做对组件的一种增强。Ada 的方式有所不同,路由文件夹下并没有直接导出组件,而是需要根据运行环境导出不同的处理函数和模块,如服务器端对应的 index.server.js 文件中需要导出 HTTP 请求方式同名的 GET、POST 函数,开发人员可以在函数内做一些数据预取操作、页面模板渲染等;客户端对应的 index.js 文件则需要导出组件挂载代码。
- 在渲染性能提升方面,Next.js、Nuxt.js 也都采取了相同的策略:静态生成、提前加载匹配到的路由的资源文件、preload 等,可以参考优化。
在请求介入上(即中间件):
- Next.js、Nuxt.js 未对中间件做功能划分,采取的都是类似 Express 或 Koa 使用
next()
函数控制流程的方式,而 Nest.js 则将更直接的按照功能特征分成了几种规范化的实现。 - 不谈应用级别整体配置的用法,Nuxt.js 是由路由来定义需要哪个中间件,Nest.js 也更像 Nuxt.js 由路由来决定的方式使用装饰器配置在路由 handler、Controller 上,而 Next.js 的中间件会对同级及下级路由产生影响,由中间件来决定影响范围,是两种完全相反的控制思路。
- Ada 架构基于 Koa 内核,但是内部中间件实现也与 Nest.js 类似,将执行流程抽象成了几个生命周期,将中间件做成了不同生命周期内功能类型不同的任务函数。对于开发人员未暴露自定义生命周期的功能,但是基于代码复用层面,也提供了服务器端扩展、Web 模块扩展等能力,由于 Ada 可以对页面路由、API 路由、服务器端扩展、Web 模块等统称为工件的文件进行独立上线,为了稳定性和明确影响范围等方面考虑,也是由路由主动调用的方式决定自己需要启用哪些扩展能力。
- Next.js、Nuxt.js 未对中间件做功能划分,采取的都是类似 Express 或 Koa 使用
- Nest.js 官方基于装饰器提供了文档化的能力,利用类型声明( 如解析 TypeScript 语法、GraphQL 结构定义 )生成接口文档是比较普遍的做法。不过虽然 Nest.js 对 TypeScript 支持很好,也没有直接解决运行时的类型校验问题,不过可以通过管道、中间件达成。
- Fastify 则着手于底层细节进行运行效率提升,且可谓做到了极致。同时越是基于底层的实现越能够使用在越多的场景中。其路由匹配和上下文复用的优化方式可以在之后进行进一步的落地调研。
- 除此之外 swc、ESBuild 等提升开发体验和上线速度的工具也是需要落地调研的一个方向。
对 Ada 架构设计感兴趣的可以阅读往期发布的文章:《GraphQL 落地背后:利弊取舍》、《解密智联招聘的大前端架构Ada》、《智联招聘的微前端落地实践——Widget》、《Koa中间件体系的重构经验》、《智联招聘的Web模块扩展落地方案》等。