这几天打算写一个简单的 API Mock 服务器,老生常谈哈?其实我是想讲 JSX, Mock 服务器只是一个幌子。javascript
我在寻找一种更简洁、方便、同时又能够灵活扩展的、和别人不太同样的方式,来定义各类 Mock API。后来我发现了 JSX 在领域问题描述的优点和潜力,固然这可不是空谈,咱们会实际写一个项目来证明这个判断。css
文章大纲html
一上来就说这么抽象的名词,'领域问题' 是什么鬼?什么是领域,Wiki 上解释的很是好,领域就是指某一专业或事物方面范围的涵盖。那么所谓领域问题就能够理解为,咱们须要经过程序或者其余方式去解决的需求。前端
好比提到 API Mock 服务器,咱们须要解决的就是请求匹配和数据模拟这些问题;Nginx 解决的资源伺服和代理问题;HTML + CSS 解决的是页面 UI 展现问题...java
咱们这里重点关注'描述'。这些描述是提供给领域专家的‘前端‘ 或者 用户界面(UI)。举个例子:node
描述的形式有不少,例如配置文件、编程语言、图形界面。 先来看看如今常见的工具是怎么作的:ios
JSON?git
JSON 是一种很是简单的数据表述, 没有任何学习成本,解析也很是方便。可是它有很是多致命的缺陷,好比不支持注释、冗余、数据结构单一。github
YAML?正则表达式
相比 JSON 语法要简洁不少、可读性也比较强。做为一个配置文件形式很是优秀
仍是其余配置文件形式...
一般这些配置文件都是语言无关的,所以不会包含特定语言的元素。换句话说配置文件形式数据是相对静态的, 因此灵活性、扩展性比较差。只适合简单的配置场景。
举个例子,这些配置文件不支持函数。咱们的 Mock 服务器可能须要经过一个函数来动态处理请求,因此配置文件在这里并不适用。
固然你能够经过其余方式来取代‘函数’,例如模板、或者脚本支持
咱们须要回到编程语言自己,利用它的编程能力,实现配置文件没法实现的更强大的功能。
不过单纯使用通用类型编程语言,命令式的过程描述可能过于繁琐。咱们最好针对具体领域问题进行简化和抽象,给用户提供一个友好的用户界面,让他们声明式地描述他们的领域问题。咱们要尽量减小用户对底层细节的依赖,与此同时最好能保持灵活的扩展能力。
我说的可能就是DSL(Domain-specific languages):
DSL 是一种用于描述特定应用领域的计算机语言。DSL 在计算机领域有很是普遍的应用,例如描述 Web 页面的 HTML、数据库查询语言 SQL、正则表达式。 相对应的是通用类型语言(GPL, General-Purpose Language),例如 Java、C++、JavaScript。它们能够用于描述任意的领域逻辑,它们一般是图灵完备的。 能够这么认为,虽然不严谨:除了通用类型语言、其余语言都算是 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,基于 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 应该具有这些特性:
上节提到了 Groovy、Ruby ‘适合‘ 用做 DSL 母体,并不表明必定要用它们实现,这只是说明它们天生具有的一些语言特性让实现更加便捷,或者说外观更加简洁。
Google 一把 ‘JavaScript DSL‘ 匹配的有效资料不多。 若是你以为困惑那就应该回到问题自己, 最重要的是解决领域问题,至于怎么组织和描述则是相对次要的。因此不要去纠结 JavaScript 适不适合。
那咱们就针对 Mock Server 这个具体领域,聊一聊 JavaScript 内部 DSL 的典型组织方式:
最简单的方式是直接基于对象或者数组进行声明,实现简单又保持组织性。例如 Umi Mock 还有 飞冰 Mock, 就是基于对象组织的:
export default {
// 支持值为 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 协议,也能够经过自定义函数进一步封装。可是有时候咱们但愿库能够承担多一点事情。
JavaScript 做为内部 DSL 的另一种典型的形式是链式调用。
其中最出名的是 JQuery, 它让链式调用这种模式广为人知。相比啰嗦的原生 DOM 操做代码,JQuery 确实让人眼前一亮, 它暴露精简的 API, 帮咱们屏蔽了许多底层 DOM 操做细节,抚平平台差别,同时还能保持灵活性和扩展性。这才是它真正流行的缘由,大众喜闻乐见的都是简单的东西。
$('.awesome')
.addClass('flash')
.draggable()
.css('color', 'red')
复制代码
JQuery 这种 API 模式也影响到了其余领域,好比 Iot 领域的 Ruff
:
$.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:
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
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 形式。并且实现也比较简单,更重要的是它接近天然语言。
近年基于 ES6 Template Tag 特性引入‘新语言‘到 JavaScript 的库层出不穷。
不过由于 ES6 Template Tag 本质上是字符串,因此须要解析和转换,所以更像是外部 DSL。别忘了 Compiler as Framework! 一般咱们能够利用 Babel 插件在编译时提早将它们转换为 JavaScript 代码。
举几个流行的例子:
Zebu: 这是一个专门用于解析 Template Tag 的小型编译器, 看看它的一些内置例子:
// 范围
range`1,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 自己的语言机制并无给它们带来多少便利(如语法高亮、类型检查)。
铺垫了这么多,只是前戏。上面提到这些方案,要么过于简单、要么过于复杂、要么平淡无奇。我将目光投向了 JSX,我发现它能够知足个人大部分需求。
先来看看一下咱们的 Mock 服务器的原型设计:
import { Get, Post, mock } from 'jsxmock'
export default (
<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>
)
复制代码
嵌套匹配场景
export default (
<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>
)
}
export default (
<server> <MyAwesomeAPI>{{ foo: 'foo', bar: 'bar' }}</MyAwesomeAPI> <MyAwesomeAPI path="/api-2">{{ hello: 'foo', world: 'bar' }}</MyAwesomeAPI> </server>
)
复制代码
看起来不错哈?咱们看到了 JSX 做为 DSL 的潜力,也把 React 的组件思惟搬到了 GUI 以外的领域。
你知道个人风格,篇幅较长 ☕️ 休息一会,再往下看。
若是你是 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')
)
复制代码
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')
)
复制代码
JSX 会区分两种组件类型。小写开头的为内置组件,它们以字符串的形式传入 createElement; 大写开头的表示自定义组件, 做用域内必须存在该变量, 不然会报错。
// 内置组件
;<div /> // 自定义组件 ;<Custom /> 复制代码
export function 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,
}
}
复制代码
你们应该比较熟悉 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 能够更直观地表示这种洋葱结构
因而乎,有了 <use />
这个基础组件。它相似于 Koa 的 app.use
, 用于拦截请求,能够进行响应, 也能够选择进入下一层。
① 来看看总体设计。
use 正是基于上面说的,使用 JSX 来描述中间件包裹层次的基础组件。由于使用的是一种树状结构,因此要区分兄弟中间件和子中间件:
<server>
<use m={A}>
<use m={Aa} />
<use m={Ab} />
</use>
<use m={B} />
<use m={C} />
</server>
复制代码
其中 Aa
、Ab
就是 A
的子中间件。在 A 中能够调用相似 koa 的 next
函数,进入下级中间件。
A
、B
、C
之间就是兄弟中间件。当前继中间件未匹配时,就会执行下一个相邻中间件。
乍一看,这就是 koa 和 express 的结合啊!
② 再看看 Props 设计
interface UseProps {
m: (req, res, recurse: () => Promise<boolean>) => Promise<boolean>;
skip?: boolean;
}
复制代码
m
req
、res
:Express 的请求对象和响应对象
recurse
:递归执行子级中间件, 相似 koa 的 next。返回一个Promise<boolean>
, 它将在下级中间件执行完成后 resolve,boolean 表示下级中间件是否匹配拦截了请求。
返回值:返回一个 Promise<boolean>
表示当前中间件是否匹配(拦截请求)。若是匹配,后续的兄弟中间件将不会被执行。
skip
:强制跳过,咱们在开发时可能会临时跳过匹配请求,这个有点像单元测试中的 skip
③ 看一下运行实例
假设代码为:
const cb = name => () => {
console.log(name)
return false
}
export default (
<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,这里没有新的东西。只是换了一种表现方式。
Ok, 有了 use
这个基础原语, 我能够作不少有意思的事情,使用组件化的思惟封装出更高级的 API。
① <Log>
:打日志
封装一个最简单的组件:
export const 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
export const 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>
: 异常处理
export const 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 组件主体:
export const 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 的具体细节能够看这里
前进,前进。 Get
、Post
、Delete
、MatchByJSON
、MatchBySearch
都是在 Match
基础上封装了,这里就不展开了。
⑤ <Delay>
: 延迟响应
太兴奋了,一不当心又写得老长,我能够去写小册了。Ok, 最后一个例子, 在 Mock API 会有模拟延迟响应的场景, 实现很简单:
export const 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 文档)
坚持到这里不容易,你对它的原理可能感兴趣,那不妨继续看下去。
简单看一下实现。若是了解过 React 或者 Virtual-DOM 的实现原理。这一切就很好理解了。
这是打了引号的'渲染'。这只是一种习惯的称谓,并非指它会渲染成 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
export function 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)
}
} else if (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)
}
复制代码
如今看看怎么运行起来。咱们实现了一个简单的中间件机制,相对 Koa 好理解一点:
export async function runMiddlewares(req, res, current): Promise<boolean> {
const { m, skip, children } = current
if (skip) {
// 跳过, 直接返回 false
return false
}
// 调用中间件
return m(req, res, async () => {
// recurse 回调
// 🔴 若是有下级中间件,则递归调用子级中间件
if (children && children.length) {
for (const child of children) {
const matched = await runMiddlewares(req, res, child)
if (matched) {
// 🔴 若是其中一个兄弟中间件匹配,后续的中间件都不会被执行
return true
}
}
}
return false // 🔴 没有下级中间件,或者没有任何下级中间件匹配
})
}
复制代码
很简单哈? 就是递归递归递归
本文从配置文件讲到 DSL,又讲到了 JavaScript 内部 DSL 表达形式和能力。最后将焦点汇集在了 JSX 上面。
我经过一个实战的案例展现了 JSX 和 React 的组件化思惟,它不只仅适用于描述用户界面,咱们也看到 JSX 做为一种 DSL 的潜力和灵活性。
最后总结一下优缺点。
✅ 优势
⚠️ 缺点
灵活却有组织性。灵活一般容易致使杂乱无章,组织性则可能意味着牺牲灵活性,二者在某种意义上面看是矛盾的。可以将二者平衡案例其实不多见,JSX 多是一个。(我好像在吹 🐂)
🎉🎉代码已经在 Github, 目前正处于原型阶段: ivan-94/jsxmock 欢迎 ⭐️ 和贡献。
也学别人建个群(好多读者问过),试试水吧...