概述
這次 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 的子路由,可配合
<nuxt-child />
元件進行子路由渲染。
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
),當使用這個元件替代<a></a>
標籤進行路由跳轉時,元件會檢測連結是否命中路由,如果命中,則元件出現在視口後會觸發對對應路由的 js 等資源的載入,並且點選跳轉時使用路由跳轉,不會重新載入頁面,也不需要再等待獲取渲染所需 js 等資原始檔。 - 出錯兜底:兩者都提供了錯誤碼響應的兜底跳轉,只要 pages 資料夾下提供了 http 錯誤碼命名的頁面路由,當其他路由發生響應錯誤時,就會跳轉到到錯誤碼路由頁面。
在根據檔案結構生成路由配置之後,我們來看下在程式碼組織方式上的區別:
路由元件:兩者沒有區別,都是使用預設匯出元件的方式決定路由渲染內容,React 匯出 React 元件,Vue 匯出 Vue 元件:
Next.js:一個普普通通的 React 元件:
export default function About() { return <div>About us</div> }
Nuxt.js:一個普普通通的 Vue 元件:
<template> <div>About us</div> </template> <script> export default {} <script>
路由元件外殼:在每個頁面路由元件之外還可以有一些預定義外殼來承載路由元件的渲染,在 Next.js 和 Nuxt.js 中都分別有兩層外殼可以自定義:
容器:可被頁面路由元件公用的一些容器元件,內部會渲染頁面路由元件:
Next.js:需要改寫 pages 根路徑下的 _app.js,會對整個 Next.js 應用生效,是唯一的。其中
<Component />
為頁面路由元件,pageProps
為預取的資料,後面會提到import '../styles/global.css' export default function App({ Component, pageProps }) { return <Component {...pageProps} /> }
Nuxt.js:稱為 Layout,可以在 layouts 資料夾下建立元件,如 layouts/blog.vue,並在路由元件中指明 layout,也就是說,Nuxt.js 中可以有多套容器,其中
<Nuxt />
為頁面路由元件:<template> <div> <div>My blog navigation bar here</div> <Nuxt /> // 頁面路由元件 </div> </template>
// 頁面路由元件 <template> </template> <script> export default { layout: 'blog', // 其他 Vue options } </script>
文件:即 html 模板,兩者的 html 模板都是唯一的,會對整個應用生效:
Next.js:改寫 pages 根路徑下唯一的 _document.js,會對所有頁面路由生效,使用元件的方式渲染資源和屬性:
import Document, { Html, Head, Main, NextScript } from 'next/document' class MyDocument extends Document { render() { return ( <Html> <Head /> <body> <Main /> <NextScript /> </body> </Html> ) } } export default MyDocument
Nuxt.js:改寫根目錄下唯一的 App.html,會對所有頁面路由生效,使用佔位符的方式渲染資源和屬性:
<!DOCTYPE html> <html {{ HTML_ATTRS }}> <head {{ HEAD_ATTRS }}> {{ HEAD }} </head> <body {{ BODY_ATTRS }}> {{ APP }} </body> </html>
head 部分:除了在 html 模板中直接寫 head 內容的方式,如何讓不同的頁面渲染不同的 head 呢,我們知道 head 是在元件之外的,那麼兩者都是如何解決這個問題的呢?
Next.js:可以在頁面路由元件中使用內建的 Head 元件,內部寫 title、meta 等,在渲染時就會渲染在 html 的 head 部分:
import Head from 'next/head' function IndexPage() { return ( <div> <Head> <title>My page title</title> <meta property="og:title" content="My page title" key="title" /> </Head> <Head> <meta property="og:title" content="My new title" key="title" /> </Head> <p>Hello world!</p> </div> ) } export default IndexPage
Nuxt.js:同樣可以在頁面路由元件中配置,同時也支援進行應用級別配置,通用的 script、link 資源可以寫在應用配置中:
在頁面路由元件配置:使用 head 函式的方式返回 head 配置,函式中可以使用 this 獲取例項:
<template> <h1>{{ title }}</h1> </template> <script> export default { data() { return { title: 'Home page' } }, head() { return { title: this.title, meta: [ { name: 'description', content: 'Home page description' } ] } } } </script>
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: {} } }
- 上文提到的容器元件也有自己的方法,不再介紹。
- 渲染過程的最後,會生成頁面資料與頁面構建資訊,這些內容會寫在
<script id="__NEXT_DATA__"/>
中渲染到客戶端,並被在客戶端讀取。
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:
<script> export default { middleware({ store, redirect }) { // If the user is not authenticated if (!store.state.authenticated) { return redirect('/login') } } } </script>
在編譯構建方面,兩者都是基於 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<Response>() const request = ctx.getRequest<Request>() 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模組擴充套件落地方案》等。