描述的形式有很多,例如配置文件、編程語言、圖形界面。先來看看現在常見的工具是怎么做的:

1.1 配置文件形式

JSON?

JSON 是一種非常簡單的數據表述, 沒有任何學習成本,解析也非常方便。但是它有非常多致命的缺陷,比如不支持注釋、冗余、數據結構單一。

YAML?

相比 JSON 語法要簡潔很多、可讀性也比較強。作為一個配置文件形式非常優秀

還是其他配置文件形式…

通常這些配置文件都是語言無關的,因此不會包含特定語言的元素。換句話說配置文件形式數據是相對靜態的, 所以靈活性、擴展性比較差。只適合簡單的配置場景。

舉個例子,這些配置文件不支持函數。我們的 Mock 服務器可能需要通過一個函數來動態處理請求,所以配置文件在這里并不適用。

當然你可以通過其他方式來取代‘函數’,例如模板、或者腳本支持

1.2 編程語言與內部 DSL

我們需要回到編程語言本身,利用它的編程能力,實現配置文件無法實現的更強大的功能。

不過單純使用通用類型編程語言,命令式的過程描述可能過于繁瑣。我們最好針對具體領域問題進行簡化和抽象,給用戶提供一個友好的用戶界面,讓他們聲明式地描述他們的領域問題。我們要盡可能減少用戶對底層細節的依賴,與此同時最好能保持靈活的擴展能力

我說的可能就是**DSL(Domain-specific languages)**[1]:

DSL 是一種用于描述特定應用領域的計算機語言。DSL 在計算機領域有非常廣泛的應用,例如描述 Web 頁面的 HTML、數據庫查詢語言 SQL、正則表達式。

相對應的是通用類型語言(GPL, General-Purpose Language),例如 Java、C++、JavaScript。它們可以用于描述任意的領域邏輯,它們通常是圖靈完備[2]的。可以這么認為,雖然不嚴謹:除了通用類型語言、其他語言都算是 DSL。

怎么創建 DSL?

從頭開發一門新語言?No! 成本太高了

一種更優雅的方式是在通用編程語言的基礎上進行減法或者封裝抽象。當然不是所有類型語言都有這個’能力’, 比如 Java、C/C++ 就不行,它們的語法太 Verbose 或者工具鏈太重了。但是 Groovy、Ruby、Scala、還有 Elixir 這些語言就可以方便地創建出‘DSL’, 而且它們大部分是動態語言。

它們有的借助宏、有的天生語法就非常適合作為 DSL、有的具備非常強的動態編程能力… 這些因素促就了它們適合作為 DSL 的母體(宿主)。

我們通常也將這種 DSL 稱為 Embedded DSL(嵌入式 DSL) 或者 內部 DSL,因為它們寄生在通用類型編程語言中。而獨立的 DSL,如 JSON、HTML,稱為外部DSL

內部 DSL 好處是省去了實現一門語言的復雜性(Parse->Transform->Generate)。

舉兩個非常典型的例子:

Java 開發者常用的 Gradle[3],基于 Groovy:

plugins {
id 'java-library'
}

repositories {
jcenter()
}

dependencies {
api 'org.apache.commons:commons-math3:3.6.1'

implementation 'com.google.guava:guava:27.0.1-jre'

testImplementation 'junit:junit:4.12'
}

還有 CocoaPods, 基于 Ruby:

source 'http://source.git'
platform :ios, '8.0'

target 'Demo'do
pod 'AFNetworking'
pod 'SDWebImage'
pod 'Masonry'
pod "Typeset"
pod 'BlocksKit'
pod 'Mantle'
pod 'IQKeyboardManager'
pod 'IQDropDownTextField'
end

具體的實現細節不在本文的范圍之內,還是聊回 JavaScript。

我個人要求 DSL 應該具備這些特性

2. JavaScript 內部 DSL

上節提到了 Groovy、Ruby ‘適合‘ 用作 DSL 母體,并不代表一定要用它們實現,這只是說明它們天生具備的一些語言特性讓實現更加便捷,或者說外觀更加簡潔。

Google 一把 ‘JavaScript DSL‘ 匹配的有效資料很少。如果你覺得困惑那就應該回到問題本身, 最重要的是解決領域問題,至于怎么組織和描述則是相對次要的。所以不要去糾結 JavaScript 適不適合。

那我們就針對 Mock Server 這個具體領域,聊一聊 JavaScript 內部 DSL 的典型組織方式:

2.1 對象形式

最簡單的方式是直接基于對象或者數組進行聲明,實現簡單又保持組織性。例如 Umi Mock[4] 還有 飛冰[5] Mock, 就是基于對象組織的:

exportdefault {
// 支持值為 Object 和 Array
'GET /api/users': { users: [1, 2] },

// GET POST 可省略
'/api/users/1': { id: 1 },

// 支持自定義函數,API 參考 express@4
'POST /api/users/create': (req, res) => {
res.end('OK')
},

// 使用 mockjs 等三方庫
'GET /api/tags': mockjs.mock({
'list|100': [{ name: '@city', 'value|1-100': 50, 'type|0-2': 1 }],
}),
}

和配置文件差不多, 實現和使用都非常簡單 ,簡單的 API Mock 場景開箱即用,對于復雜的用法和 API 協議,也可以通過自定義函數進一步封裝。但是有時候我們希望庫可以承擔多一點事情

2.2 鏈式調用形式

JavaScript 作為內部 DSL 的另外一種典型的形式是鏈式調用。

其中最出名的是 JQuery, 它讓鏈式調用這種模式廣為人知。相比啰嗦的原生 DOM 操作代碼,JQuery 確實讓人眼前一亮, 它暴露精簡的 API, 幫我們屏蔽了許多底層 DOM 操作細節,撫平平臺差異,同時還能保持靈活性和擴展性。這才是它真正流行的原因,大眾喜聞樂見的都是簡單的東西。

$('.awesome')
.addClass('flash')
.draggable()
.css('color', 'red')

JQuery 這種 API 模式也影響到了其他領域,比如 Iot 領域的 Ruff[6]:

$.ready(function(error) {
if (error) {
console.log(error)
return
}

// 點亮燈
$('#led-r').turnOn()
})

jest

expect(z).not.toBeNull()
expect(z).toBeDefined()
expect(value).toBeGreaterThan(3)
expect(value).toBeGreaterThanOrEqual(3.5)

API Mock 服務器領域也有兩個這樣的例子:

Nock[7]:

const scope = nock('http://myapp.iriscouch.com')
.get('/users/1')
.reply(404)
.post('/users', {
username: 'pgte',
email: 'pedro.teixeira@gmail.com',
})
.reply(201, {
ok: true,
id: '123ABC',
rev: '946B7D1C',
})
.get('/users/123ABC')
.reply(200, {
_id: '123ABC',
_rev: '946B7D1C',
username: 'pgte',
email: 'pedro.teixeira@gmail.com',
})

還有網易云團隊的 Srvx[8]

get('/handle(.*)').to.handle(ctx => {
ctx.body = 'handle'
})
get('/blog(.*)').to.json({ code: 200 })
get('/code(.*)').to.send('code', 201)
get('/json(.*)').to.send({ json: true })
get('/text(.*)').to.send('haha')
get('/html(.*)').to.send('<html>haha</html>')
get('/rewrite:path(.*)').to.rewrite('/query{path}')
get('/redirect:path(.*)').to.redirect('localhost:9002/proxy{path}')
get('/api(.*)').to.proxy('http://mock.server.com/')
get('/test(.*)').to.proxy('http://mock.server.com/', {
secure: false,
})
get('/test/:id').to.proxy('http://{id}.dynamic.server.com/')
get('/query(.*)').to.handle(ctx => {
ctx.body = ctx.query
})
get('/header(.*)')
.to.header({ 'X-From': 'svrx' })
.json({ user: 'svrx' })
get('/user').to.json({ user: 'svrx' })
get('/sendFile/:path(.*)').to.sendFile('./{path}')

鏈式調用模式目前是主流的 JavaScript 內部 DSL 形式。而且實現也比較簡單,更重要的是它接近自然語言

2.3 ES2015 Template Tag

近年基于 ES6 Template Tag[9] 特性引入‘新語言‘到 JavaScript 的庫層出不窮。

不過因為 ES6 Template Tag 本質上是字符串,所以需要解析和轉換,因此更像是外部 DSL。別忘了 Compiler as Framework! 通常我們可以利用 Babel 插件在編譯時提前將它們轉換為 JavaScript 代碼。

舉幾個流行的例子:

Zebu[10]: 這是一個專門用于解析 Template Tag 的小型編譯器, 看看它的一些內置例子:

// 范圍
range1,3 ... (10)// [1, 3, 5, 7, 9] // 狀態機, 牛逼 const traffic = machine` initState: #green states: #green | #yellow | #red events: #timer onTransition: ${state => console.log(state)} #green @ #timer -> #yellow #yellow @ #timer -> #red #red @ #timer -> #green ` traffic.start() // log { type: "green" } traffic.send({ type: 'timer' }) // log { type: "yellow" }

Jest 表格測試:

describe.each`
a | b | expected
${1} | ${1} | ${2}
${1} | ${2} | ${3}
${2} | ${1} | ${3}
`('$a + $b', ({ a, b, expected }) => {
test(returns ${expected}, () => { expect(a + b).toBe(expected) }) test(returned value not be greater than ${expected}, () => { expect(a + b).not.toBeGreaterThan(expected) }) test(returned value not be less than ${expected}, () => { expect(a + b).not.toBeLessThan(expected) }) })

除此之外還有:

Template Tag 這些方案給我們開了很多腦洞。盡管如此,它也帶來了一些復雜性,就像開頭說的,它們是字符串,需要解析、語法檢查和轉換,且 JavaScript 本身的語言機制并沒有給它們帶來多少便利(如語法高亮、類型檢查)。

2.4 要不試試 JSX?

鋪墊了這么多,只是前戲。上面提到這些方案,要么過于簡單、要么過于復雜、要么平淡無奇。我將目光投向了 JSX,我發現它可以滿足我的大部分需求。

先來看看一下我們的 Mock 服務器的原型設計:

import { Get, Post, mock } from'jsxmock'

exportdefault (
<server port="4321">
{/* 首頁 */}
<Get>hello world</Get>
{/* 登錄 */}
<Post path="/login">login success</Post>
{/* 返回 JSON */}
<Get path="/json">{{ id: 1 }}</Get>
{/* mockjs */}
<Get path="/mockjs">{mock({ 'id|+1': 1, name: '@name' })}</Get>
{/*自定義邏輯*/}
<Get path="/user/:id">{(req, res) => res.send('hello')}</Get>
</server>
)

嵌套匹配場景

exportdefault (
<server>
<Get path="/api">
{/* 匹配 /api?method=foo */}
<MatchBySearch key="method" value="foo">
foo
</MatchBySearch>
{/* 匹配 /api?method=bar */}
<MatchBySearch key="method" value="bar">
bar
</MatchBySearch>
<BlackHole>我會吃掉任何請求</BlackHole>
</Get>
</server>
)

有點 Verbose? 進一步封裝組件:

const MyAwesomeAPI = props => {
const { path = '/api', children } = props
return (
<Get path={path}>
{Object.keys(children).map(name => (
<MatchBySearch key="method" value={name}>
{children[name]}
</MatchBySearch>
))}
</Get>
)
}

exportdefault (
<server>
<MyAwesomeAPI>{{ foo: 'foo', bar: 'bar' }}</MyAwesomeAPI>
<MyAwesomeAPI path="/api-2">{{ hello: 'foo', world: 'bar' }}</MyAwesomeAPI>
</server>
)

看起來不錯哈?我們看到了 JSX 作為 DSL 的潛力,也把 React 的組件思維搬到了 GUI 之外的領域。


你知道我的風格,篇幅較長 ?? 休息一會,再往下看。

3. JSX 入門

如果你是 React 的開發者,JSX 應該再熟悉不過了。它不過是一個語法糖,但是它目前不是 JavaScript 標準的一部分。Babel、Typescript 都支持轉譯 JSX。

例如

const jsx = (
<div foo="bar">
<span>1</span>
<span>2</span>
<Custom>custom element</Custom>
</div>
)

會轉譯為:

const jsx = React.createElement(
'div',
{
foo: 'bar',
},
React.createElement('span', null, '1'),
React.createElement('span', null, '2'),
React.createElement(Custom, null, 'custom element')
)

3.1 自定義工廠

JSX 需要一個工廠方法來創建創建’節點實例’。默認是 React.createElement。我們可以通過注釋配置來提示轉譯插件。按照習慣,自定義工廠都命名為 h:

/* @jsx h */
/* @jsxFrag 'fragment' */
import { h } from'somelib'

const jsx = (
<div foo="bar">
<span>1</span>
<span>2</span>
<>fragement</>
</div>
)

將轉譯為:

import { h } from'somelib'

const jsx = h(
'div',
{
foo: 'bar',
},
h('span', null, '1'),
h('span', null, '2'),
h('fragment', null, 'fragement')
)

3.2 Host Component vs Custom Component

JSX 會區分兩種組件類型。小寫開頭的為內置組件,它們以字符串的形式傳入 createElement; 大寫開頭的表示自定義組件, 作用域內必須存在該變量, 否則會報錯。

// 內置組件
;<div />
// 自定義組件
;<Custom />

3.3 簡單實現 createElement 工廠方法

exportfunction createElement(type, props, ...children) {
const copy = { ...(props || EMPTY_OBJECT) }
copy.children = copy.children || (children.length > 1 ? children : children[0])

return {
_vnode: true,
type,
props: copy,
}
}

4. 基礎組件的設計

4.1 來源于 Koa 的靈感

大家應該比較熟悉 koa 中間件機制。

// logger
app.use(async (ctx, next) => {
await next()
const rt = ctx.response.get('X-Response-Time')
console.log(${ctx.method} ${ctx.url} - ${rt}) }) // x-response-time app.use(async (ctx, next) => { const start = Date.now() await next() const ms = Date.now() - start ctx.set('X-Response-Time', ${ms}ms) }) // response app.use(async ctx => { ctx.body = 'Hello World' })

形象的說,它就是一個洋蔥模型:

中間件調用 next,就會進入下一級。如果把函數的邊界打破。它的樣子確實像洋蔥:

?我發現使用 JSX 可以更直觀地表示這種洋蔥結構

4.2 use 基礎組件

于是乎,有了 <use /> 這個基礎組件。它類似于 Koa 的 app.use, 用于攔截請求,可以進行響應, 也可以選擇進入下一層。

① 來看看整體設計

use 正是基于上面說的,使用 JSX 來描述中間件包裹層次的基礎組件。因為使用的是一種樹狀結構,所以要區分兄弟中間件子中間件:

<server>
<use m={A}>
<use m={Aa} />
<use m={Ab} />
</use>
<use m={B} />
<use m={C} />
</server>

其中 AaAb 就是 A 的子中間件。在 A 中可以調用類似 koa 的 next 函數,進入下級中間件。

ABC之間就是兄弟中間件。當前繼中間件未匹配時,就會執行下一個相鄰中間件。

乍一看,這就是 koa 和 express 的結合啊!

② 再看看 Props 設計

interface UseProps {
m: (req, res, recurse: () =>Promise<boolean>) => Promise<boolean>;
skip?: boolean;
}

③ 看一下運行實例

假設代碼為:

const cb = name =>() => {
console.log(name)
returnfalse
}

exportdefault (
<server>
<use
m={async (req, res, rec) => {
console.log('A')
if (req.path === '/user') await rec() // 如果匹配,則放行,讓其遞歸進入內部
console.log('end A')
return false
}}
>
<use m={cb('A-1')}>如果父級匹配,則這里會被執行</use>
<use m={cb('A-2')}>...</use>
</use>
<use m={cb('B')} />
<use m={cb('C')} />
</server>
)

如果請求的是 ‘/’,那么打印的是 A -> end A -> B -> C;如果請求為 ‘/user’, 那么打印的是 A -> A-1 -> A-2 -> end A -> B -> C

我們的基礎組件和 Koa / Express 一樣,核心保持非常小而簡潔,當然它也比較低級,這樣能夠保證靈活性。

這個簡單的基礎組件設計就是整個框架的‘基石’。如果你了解 Koa 和 Express,這里沒有新的東西。只是換了一種表現方式。

4.3 高層組件的封裝

Ok, 有了 use 這個基礎原語, 我可以做很多有意思的事情,使用組件化的思維封裝出更高級的 API

① <Log>:打日志

封裝一個最簡單的組件:

exportconst Log: Component = props => {
return (
<use
m={async (req, res, rec) => {
const start = Date.now()
// 進入下一級
const rtn = await rec()
console.log(
${req.method} ${req.path}: ${Date.now() - start}ms ) return rtn }} > {props.children} </use> ) }

用法:

<server>
<Log>
<Get>hello world</Get>
<Post path="/login">login sucess</Post>
...
</Log>
</server>

② <NotFound>: 404

exportconst NotFound = props => {
const { children } = props
return (
<use
m={async (req, res, rec) => {
const found = await rec()
if (!found) {
// 下級未匹配
res.status(404)
res.send('Not Found')
}
return true
}}
>
{children}
</use>
)
}

用法和 Log 一樣。recurse 返回 false 時,表示下級沒有匹配到請求。

③ <Catch>: 異常處理

exportconst Catch: Component = props => {
return (
<use
m={async (req, res, rec) => {
try {
return await rec()
} catch (err) {
res.status(500)
res.send(err.message)
return true
}
}}
>
{props.children}
</use>
)
}

用法和 Log 一樣。捕獲下級中間件的異常。

④ <Match>: 請求匹配

Match 組件也是一個非常基礎的組件,其他高層組件都是基于它來實現。它用于匹配請求,并作出響應。先來看看 Props 設計:

export type CustomResponder =
| MiddlewareMatcher
| MockType
| boolean
| string
| number
| object
| null
| undefined

export interface MatchProps {
match?: (req: Request, res: Response) => boolean // 請求匹配
headers?: StringRecord // 默認響應報頭
code?: number | string // 默認響應碼
// children 類型則比較復雜, 可以是原始類型、對象、Mock對象、自定義響應函數,以及下級中間件
children?: ComponentChildren | CustomResponder
}

Match 組件主體:

exportconst Match = (props: MatchProps) => {
const { match, skip, children } = props
// 對 children 進行轉換
let response = generateCustomResponder(children, props)

return (
<use
skip={skip}
m={async (req, res, rec) => {
// 檢查是否匹配
if (match ? match(req, res) : true) {
if (response) {
return response(req, res, rec)
}
// 如果沒有響應器,則將控制權交給下級組件
return rec()
}

return false
}}
>
{children}
</use>
)
}

限于篇幅,Match 的具體細節可以看這里[14]

前進,前進。 GetPostDeleteMatchByJSONMatchBySearch 都是在 Match 基礎上封裝了,這里就不展開了。

⑤ <Delay>: 延遲響應

太興奮了,一不小心又寫得老長,我可以去寫小冊了。Ok, 最后一個例子, 在 Mock API 會有模擬延遲響應的場景, 實現很簡單:

exportconst Delay = (props: DelayProps) => {
const { timeout = 3000, ...other } = props
return (
<use
m={async (req, res, rec) => {
await new Promise(res => setTimeout(res, timeout))
return rec()
}}
>
<Match {...other} />
</use>
)
}

用法:

<Get path="/delay">
{/* 延遲 5s 返回 */}
<Delay timeout={5000}>Delay Delay...</Delay>
</Get>

更多使用案例,請看 jsxmock 文檔[15])

堅持到這里不容易,你對它的原理可能感興趣,那不妨繼續看下去。

5. 淺談實現原理

簡單看一下實現。如果了解過 React 或者 Virtual-DOM 的實現原理。這一切就很好理解了。

5.1 ‘渲染’

這是打了引號的’渲染’。這只是一種習慣的稱謂,并不是指它會渲染成 GUI。它用來展開整顆 JSX 樹。對于我們來說很簡單,我們沒有所謂的更新或者 UI 渲染相關的東西。只需遞歸這個樹、收集我們需要的東西即可。

我們的目的是收集到所有的中間件,以及它們的嵌套關系。我們用 MiddlewareNode 這個樹形數據結構來存儲它們:

export type Middleware = (
req: Request,
res: Response,
// 遞歸
recurse: () =>Promise<boolean>,
) => Promise<boolean>

export interface MiddlewareNode {
m: Middleware // 中間件函數
skip: boolean // 是否跳過
children: MiddlewareNode[] // 子級中間件
}

渲染函數:

let currentMiddlewareNode
exportfunction render(vnode) {
// ...
// ?? 創建根中間件
const middlewares = (currentMiddlewareNode = createMiddlewareNode())
// ?? 掛載
const tree = mount(vnode)
// ...
}

掛載是一個遞歸的過程,這個過程中,遇到自定義組件我們就展開,遇到 use 組件就將它們收集到 currentMiddlewareNode 中:

function mount(vnode) {
let prevMiddlewareNode
if (typeof vnode.type === 'function') {
// ??自定義組件展開
const rtn = vnode.type(vnode.props)
if (rtn != null) {
// 遞歸掛載自定義組件的渲染結果
mount(rtn, inst)
}
} elseif (typeof vnode.type === 'string') {
// 內置組件
if (vnode.type === 'use') {
// ??收集中間件
const md = createMiddlewareNode(inst.props.m)
md.skip = !!inst.props.skip
currentMiddlewareNode.children.push(md)

// 保存父級中間件
prevMiddlewareNode = currentMiddlewareNode
currentMiddlewareNode = md // ??推入棧,下級的中間件將加入這個列表
} else {
// ... 其他內置組件
}

// ??遞歸掛載子級節點
mountChilren(inst.props.children, inst)

if (vnode.type === 'use') {
currentMiddlewareNode = prevMiddlewareNode // ??彈出棧
}
}
}

// ?? 子節點列表掛載
function mountChilren(children: any, parent: Instance) {
childrenToArray(children).forEach(mount)
}

5.2 運行

現在看看怎么運行起來。我們實現了一個簡單的中間件機制,相對 Koa 好理解一點:

exportasyncfunction runMiddlewares(req, res, current): Promise<boolean> {
const { m, skip, children } = current
if (skip) {
// 跳過, 直接返回 false
returnfalse
}
// 調用中間件
return m(req, res, async () => {
// recurse 回調
// ?? 如果有下級中間件,則遞歸調用子級中間件
if (children && children.length) {
for (const child of children) {
const matched = await runMiddlewares(req, res, child)
if (matched) {
// ?? 如果其中一個兄弟中間件匹配,后續的中間件都不會被執行
returntrue
}
}
}

returnfalse// ?? 沒有下級中間件,或者沒有任何下級中間件匹配
})
}

很簡單哈?就是遞歸遞歸遞歸

6. 總結,終于完事了

本文從配置文件講到 DSL,又講到了 JavaScript 內部 DSL 表達形式和能力。最后將焦點聚集在了 JSX 上面。

我通過一個實戰的案例展示了 JSX 和 React 的組件化思維,它不僅僅適用于描述用戶界面,我們也看到 JSX 作為一種 DSL 的潛力和靈活性。

最后總結一下優缺點。

? 優點

?? 缺點

靈活卻有組織性。靈活通常容易導致雜亂無章,組織性則可能意味著犧牲靈活性,兩者在某種意義上面看是矛盾的。能夠將兩者平衡案例其實很少見,JSX 可能是一個。

文章轉自微信公眾號@前端技術地圖

上一篇:

E2E 測試中的 Mock API

下一篇:

如何使用Postman來Mock API
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費