TySheMo 前端数据建模

使数据变化可预测,前后端数据耦合规范化

前言

适用版本:v5

有些业务非常麻烦,因为非常明显的数据问题带来了非常大的阻碍。是怎么样的数据问题?举个例子,在一个表单的编辑时,有一个文件上传的位置,我认为文件这种类型,具有通用性,所以文件都是以数组的形式存在,个数由程序控制;但后台同学认为这个位置的文件只会有一个,所以返回给我一个对象即可。这样的数据问题每天都会发生。如果你也像我一样,在业务中深陷数据本身的问题,那么 TySheMo 可能能帮到你。

TySheMo 主要为具有复杂的前端数据模型场景准备。它不是一个典型的新一代项目,并不追求性能和特性上的多厉害,相反,它是在性能有损的前提下,提升前端数据管理的高可用性。这是 TySheMo 的使用文档,你将会在这里了解到 TySheMo 的思想、实现逻辑、使用方法。

以往,前端是一个消费数据的地方。后台 API 返回数据,前端拿到数据之后,在界面上进行渲染。但随着现代应用越来越复杂,开发场景也在随时代进步,例如同一个功能的多端开发,这些都使得简单的拿数据进行渲染不再适用。最最最常见的一个问题是,前端对后端数据具有较强的依赖,包括数据类型和结构。前端总希望拿到数据之后直接渲染,但是后端返回的数据并不一定可靠,很多隐藏的细节会给前端带来麻烦,例如数据类型、数据结构、字段缺失等等。这些看上去都是小问题,但是在真实的开发场景中,会给开发者留下非常深刻的负面印象,因为你会需要花大量的时间去进行调试。另外一个场景是,在前端实际运行过程中,一个数据的状态是不确定的,比如你在界面上看到的是文字,但是在实际提交时是数字,这在表单中比较常见。

如何解决这个问题?

我在腾讯的导师 @zawa 提出了“Mota 数据模型”的概念,并付诸实践。我在去年,发布了 HelloType 这个库,可以有效解决运行时数据检查的问题。这些经验给了我新的冲动,基于 HelloType 的成果,复制一个新的“前端数据建模”库。而且,由于使用习惯不同,我希望更多使用 HelloType 的语法去构建这个库。最终,TySheMo 发布了。

TySheMo 的主要目标,是为那些需要进行结构化数据进行前端管理的场景提供一套解决方案。结构化数据主要是指在 Restful 风格下,后端 API 返回的数据结构,提交到 API 的数据结构,有非常强的前后端耦合的场景。前端在获取 API 数据之后,总是会需要经过一定的条件判断之后才用来展示。比如判断某个值是否存在,判断某个值是否是某种数据类型,实际上,这些判断是没有必要的非业务代码。这些判断不仅增加了代码量,同时,也给业务逻辑本身带来了干扰,让后来新加入团队的成员看到时一脸懵逼,不知所措。而使用 TySheMo 很大程度上,不需要去进行数据判断,它的目标是,在从后端获取数据之后,restore 到 model 中,无论后端返回的数据格式或结构是否能正确解析,都可以让对应的数据拥有符合业务代码中的需要,这样,你就可以不用再在业务代码中写一大堆的条件判断。

但是更底层的,TySheMo 希望探索科学抽象的前端数据建模方案。它把一个数据模型进行拆分,从底层到顶层,它的拆分结果是:

从理想状态上看,他们之间具有层层递进的关系。原型是一切的基础,是一个值最原子的类型。类型由原型,以及特定的数据结构组成。规则则是针对复杂的 Type 结构,对每个属性的逻辑规则予以处理。模式则是由对不同的元数据组合而成,元数据中就包含了类型。模型是基于 Schema 进行数据管理的状态容器,并且提供了操作数据的接口,模型中可能需要包含一个 Store 来有效的实现数据的响应式存储。

另外,在完成 tyshemo 的开发之后,基于它的类型系统,我开发了 tyshemo-service 主要解决的是前后端对接问题。在日常开发中,前后端在一开始准备一个需求时,需要在一起约定接口返回/提交数据的结构、类型。但是由于一些不可描述的原因,这件事总是不是很顺利。特别是后端同学,常常因为数据库结构和目标结构不同,导致很难按照前端同学的理想结构返回,而前端同学对此又颇有意见,导致合作不愉快。利用 tyshemo 可以很好的解决这个问题。(当然,它是基于大家合作的基础上。)在开始阶段,甚至开发阶段,tyshemo-service 可以帮助两端开发者共同维护一个类型系统,这个类型系统可以直接生成接口文档,并且帮助前端开发者 mock 数据(在后端完成开发之前),同时给后端同学一个测试服务,在他完成接口开发之后,可以方便快速的自测接口是否符合约定。Tyshemo 在这个过程中充当接口的 api-schema 角色,也就是 DLS 的角色。

这些所有的一切就是 tyshemo 所要提供的。下面就是详细的文档。

概念

TySheMo 是 Type Schema Model 这三个单词的合体。也就是说,这个库,将包含这三个部分。但是,并不要急,它会包含 6 个方面的内容:原型、类型、规则、模式、Store、模型。

原型

原型(Prototype)是用来描述数据的原子属性或特征方式。什么是“原子属性”,就是作为一个数据,在计算机中,它是怎么被对待的,即一个变量是如何对待的,以什么方式存储和运算它。

举一个例子,我们在代码中常会写 var a = 10 这样的代码,这时候,我们知道 a 是一个数值,在浏览器或其他环境中,js 引擎会按照数值型数据对它进行存储和运算。但是,你怎么知道 a 是一个数值呢?我的意思是,计算机怎么知道它是一个数值呢?这是由 a 的原子属性决定的。一个 js 中的数值,它具备数值模型的特征,并且拥有数值模型上的一些接口。数值可以进行精度求值,可以进行小数位计算等,这些特定的特征使得程序运行时,a 按照数值进行存储和运算。

在 js 中,基本的数据类型有 6 种:number, string, boolean, null, undefined, symbol. 未来 js 还会出现 bigint,甚至还会有 bigfloat 这样的新数据类型。这些基础的数据类型构成了 js 世界的原子属性。同样的道理,在 TySheMo 的体系里,对一个数据的原型进行判定,是一切的基础。问题在于,怎么去判定一个原型呢?实际上,我们要找到一种方法,可以判断某个变量是否符合某种原型,比如,我们常见的 typeof 就是一种判断方法。但在 TySheMo 的体系里,typeof 这种方法不适用。

TySheMo 判定一个变量是否符合某个原型主要通过创建一个判定逻辑,返回 true 或者 false。这个过程主要依托 TySheMo 内建的接口,你可以使用 js 的原生接口作为原型,也可以使用 TySheMo 内置的原型。

在学习下面的 Prototype 的使用方法之后你可以创建自己的原型,用来作为数据类型判定的原子属性判定方法。

类型

数据类型(Type,本文简称“类型”),是指一个变量的本质特征,TySheMo 中的类型关注数据结构,以及结构上每个节点的类型。什么是“变量的本质特征”呢?就是你需要知道这个变量,它的内在结构,以及嵌套在它结构节点上的更深层级的类型。

例如,你称呼一个小男孩是一个“人”,这里的“人”就是“小男孩”的本质特征,“人”所拥有的结构体系,“小男孩”也应该拥有,否则“小男孩”就不是“人”。明白这一点之后,“人”拥有什么结构,“一个小男孩”就会拥有什么结构,而且还会在“人”的共性上多出一些特征。如果将“小男孩”视为一个变量,那么“人”就是他的数据类型。他具备“人”的结构,以及人“可以说话”“可以走路”等方法。

从某种意义上讲,原型也是一种类型,但是由于原型是原子层面的,它无法描述有结构的数据,只能描述无结构的数据。举个例子,原型可以指出某个变量应该是一个数组,但是,它无法描述这个数据的内部结构,无法描述数组内每个元素的类型。

现在我们要反过来思考,作为“人”这个概念,怎么去检测这个小男孩是不是“人”。一个数据类型,如何去判别一个变量是不是该数据类型?在 TySheMo 中,我们需要借助“类型容器”来实现这个目的。数据类型本身无法用代码来表述(这句话有误,因为我们定义一个 class 本质上就是在定义一个数据类型),但是,我们可以通过一个类型容器,使得数据类型是可以被表达的。而要得到这个类型容器,就是 TySheMo 提供的 Type。

TySheMo 将抽象的“数据类型”进行提炼,用“类型容器”这个可以用代码表达的东西代替。

基于 Type,我们可以定义任意的数据类型。等你学了 Type 的使用方法之后,你也可以定义自己的数据类型了。

规则

属性规则(Rule,本文简称“规则”)是在一个对象中,属性的存在形式和值的逻辑的描述。属性规则用于对象属性的类型进行逻辑判定,它的主要效果是根据对象本身的特征或人为逻辑,决定当前属性是否必须存在,或者当前属性应该以怎样的方式进行类型判定。也因为规则是对属性的描述,所以,只有在 Dict、Tuple 中可以使用规则。

在下文学习完 Rule 之后,你可以通过它定义自己的任意规则。

模式

模式(Schema)是指用于描述数据每个属性的详细描述的集合。说的简单点,就是元数据的集合,有点类似 sql 数据库的结构描述,“包含了那些字段,每个字段的结构是怎样的”。模式开始针对业务场景进行抽象。在业务代码中,我们对一个对象的每一个属性(类似字段)进行描述(类似字段结构),得到的一个完整的数据描述就是 Schema。

TySheMo 的模式是一个 js class,可以用来定义一个对象数据包含了那些属性字段,每个属性的具体逻辑是怎样的。它并不关心实际的数据是怎样的,它关心的是数据的抽象逻辑。当数据还不存在时,这个数据的每个字段要符合哪些逻辑。它相当于是一个无状态的武器库,用来控制数据应该如何产生、变化和读取。

在 TySheMo 中,通过 Schema 这个类来创建一个 schema 实例。一个 schema 实例是无状态的,它不保存数据,它就像一个管道一样,接收数据,并处理数据,输出一个完全符合校验逻辑,不会报错的数据。通过下文对 Schema 的学习,你可以快速掌握这项技术。

模型

数据模型是一个数据管理器,它通过内部状态管理着数据,在你需要使用的时候从中取出数据,也可以更新数据。TySheMo 的 Model 是基于 Schema 的,也就是说,一个 Model 它必须保持着一个 schema 实例,用来作为模型提供数据的抽象结构的基础,通过 schema,模型中的数据变化具有非常严格控制逻辑,不会让用户随便就能对数据进行任意值的修改和提交。除此之外,TySheMo Model 还提供数据观察和响应能力,在调用模型的更新方法之后,你所设置的观察回调会被执行。

Model 将会是和业务代码结合紧密的一个东西,因此,当我在设计 Model 时,所站在的角度就不再是纯粹的技术抽象层面去思考问题,而是更多的要考虑实际的业务场景。我们可以想象一种业务场景,就是从后端拿到数据后,通过数据模型,将后端给的数据回写到模型中,这样,我可以在不做任何多余操作的情况下,让数据高可用。

在设计时,我将一个模型设计为 Model 的继承类,也就是如下的写法:

import { Model } from 'tyshemo'

class SomeModel extends Model {
  static some = {
    type: String,
    default: '',
  }
}

这样,创建一个模型之后,你需要实例化这个模型为一个具体的对象,才能使用这个模型。这主要是考虑到:1. 模型不应该在应用一运行时就消耗内存,而是在使用的时候再去实例化出来。2. 同一个模型可能在同一个页面需要实例化多个。

这样的设计也是经过了实践验证的。这有利于我们不断的去扩展模型,根据不同的业务场景和实际的代码逻辑来进行控制。

总而言之,模型是业务中,对数据进行管理、使用的对象。也是 TySheMo 的最顶层设计。

Store

在模型中,我们需要保管一个状态,用来存放数据。而在 TySheMo 中,这个用来保管数据的状态容器,就是 Store。Store 是基于响应式编程所开发的状态容器,它是可观察的,开发者可以根据它内部的状态变化来决定做什么事情。在 TySheMo 中,每一个 Model 内部都有一个 Store 的实例,用来管理和存储模型上的数据。但是 Store 也可以脱离 Model 单独使用,这样,作为开发者,你可以根据自己的需要来使用 TySheMo 中的 Store 管理自己的数据。

在后面的章节中,我们有详细的教程告诉你如何使用 Store。

小结

TySheMo 从微观的数据规则到宏观的数据模型,给出了一个完成的前端数据建模的实现。它大致为一个层层递进的设计:

TySheMo 体系结构,它们处于数据的:Prototype 原子级别;Type 校验级别;Rule 逻辑级别;Schema 结构级别;Model 应用级别(包含了 Store 这个响应式的状态管理工具)。

这样的设计不仅将每一个层级的概念提炼后仍然具有可行性,同时还符合我们对建模的认知。经过抽象之后的概念,完全脱离了业务逻辑,形成一个可以脱离实际需求,完全服务于建模的 js 模块。这有利于在任何业务中,如果有需要,都可以把 TySheMo 拿过来用,而不会受到限制。

快速入门

本章主要教会你如何快速的在你的项目中使用 TySheMo。

安装

npm i tyshemo

引入到项目

tyshemo 这个 npm 包提供了多种被引用的方式。

 import { Model } from 'tyshemo'
const { Model } = require('tyshemo')

tyshemo 导出的默认方式是 commonjs,但是它的源码不是纯的 ES6 源码,它引入了第三方 npm 库,所以,不能直接在 ES6 系统中直接使用,需要通过构建工具打包。

为了方便使用,tyshemo 输出一个完整的 bundle 文件,以 umd 规范导出接口,可以直接在页面引入。

<script src="/node_modules/tyshemo/dist/tyshemo.min.js"></script>
<script>
const { Model } = window['tyshemo']
</script>

因为遵循 umd 规范,所以你可以在 require.js 等 amd 规范的框架中使用 bundle 方式。另外,你可以使用 dist/ty.min.js,如果你只需要使用到类型系统,而不需要其他部件的话。ty 是 TySheMo 的类型系统,你可以使用它完成类型系统的构建,以及 tyshemo-service 的搭配使用。

使用

TySheMo 有很多接口。我们在业务中最常用的是两个,一个是利用类型校验能力,对拿到的数据进行检验。另一个则是使用 Model 在业务中管理数据,确保数据的可靠性。

数据类型校验

// person.type.js

import { Dict, List, Range } from 'tyshemo'

export PersonDict = new Dict({
  name: String,
  age: Number,
})

export PersonList = new List([PersonDict])

export PersonResponse = new Dict({
  code: new Range({ min: 0, max: 100000, minBound: true }),
  data: PersonList,
})
// 业务代码 person.service.js

import { PersonResponse } from './person.type.js'

export getPersons(params) {
  // 当从用户接口返回的数据不符合 PersonResponse 定义时,会报错
  return fetch('xxx', params).then(res => res.json()).then((data) => {
    PersonResponse.assert(data) // 当类型校验失败时,会抛出错误,你可以在 catch 中捕获该错误提示
    return data
  })
}

数据规整化

在你的业务代码中使用后台返回的数据直接渲染?No!你应该建立自己的数据模型,该数据模型拥有你渲染需要的字段,后台返回的数据作为源输入到模型中,经过解析和调整,由模型输出统一规格的数据,这样,未来你的接口数据发生变化,你只需要在模型的解析方法中进行调整,而无需修改渲染层面的数据来适应。

import { Model } from 'tyshemo'

export class PersonModel extends Model {
  static name = {
    type: String,
    default: 'unknow',
  }
  static age = {
    type: Number,
    default: 0,
  }
}
export class PersonPage extends React.Component {
  constructor(props) {
    super(props)

    const person = new PersonModel()
    this.person = person

    // 监听 model 中的数据变化,当数据发生变化时,强制重新渲染组件
    person.watch('*', () => this.forceUpdate())
  }
  grow() {
    直接修改模型属性,由于其内部的响应式系统,会触发上面 watch 所绑定的回调函数,从而触发 this.forceUpdate,最终带来视图重绘
    this.person.age ++
  }
  render() {
    const { name, age } = this.person
    return (
      <div>
        <span>{name}</span>
        <span onClick={() => this.grow()}>{age}</span>
      </div>
    )
  }
}

在下文中,我会详细的去讲解 Model 的接口,学习完之后,你就可以理解上面这段代码是怎么工作的了。

原型

你可以使用 js 原生接口(标准库或特定结构)作为原型:

上面的规则利用了 js 原生的能力来进行判断,虽然上面列举了不多,但是,任何一个实例对象,都可以用它的类来作为原型,例如 let some = new Some() 中 Some 作为 some 的原型。因此,基于这种内置逻辑,其实原型是有无穷多的。

使用 TySheMo 内置原型:

在具体写代码的时候,这些原型总是位于数据校验链的最末端。

类型

本章详细讲解类型容器的使用。前文讲到,类型容器是 TySheMo 对数据的结构类型的具体化,帮助开发者判断某个变量是否符合某种结构类型。

TySheMo 中内置了 6 种常见的结构类型。Dict, List, Tuple, Enum, Range, Mapping, 其中最常用的莫过于 Dict 和 List。我们在 TySheMo 体系下使用类型容器,不是去定义某个数据结构,而是用来判断某个变量是否符合某个结构类型。

TySheMo 中创建类型容器的基础是 Type 类,Dict, List, Tuple, Enum, Range, Mapping 全都是基于Type 扩展出来的。

Type

作为类型容器的基础,Type 类创建基于 Prototype 的类型容器。也就是说,在进行实例化的时候,你应该传入 prototype 作为参数。

const SomeType = new Type(String)

但是在实际编程的时候,你不会这样去做,我们 100% 只会用到 Dict, List, Tuple, Enum, Range, Mapping. 在这些扩展出来的类型中,每个节点,可以直接接收 prototype,而无需自己写一个 new Type。

得到一个类型容器之后,你就可以使用类型容器的方法进行类型校验了。下方的方法是 Dict, List, Tuple, Enum, Range, Mapping 都具备的。

assert

断言某个值是否符合当前类型。

import { Dict } from 'tyshemo'

const SomeDict = new Dict({
  name: String,
  age: Number,
})

SomeDict.assert({
  name: 'tomy',
  age: 10,
})

assert 方法不返回任何内容,但是它会在断言失败时使用 throw 抛出错误,中断程序执行。

catch

catch 方法用于抓取类型校验过程中的错误,如果错误发生,返回错误(一个 TypeError 实例),如果没有错误,返回 null。

const error = SomeDict.catch({
  name: 'tomy',
  age: 10,
})

test

test 方法用于判定校验是否通过,通过返回 true,不通过返回false。

const data = {
  name: 'some',
  age: 0,
}
if (SomeDict.test(data)) {
  // do something
}

track

track 方法返回一个 Promise 实例,在校验过程中会抓取错误,如果错误发生,会 reject 抛出错误,如果通过,则 resolve null。

SomeDict.track({
  name: null,
}).catch(error => console.log(error))

trace

trace 方法和 track 方法的使用是一模一样的,但是 trace 在进行数据校验的时间点上不同。track 会在你输入数据的时候进行校验,而 trace 会在一个异步执行中去校验数据,也就是延后校验。这样做的好处是,在一些数据类型不影响业务的场景中,你仅想知道数据是否符合类型,而不想让校验过程阻塞界面渲染,就可以使用 trace 替代 track。

但是,你需要注意的是,由于是异步校验,如果你在异步任务没有执行之前修改了传入的数据,就会发生不可预测的问题,因为当异步校验开始时,已经不是要校验原始数据了。

Strict/strict/toBeStrict

严格模式主要是为规则设定的。在严格模式下,某些规则会失效,例如 ifexist。严格模式会带来如下影响:

其中 Strict 和 strict 是属性,它们是完全一样的,只是为了给开发者提供不同大小写使用习惯的选择。

const SomeStrictDict = SomeDict.Strict

你会得到一个全新的 Dict 实例,这个实例复用了原来实例的配置参数,但是和原来的实例没有任何关系。

toBeStrict 是一个方法。调用它,你会将当前这个实例转化为一个严格模式的实例。

SomeDict.toBeStrict(true)

在使用时,应该小心。因为一般情况下,我们经常复用定义好的类型容器,如果你在业务中修改了严格模式,可能导致在另外的地方的校验会失败。一般仅仅在一次性使用一个类型容器时,可能用到 toBeStrict.

Dict

Dict 用于校验字典结构,对应 js 中的对象,和 python 中的 dict。在实例化时,你必须传入一个对象,这个对象规定了所有内部结构,你可以通过阅读内部结构就可以了解后台 api 应该返回什么数据给你。基于这种想法,TySheMo 可以作为前后端确定返回数据结构和类型的一个协商工具。

const SomeDict = new Dict({
  name: String,
  age: 10,
})
const OtherDict = new Dict({
  some: SomeDict, // 复用定义好的类型
  other: ifexist(SomeDict), // 使用规则
  total: ifnotmatch(Number, 0), // 通过 ifnotmatch 可以在类型校验失败时设置默认值
})

可以说,Dict 是我们用到最多的一种类型。它的使用场景及其广泛,而且有非常高的复用需求,因此,在 Type 的基础上,Dict 还拥有另外 2 个方法。

extend

扩展当前 dict 得到一个新 dict。新 dict 天生拥有当前 dict 的全部属性,但是你可以通过传入新的属性值进行覆盖,同时,你可以传入原本没有的属性来添加属性。

const Some2Dict = SomeDict.extend({
  age: Numeric, // 修改一个原有属性
  height: Number, // 添加一个新属性
})

extract

从当前 dict 中提炼部分属性得到一个新 dict。新 dict 的属性一定是原 dict 属性的子集。如果你需要该属性被提炼,只需要设置为 true 即可。

const Some3Dict = Some2Dict.extract({
  // some3 将只有 name 和 height 这两个属性
  name: true,
  height: true,
})

你可以将 extract 和 extend 连起来用,起到一定的效果。

const Some4Dict = Some2Dict.extract({
  name: true,
  height: true,
}).extend({
  weight: Number,
})

这在一些必要的场景下非常有用,可以减少代码量,但同时又可以非常明晰的知道一个 dict 都必须拥有那些校验规则。

List

List 用于校验列表结构,对数组进行检查,对应 python 中的 list。在 js 中,一个数组的内部结构是随意的,但是,在真实的业务场景中,一个列表总是要求内部元素是具备结构化特征的。一个列表中的元素,往往只会有一种类型,而不会像 js 的数组中一样,任意类型都可以。

const SomeList = new List([SomeDict])

这样规定之后,要求被检查的数组的每一个元素必须符合 SomeDict 类型。

const SomeList = new List([SomeDict, Some2Dict])

这样规定之后,要求被检查的数组的元素,必须是 SomeDict 或 Some2Dict 中任意一种类型,适合列表中有多种结构类型的时候用。

List 结构往往对应后台输出的数组,因此,如果这个数组中一个元素都没有,也不会引发错误。因为正常业务中,数组中不存在数据是常有的事。

Tuple

Tuple 用于校验元组结构,对数组进行检查,对应 python 中的 tuple。在业务场景中,我们有时需要一个数组的元素个数是确定的,每一个位置上元素的类型也是确定的。例如对函数的参数列表进行检查的时候,Tuple 则是最佳的选择。

const SomeTupe = new Tuple([String, Number])
function some(...args) {
  SomeTuple.assert(args)
}

Tuple 的末尾元素可以使用 ifexist,非末尾元素使用 ifexist 无意义。

Enum

Enum 表示枚举,被校验的值必须符合被枚举的值中的某一个。在业务场景中,实际上我们可能经常用到 Enum,因为我们要检验的数据,经常只要符合两种类型的其中之一即可。

const SomeEnum = new Enum([Number, Numeric]) // 数字或则数字形式的字符串都行

Range

Range 表示区间,被校验的数字必须在规定的区间内。这个类型比较少用到。

const SomePercent = new Range({
  min: 0,
  max: 100,
  minBound: true, // 是否包含最小值,默认 true
  maxBound: false, // 是否包含最大值,默认 true
})

Mapping

Mapping 表示映射关系表,可用于规定结构单一的对象类型结构。例如:

const SomeMapping = new Mapping({
  key: Numeric, // 属性名的类型
  value: Number, // 属性值的类型
})

可用于检查类似如下数据:

{
  201908: 12,
  201909: 20,
  201910: 19
}

这种 key-value 类型规律且单一的对象,且最重要的是 key 是在规律性变化的,可以用 Mapping 来检查。

规则

TySheMo 体系中的属性规则,仅适用于 Dict 和 Tuple。为什么呢?只有 Dict 和 Tuple 中我们可以确定属性,List 虽然也是数组,但是它的元素个数是不定的,是一个弹性结构,无法运用属性规则。

你必须在属性的位置上使用这些规则,而不能直接传给 Type 构造器。

const SomeDict = new Dict({
  some: ifexist(String),
})
const SomeTuple = new Tuple([
  Number,
  ifexist(String),
])

Tuple 相当于有确定的索引号的对象,因此,可以使用规则。但是在使用一些规则的时候,要考虑它存在的意义,例如 ifexist 仅适用末尾的元素,非末尾元素使用无意义。

内置规则

TySheMo 的内置规则全部是函数,规则函数一般接收原型、类型和规则作为参数。它们包含:

asynch

异步规则。当一个类型需要异步加载的时候,可以使用该规则。它接收一个函数,用于返回最终的类型。在最终的类型返回之前,所有的校验都会被直接通过。

const SomeType = new Dict({
  some: asynch(fetchType), // 定义一个 fetchType 函数,用于从服务端抓取数据类型
})

在 fetchType 没有完成之前,some 属性相当于遵循 Any。

为了不与 async 发生词汇冲突,我使用了 asynchronous 的前 6 个单词,方便记忆。

shouldmatch

必须满足传入的类型,如果不满足,则会抛出传入的 message 作为错误信息。

const SomeType = new Dict({
  some: shouldmatch(String, '{keyPath} 必须是一个字符串'),
})

shouldnotmatch

和 shouldmatch 相反,检查时,不能是传入的类型,如果符合传入的类型,反而会抛出错误,并用传入的 message 作为错误信息。

const SomeType = new Dict({
  some: shouldnotmatch(String, '{keyPath} 不能是一个字符串'),
})

match

同时满足多条规则。match 接收一个数组,但比较常用的场景是搭配 shouldmatch 和 shouldnotmatch 这两个规则来用。

const SomeType = new Dict({
  some: match([
    shouldmatch(String, '必须是一个字符串'),
    shouldmatch(Numeric, '必须是一个数字'),
  ]),
})

这样可以起到根据不同类型错误返回不同错误提示的效果。

determine

判定规则。用于根据不同的情况使用不同的规则。例如,你想先判定数据中是否存在某个字段,如果存在的话,使用某个类型,如果不存在的话使用另外一个类型。

const SomeType = new Dict({
  some: determine((data) => 'type' in data, String, Any),
})

它接收的参数为:

ifexist

根据属性存在情况进行校验。当属性不存在时,不进行任何校验。当属性存在时,必须符合传入的类型。ifexist 是一个非常重要的规则,它可以让属性更加灵活。而且由于规则本身是可以嵌套的,你还可以在 ifexist 里面嵌套另外一个规则,例如 ifnotmatch。

const SomeType = new Dict({
  some: ifexist(String), // some 属性可以不存在,如果存在,必须是一个 String
})

ifnotmatch

如果被校验的属性不符合类型,则用传入的参数作为默认值进行替代。

const SomeType = new Dict({
  some: ifnotmatch(Number, 0), // 如果 some 属性值不是 Number,那么直接将 some 属性设置为 0
})

它的第二个参数可以是一个函数,如果是一个函数的话,会在校验失败时,执行该函数,并把该函数的返回结果作为默认值。

ifmatch

和 ifnotmatch 相反,当接收到到值和传入到类型一致时,第二个参数会被使用。

shouldexist

判定该属性是否必须存在。它接收一个函数用来进行判定如,如果返回 true,当被检查的数据没有该属性,就会报错。如果返回 false,当被检查的数据没有该属性,不会报错。但无论返回 true 或 false,只要该属性存在,都应该满足传入的第二个参数的类型。该函数接收的参数和 determine 规律一致。

const SomeType = new Dict({
  some: shouldexist((data) => !!data.should, String),
})

shouldnotexist

和 shuldexist 相反,当函数返回 true 时,不允许该属性存在,当函数返回 fasle 时,该属性可存在也可不存在,存在时必须满足传入的类型。

const SomeType = new Dict({
  some: shouldnotexist((data) => !!data.shouldnot, String),
})

这个规则有点绕,需要你在实践中多尝试。

instance

校验值是否为传入的类的实例。在 TySheMo 中,类似 String, Boolean 这些系统自带的变量都被作为校验使用的原型去了。但是,如果真的想去校验一个 new String('xx') 是否是 String 的实例时,就需要用到 instance。

const SomeType = new Dict({
  some: instance(String),
})

equal

用于对对象等需要进行深度比较的值。

const SomeType = new Dict({
  some: equal({ ok: true }),
})

nullable

允许被检查到值为 null。

const SomeType = dict({
  some: nullable(String), // null 或者 String
})

当 some 属性值为 null 时,不会返回错误。

lambda

用于对属性方法进行重写,以此让该方法拥有参数和返回值校验的能力。lambda 规则接收两个参数,第一个参数必须是一个 Tuple 类型容器,用于对方法函数对传入参数进行校验,第二个参数是用于对函数返回值进行校验的类型容器。

const SomeTuple = new Tuple([Number, Number])
const SomeType = new Dict({
  do: lambda(SomeTuple, Number),
})

为了方便起见,我将它的第一个参数允许传入一个数组,该数组会自动实例化为一个 Tuple。

const SomeType = new Dict({ 
  do: lambda([Number, Number], Number), 
})

Rule

在学习了前面的所有内置规则之后,我们来学习这些规则背后的 Rule 这个类。

const SomeRule = new Rule({
  name: 'Some',
  validate: v => v !== 0,
  message: 'value should not be 0.',
})

所有上述规则生成器函数的最后,都是返回一个 Rule 的实例。因此,你实际上可以使用这些 Rule 实例来做一些事。

validate

validate 方法用于对数据进行校验,并返回一个 error 或 null。

bind

bind 方法用于绑定一个函数,当 validate 校验中返回了错误,这个函数一定会执行,接收 error 实例。

在一些规则中,例如 ifnotmatch, ifmatch 中,数据会被校验和改写。但是,如果你这个时候想要对被校验的原始所抛出的错误进行监控时,可以使用 bind 方法:

const SomeRule = ifnotmatch(Number, 0)
const SomeType = new Dict({ some: SomeRule, })

SomeRule.bind(error => console.log(error))

这样,无论任何情况下,你都可以抓取到 SomeRule 校验失败时抛出的错误。

unbind

解除 bind 的函数。

Ty

Ty 是 TySheMo 中的类型系统的快捷集成工具,它让校验接口更接近我们的编程直觉,而且,在我们写代码过程中,有的时候你需要通过编辑器搜索功能,一次性搜索出某些特定的校验逻辑,直接使用 Type 实例进行校验会让这种操作很麻烦,而如果有一个基于 Ty 开头的标识,搜索就会方便很多。

import {
  Ty,
  Numeric, // 获取原型
  Dict, // 获取类型
  ifexist, // 获取规则
} from 'tyshemo/cjs/ty'

上面是 tyshemo 类型系统的导出快捷入口,通过 tyshemo/cjs/ty.js 这个文件,你可以使用有关 tyshemo 中定义的类型系统的所有功能。另外,Ty 这个特殊接口,具有更令人喜欢的方法可以使用。

if (Ty.is(10).of(Number)) {
  // do something
}

Ty 上提供了如下的便捷方法。这些方法,大部分都是链式的,理解起来会比较舒服。

expect.to.be

类似于 assert 的功能。

Ty.expect(10).to.be(Number)

你也可以使用它的别名 expect.to.match 这样看上去更符合词语的意思。

Ty.expect(10).to.match(Number)

两则之间仅仅是一个别名而已,作用完全相同。

catch.by

类似于 type 实例的 catch 功能。

const error = Ty.catch(10).by(Numeric)

track.by

类似于 track 功能。

Ty.track(10).by(Numeric).catch(error => console.log(error))

trace.by

Ty.tracc(10).by(Numeric).catch(error => console.log(error))

is.typeof

用于判断一个类型是否是某值的类型。

if (Ty.is(Number).typeof(10)) {
  ...
}

is.of

用于判断一个值是否符合一个类型。

if (Ty.is(10).of(Number)) {
  ...
}

decorate.with

这是一个高级功能,用于对 ES6+ 的 class 进行修饰。你需要了解“装饰器”这种操作符之后再来使用。而且,就目前而言,使用装饰器要求你的编译工具支持。

它支持对属性、方法的输入和输出进行装饰。

@Ty.decorate().with(SomeDict) // 修饰 constructor
class Some {
  constructor(some) {
    this.data = some
  }
  
  @Ty.decorate().with(String) // 修饰属性
  name = ''

  @Ty.decorate().with(Function) // 修饰方法
  @Ty.decorate('input').with(SongTupleType) // 修饰方法参数,注意,参数必须是 Tuple 类型,支持输入一个数组来构造 Tuple,如果但输入一个值,也就以改值创建一个 Tuple
  @Ty.decorate('output').with(SingType) // 修饰方法返回值
  sing(song) {
    // ...
  }
}

create

在某些情况下,你并不知道你应该创建一个 Dict 还是一个 List,Ty.create 帮助你完成这个任务。

const SomeType = Ty.create(unknown)

监听功能

Ty 提供一个监听错误的功能。在一些特殊的情况下,而要获得监听能力,你必须实例化一个 Ty 对象:

const ty = new Ty()

此时,实例对象 ty 才拥有监听能力。

bind

绑定一个函数,用于在监听到错误发生时执行。这个函数接收一个 Error 实例,即错误发生时的错误实例。

const watch = error => record(error) // 创建一个函数,用于和你的监控系统对接,将错误信息记录到监控系统中
ty.bind(watch)

unbind

用于解绑一个监听函数。

ty.unbind(watch)

silent

是否开启安静模式。开启安静模式后,不会在阻断程序运行,不在控制台输出错误。一旦开启安静模式,Ty 中使用 throw 抛出错误的地方将受到影响,例如 expect.to.be, track.by, trace.by。

ty.silent(true)

一般来说,不建议开启安静模式,但是在监听了错误的时候,你可以通过开启安静模式减少报错。

错误

当我们使用 TySheMo 的类型检查系统去检查一个数据时,在检查到数据不符合类型结构时,会有错误抛出。这个错误 error 是一个错误实例,即 Error 的实例。Type 的错误是一个 Error 的子集,具有特殊的属性。

错误收集和展示

TySheMo 内部检查时会收集所有错误,而非遇到检查失败就立即停止后续检查。也就是说,它是遍历整个被检查对象后才把错误返回的。在返回的错误中,你可以通过打印错误 message 查看所有错误。例如:

const SomeType = dict({
  a: ifexist(tuple([
    new Enum([String, Boolean]),
    Number,
  ])),
  b: {
    title: String,
    count: Number,
  },
  c: shouldmatch(dict({
    name: String,
    age: Number,
  }), '{keyPath} 不是一个用户。'),
})
test(() => SomeType.assert({
  a: [1],
  b: 'null',
  c: null,
}))

TySheMo 抛出的错误将是一个包含了该对象所有错误的信息。比如上面这个校验,会抛出如下的错误:

它有4条信息,a[0], a[1], b, c,告诉了你四个位置上具体是什么错误。

错误信息模板

你可以看到,上面的错误信息,前 3 条使用了内置的错误信息模板。你可以通过修改模板来调整内置信息模板。

import { TyError } from 'tyshemo'

TyError.defaultMessages = {
  ...TyError.defaultMessages,
  exception: '{keyPath} 位置错误。',
}

模板以键值对形式存在,包括如下:

在消息值里面,你可以使用字符串模板例如上面示例代码里面的 {keyPath},可以用到的有:

错误信息序列化

当你的 should 或 receive 过长的适合,你阅读起来非常麻烦,TySheMo 提供了可以换行展示的能力,这样可以方便你阅读,要开启这个功能,你只需要:

import { TyError } from 'tyshemo'

TyError.shouldBreakLongMessage = true

开启这个功能之后,你的消息里面的对象、数组,会自动进行换行,这样可以方便你阅读,可以更好的知道错误的详情。

你可以看到,开启之后,b 那条错误里面的对象换行了。

错误信息脱敏

在一些系统中,你希望你收到的错误信息进行脱敏,而非把错误内容完整的展示出来。你需要关闭对应的功能:

import { TyError } from 'tyshemo'

TyError.shouldHideSensitiveData = true

关闭之后,最终打印出来的值会被脱敏。字符串显示为 "***" 数字显示为 ***。

错误信息格式化

针对单条错误,你希望对这一条错误信息进行单独格式化。你可以通过 error.format 方法实现。

const error = SomeType.catch(some)
error.format({ templates, jointag, shouldbreak, shouldusesensitive, keyPathPrefix })

Parser

基于描述语言的类型解析器。

描述语言

Parser 是用以解析基于字符串描述的 Type 构造器。它的目标是,一个类型容器的配置,由服务端返回。它类似于一些描述语言,用字符串描述类型结构。

例如,服务端返回了如下的 json 作为类型结构描述:

{
  "__def__": [
    {
      "name": "book",
      "def": {
        "name": "string",
        "price": "float"
      }
    }
  ],
  "name": "string",
  "age": "number",
  "has_football": "?boolean",
  "sex": "0|1",
  "dot": "='xxx'",
  "belong": "?='animal'",
  "vioce": "!number",
  "num": "string,numeric",
  "parents": "[string,string]",
  "books": [
    {
      "name": "string",
      "age": "number"
    }
  ],
  "body": {
    "head": "boolean",
    "neck": "boolean"
  }
}

它完全是基于字符串的描述的。但是很明显,你可以大致读懂它的描述内容。只是有些符号代表什么意义,你还需要学一下而已。前端在拿到这样一段描述 json 之后,怎么把它解析为一个类型容器呢?

import Parser from 'tyshemo/cjs/parser'

const parser = new Parser()
const SomeType = parser.parse(json)

这样你就可以将描述文本转化为可运行的类型容器。

语法

目前仅支持 Dict。它的语法非常简单:

{
  属性名:表达式,
}

类型表达式

Parser 仅支持如下表达式用以简写类型:

规则表达式

仅支持 4 种规则:

前三种规则必须置于描述最前面,? 可以和其他规则混用,例如 "?='tomy'" 表示如果存在该属性,必须等于字符串 'tomy'。

目前不支持复杂的规则表达。如果不遵循上述语法,解析器可能报错,或返回错误的结果,因此必须非常注重描述语言的写法。

类型表达式的优先级高于规则表达式,例如 "string,number|numeric" 解释之后是 match([String, new Enum([Number, Numeric])])

原型

内置支持的类型如下:

Parser.defaultTypes = {
  'string': String,
  'number': Number,
  'boolean': Boolean,
  'null': Null,
  'undefined': Undefined,
  'symbol': Symbol,
  'function': Function,
  'array': Array,
  'object': Object,
  'numeric': Numeric,
  'int': Int,
  'float': Float,
  'negative': Negative,
  'positive': Positive,
  'zero': Zero,
  'any': Any,
  'nn': NaN,
  'infinity': Infinity,
  'finity': Finity,
  'date': Date,
  'promise': Promise,
  'error': Error,
  'regexp': RegExp,
}

你可以通过修改 Parser.defaultTypes 来扩展或修改支持的类型。

类型嵌套

由于 TySheMo 仅支持非常简单的描述语言,它不支持在描述文本中嵌套,因此,如果你需要有多层的嵌套规则,例如某个字段是对象,然后你需要对对象内部进行约束,你必须使用自定义规则,例如最前面代码里面的 books 那个属性一样。

自定义类型逻辑如下:

TySheMo Parser 会首先去读取 __def__ 中的内容,它必须是一个数组,Parser 会去遍历这个数组,这个数组的元素结构如 { name, def },每遍历一个元素,这个元素的解析结果就会加入到 Parser 中,成为一个定义,在后面的任何描述中,都可以使用这个元素。遍历完 __def__ 之后,再对整个 json 进行解析。

以前面的 books 为例。我们首先在 __def__ 中定义了一个 name 为 book 的类型,这个类型的具体描述在 def 中,它实际上也是一个对象结构。在 books 的描述中,我们使用了 "book[]",表示 books 是一个 book 的数组。

自定义类型

在一些场景下,你需要使用预设好的类型。你可以这样做:

// 预设类型
const types = {
  some: new Tuple([String, Number]),
}

// 将预设类型加入到该解析器,仅该解析器可以读懂预设类型
const parser = new Parser(types)

// 使用预设类型
parser.parse({
  list: "some", // some 为预设类型中对应的属性名
})

在 Parser 实例化的时候传入预设类型,这些预设类型只会在该解析器解析的时候生效,而不会影响其他解析器。

注释

我们通过 json 来描述时,支持特殊的方式表示注释。注释非常简单,就是在需要注释的字段名字前加 #,这样就可以了。

{
  "#name": "对 name 进行注释",
  "name": "string"
}

注释支持对象嵌套,但不支持数组嵌套:

{
  "body": {
    "#hand": "对象支持嵌套注释",
    "hand": "boolean",
    "foot": "boolean"
  },
  "#books[0].price": "数组不支持嵌套注释,只能在数组外部通过 keyPath 的方式进行注释",
  "books": [
    {
      "price": "number"
    }
  ],
  "#body.foot": "对象也支持 keyPath 的方式进行注释"
}

注意,__def__ 为关键字。在生成类型容器时,这些注释字段会按照规则,被添加到 __comments__ 属性中,方便在 TyshemoService 中使用。

但是在实际生产环境中需要注意,你需要在工具(例如 webpack)中将这些注释字段从 json 中移除,以尽可能的减小代码体积。

接口

parse(description)

将描述文本 json 对象解析为 tyshemo 的类型系统实例。

describe(type)

将 tyshemo 的类型系统实例(Type 实例)抽象为描述文本 json。

define(type, description)

定义一个解析对组。

parser.define('some', SomeType)

经过 define 之后,你就可以在描述文本中使用 some 代表 SomeType 了。

技巧

一般而言,只会在一些特殊场景下去使用解析器。基于性能的考虑,不应该在前端大面积使用这种方案,因为解析器本身在解析的时候,就会消耗一定的性能。

前后端通信时

由于前后端通信时,你无法直接传递运行时变量,不可能把 String, Number 这种传给前端,因此,只能选择 json 字符串等通用描述方式。

前后端同构时

使用同一套类型校验时,前后端使用相同的预设类型,可以减少传输成本。例如上面的一段代码,其中的 some 类型,我们可以提前预设好,这样,在发送的描述文件时,就不需要再在描述文件中使用 __def__ 去定义 some。

由于一些规则,特别是 determine 是无法直接获取计算后的类型的,因此,在这种情况下,我们要使用自定义类型来实现这些特殊规则的解析。

export const SomeRule = determine(data => data.type === 'die' ? DieType : LifeType)

事先定义 determine 规则的时候,就是这样使用。让外部可以获取它。接着,使用独立作用域:

import { SomeRule } from './rules'

const parser = new Parser({
  tobeornottobe: SomeRule,
})

另外,还有一个技巧是,在解析器中,它会去主动读取 Rule 实例的 pattern 属性。因此,在解析代码中,我们可以自己覆盖 pattern 属性,而非使用自定义类型。

import { SomeRule } from './rules'

SomeRule.pattern = [DieType, LifeType]

但是这样做明显是带有污染性质的,不建议这么做,本文只是告诉开发者,在特殊情况下,我们可以通过覆盖 pattern 来得到想要的结果。

Mocker

上面,我们利用 Parser.describe 可以生成描述文件。差不多同样的道理,我们可以生成 mock 数据。问题的关键在于,我们如何生成每种原型对应的随机值。用法和 Parser 很像,但随机值的产生不同。

const = loaders = [
  [String, function() {
    return 'a random string'
  }],
  [Number, function() {
    return Math.random() * 100
  }],
]
const mocker = new Mocker(loaders)

const dict = new Dict({
  name: String,
  age: Number,
})

const mock = mocker.mock(dict)

mock 方法可以提炼出一个 mock 数据,loaders 中的函数会返回一个经过随机算法返回的字符串。最终,mock 方法会返回一个符合 dict 的假数据。Mocker 只有一个方法 mock,因此这里就不再深入赘述。

Service

基于 Parser 和 Mocker,我开发了一个用于提供 nodejs 服务的包: tyshemo-service,你可以阅读这篇文章了解我的设计思路。这个包的主要目的是通过项目中的 types 实例,自动生成文档和 mock 数据接口。这样,我们就可以重复利用项目中已经写好的 types 文件,很快输出开发阶段需要的东西。例如,我们在我们的项目中写了一些 type 文件:

// book.type.js
import { Dict } from 'tyshemo'

export const BookType = new Dict({
  name: String,
  price: Number
})

现在,我们有一个关于 book 的 restful 接口,在前后端同学都是黑盒的情况下,我们可以启动一个这样的服务:

// book.api.doc.js
const Service = require('tyshemo-service')

BookType.__comments__ = {
  'name': '书的名字',
  'price': '书的价格',
}

const server = new Service({
  getResponseType: (type) => {
    return {
      code: 0,
      data: type,
    }
  },
  getErrorType: (type) => {
    return {
      code: 10000,
      error: type,
    }
  },
  errorMapping: {
    10000: 'Database broken',
    10005: 'network error',
  },
  basePath: '/api/v2',
  data: [
    {
      name: 'Default Group',
      items: [
        {
          name: 'Book',
          description: '书本接口',
          method: 'get',
          path: '/book/:id',
          response: BookType,
        },
      ],
    },
  ], 
})

server.doc({ port: 4000 })

然后用 node 运行这个文件:

node book.api.doc.js

这样就可以在本地启用一个服务,打开 localhost:4000 就可以阅读文档。把这个文档另存为一个 html 发给后端同学,大家就可以对该接口进行约定了。这样可以大大节约开发前期的沟通时间。同时,对于已经写好的 *.type.js 文件,可以被重复用在运行时数据检查和做 api 测试中。api 测试,实际上就是在本地写 ajax 去不断请求和校验的过程。

初始配置

在实例化Service的时候,需要传入一个初始配置,这个配置是一个非常大的配置,它包含了所有和接口、类型相关的配置。通过这个配置,你可以描述所有的接口,以及输入输出的一些规则。

{
  // 接口返回时,统一经过该方法进行处理。
  // 在大部分restful api接口中,都会在最外层包一层,但是我们没有必要把这些通用的东西写在response type里面,可以通过该方法来适配
  // 它接收下面接口配置的类型,返回一个新类型容器,也可以直接写成下面这种方式
  getResponseType(type) {
    return {
      code: 0,
      data: type,
    }
  },
  // 和 getResponsType一样的功效,只是在返回错误时进行包裹
  getErrorType(type) {
    return {
      code: Number,
      error: type,
    }
  },
  // 和getResponsType一样的功效,只是在请求数据外包裹
  getRequestType(type) {
    return type
  },
  // 规定全局的错误码对应文本,一般这个可以直接在项目的某个配置文件中引入
  errorMapping: {
    10000: 'Database broken',
    10005: 'network error',
  },
  // 接口访问时的基本路径,不包含域名部分
  basePath: '/api/v2',
  // 所有接口的配置信息
  data: [
    {
      // 先定义一个组
      id: 'default_group',
      name: 'Default Group',
      // 这个组里面包含了哪些接口,每个接口的具体配置下文详细解释
      items: [
        Person,
        Desk,
      ],
    },
  ],
}

其中最主要的部分就是 data 的配置。这个里面首先需要对接口进行分组,这是必须的一个设计,因为一个接口的集合,不可能完全不分组一次性给到开发者。每一个组的配置包含三个字段,id, name, items,其中items就是将该组下的所有接口按顺序排列,这个顺序会影响输出文档中的顺序。

每一个item的配置如下:

{
  id: 'person',
  name: 'Person',
  // 接口请求方式,必须是小写,因为express的路由中将会使用到这个值
  method: 'post',
  // 接口请求的路径,不包含basePath部分
  path: '/person/:id',
  // 请求发出的数据类型容器,method为get时表示queryString的参数
  request: RequestType,
  // 接口的响应数据类型容器
  response: ResponseType,
  // 用于单元测试的配置
  test: [
    {
      // 该单元测试多久执行一次,设置为0时取消自动循环功能
      frequency: 60000,
      name: '123',
      // 用于替换path中的占位符
      params: { id: 123 },
      // 用于覆盖单元测试随机值,保证某些字段是固定值
      request: { age: '10' },
    },
  ],
}

除了上述这些配置之外,还支持getResponseType getErrorType getRequestType basePath 这几个配置,这几个配置如果在 item 中配置了,那么这个接口不会再使用全局配置中的这几个配置,而是使用自己的配置,不配置的情况下,会直接使用全局配置。errorMapping 会和全局配置中的 errorMapping 合并后作为当前接口的输出结果。

文档服务器

通过Service可以快速启用一个文档服务器,让前后端的同学可以同时看着文档进行开发,避免复杂的沟通交流。

当我们通过 new Service 得到一个 server 之后,我们可以通过 doc 方法快速启动一个服务器来展示文档。

server.doc(options)

使用 node 去运行这段脚本,就可以在浏览器中打开对应的端口访问默认提供的网页进行阅读。这里当 options 是对文档服务器启动时的配置。具体如下:

{
  // 服务器端口
  port: 9000,
  // 文档页面的title
  title: 'My App API DOC', // the doc page title
  // 文档页面一进入的欢迎语
  description: '', // the doc page description
  // 文档模版文件的路径,注意要使用绝对路径
  docTemplateFile: null,
}

怎么解决一些注释呢?我们怎么知道哪个字段是什么意思呢?我们有一个 hook 用以解决注释问题。

RequestType.__comments__ = {
  // body.head 是对应的请求参数的键路径,而非单纯一个键
  'body.head': '这是 body.head 属性的注释',
}

通过 __comments__ 属性来定义一些字段的备注。其中需要注意的是,__comments__ 的键名是基于 RequestType 的路径,这些路径形式如 prop[i].prop.prop 是用来表示比较深层次的节点的。

Mock服务器

通过启用一个Mock服务器,前端同学不需要后台同学完成开发,就可以在前端调通接口,避免前端开发中,被后台接口开发卡住脖子的情况。

我们通过 server 的 mock 方法来开启Mock服务器。

server.mock(options)

目前,options 中只支持 port 参数。

Mock 服务器启动之后,前端同学直接通过代理等方式,将前端的请求转发到 Mock 服务器,就可以在让 ajax 得到需要的数据,这些数据全部是根据数据类型描述随机生成的。这样,就不用得到后台同学开发完之后再进行接口联调了。

但是在一些情况下,你需要对某些节点的 mock 数据做一个硬性的规定,你可以使用 __mocks__ 属性来进行处理。这个属性的用法和 __comments__ 很像。它的值接收两种形式,一种是固定值,另一种是函数,如果是一个函数,那么函数的运行结果就会作为该节点的值。

ResponseType.__mocks__ = {
  'body.feet[0]': true,
}

但是有的情况下,对于一个 list,它的值的类型是重复的,这个时候,你需要使用 [*] 来表示这种情况:

ResponseType.__mocks__ = {
  'books[*].pages[*].num': function(data, indexes) {
    // data 是指整个 response 的 mock 数据(被 __mocks处理之前)
    // indexes 用于存储 * 所代表的索引值,之所以是一个数组,是表示 keyPath 中对应的 *
    // 例如这个例子里面,keyPath 中有两个 *,那么 indexes 中就会有两个值,第一个值代表 books[i],第二个代表 pages[i]
    // 该函数每次执行的时候,indexes 里面的值都不一样,具体要看 mock 所产生的数据条数
    const [bookIndex, pageIndex] = indexes
    return 'page-' + (pageIndex + 1)
  },
}

自动测试服务器

后端同学开发完接口之后,不知道自己开发好的接口是否符合前端同学的需要,因此,需要有一个测试工具,用来检查写好的接口是否是 ok 的。tyshemo 提供一个自动测试服务器,用来在这个阶段辅助完成接口测试(仅开发人员参与)。

server.test(options)

options 支持如下配置:

{
  port = 8087,
  // target 用于指向后端同学开发好后端代码后部署的服务器地址
  target = 'http://localhost:8089',
  title = 'TySheMo',
  description = 'This is an api doc generated by TySheMo.',
  // 自定义测试界面模板
  testTemplateFile = path.resolve(__dirname, 'test.html'),
}

用 node 去启动这个脚本之后,就可以在浏览器中打开页面,去观看写好的测试用例,并且运行测试用例来检查后台代码返回的结果是否符合前端同学的要求。

测试用例依赖于最前面 Service 配置中的每个接口的 test 配置,通过这些用例,可以很好的检查后台代码返回结果的可用性。

统一服务器

上述三个服务器在介绍中,都是独立启动的。你也可以一次性把三个服务器都起起来。

server.serve(options)

这里的 options 是上述三个服务器的 options 的并集。当服务器起来之后,在浏览器中打开对应的地址,根页面是文档页面,/_test 是测试页面。

Schema

数据模式(Schema,本文简称“模式”)是一种抽象结构。它的作用更多是描述,描述一个结构应该是怎么构成的。Schema 是 TySheMo 提供的一个类,它用于生成一个模式描述。而这个描述有一些方法,去保证你所提供的数据符合这个结构。

基于业务场景的思考,目前 Schema 仅做了一层数据结构的描述。也就是说,Schema 是不嵌套的。它只能描述当前这一层的数据,深层结构数据是无法进行描述的(虽然你可以用 object 来表示)。

import { Schema } from 'tyshemo'

const PersonSchema = new Schema({
  name: {
    type: String,
    default: 'unknown',
  },
  age: {
    type: Number,
    default: 0,
  },
})

这就是一个 schema,创建一个 schema,你需要传入配置对象。对象的属性,就是所描述的数据的属性,属性值,则是该属性的描述。一个属性描述包含如下

setter 和 getter 是两个格式化函数,例如你要求当前属性必须是数字,但是在表单中会使用字符串输入,因此,在 setter 中将字符串转化为数字,就可以解决问题。setter 具有比较高的优先级,它会在 validate, ensure 之前发挥作用,这样你就不用担心本来想用 '12' 得到 12 时由于类型校验不符合要求而得到 default 了。

drop, map, flat 接收的参数格式一致。它们都接收三个参数:

以上就是一个属性描述的所有配置项。在接下来的接口详解时,可能会涉及到,你可以继续往下读。一个 schema 实例上面的方法有如下这些。

get(key, value, context)

基于 getter 配置的格式化。

const formatted = PersonSchema.get('age', 12)

set(key, next, prev, context)

基于 setter 配置的格式化。

const formatted = PersonSchema.set('age', '12', '11')

有如下限制:

validate(key, value, context)

用于校验某个数据是否符合 schema 的描述。它包含了 type, required 和 validators 校验。

const error = PersonSchema.validate('age', 12, this)

例如:

const SomeSchema = new Schema({
  some: {
    type: String,
    default: '',
    validators: [
      {
        validate(value) { return this.hasChildren(value) },
        message: '"some" should have children',
      },
    ],
  },
})
const some = {
  hasChildren(value) { ... },
}
const error = SomeSchema.validate(key, value, some)

restore(data, context)

通过传入的数据,还原成需要的数据。如果传入的数据对应字段上不符合类型,会使用 default 中配置的值替代。

const typedData = PersonSchema.restore({
  name: null, // 由于不符合 String 的类型要求,会被置为 ''
  age: 10,
})

formulate

用于规整化数据。它利用 drop, map, flat 输出格式化后的数据。同时也支持第二个参数给 map 使用。

const formulated = PersonSchema.formulate(data, some)

required

用于判断某个属性是否必填。通过执行required参数来实现。

if (SomeSchema.required('some', this)) {
  // ..
}

readonly

用于判断某个属性是否为只读。

disabled

用于判断某个属性是否为禁用。

Store

TySheMo 提供了一个状态管理工具 Store,它是一个状态管理器,一个 store 管理一个 state,它的特征是可被观察,让开发者可以通过 watch 监听哪一个属性发生变化,并进行对应的下一个操作。要使用 Store 也很方便。

import { Store } from 'tyshemo'

Store 是比 redux, mobx 更便捷高效的状态管理器,它可以在 react, vue, angular 中被无缝使用。而且,它的设计极其简单,没有任何复杂概念,接口也很少,只能做这些操作。

初始化

初始化动作超级简单。

const store = new Store({
  name: 'tomy',
  age: 10,
})

没错,这样就创建了一个 store,store 内保管着一份 state,初始化之后,state 的值如上所示。

State

你可以读取 store.state 来进行任意操作。利用 Proxy 技术,state 比任何响应式库做的都要有意思。

const { state } = store

// 新增一个属性
state.sex = 'F'
// 修改原有属性
state.age ++
// 删除原有属性
delete state.name

state 的所有修改操作都是响应式的,可以通过 watch 监听到这些修改。

计算属性

Store 支持计算属性。在创建一个 store 实例的时候,我们通过传入计算属性来定义该属性。

const store = new Store({
  name: 'tomy',
  birth: 2009, 

  // age 是一个双向计算属性,可通过 get 实时动态计算值,可设置值时反写当前对象属性
  get age() {
    return new Date().getFullYear() - this.birth
  },
  set age(age) {
    this.birth = new Date().getFullYear() - age
  },

  // height 是一个单向计算属性,只能读取其计算结果值,不能向 height 设置值
  get height() {
    return this.age * 5
  },
  
  // fatherAge 是一个单向计算属性,任何情况下,读取结果都是 undefined,对它设置值时,只会对其他属性产生影响,而不会更新自己的值
  set fatherAge(fage) {
    this.age = fage - 28
  },
})

计算属性具有缓存,在第一次产生值之后,后续只有当该值,或它内部所依赖的值,发生变化时,才会更新缓存。因此,上面使用了 new Date().getFullYear() 并不会每次都执行,倘若在年度之交使用上面代码,就会造成问题。因此,使用时需要特别注意。

计算属性的计算器中的 this 指向 store.state,在其内部读取值时需要注意,有的时候,被读取属性不存在时,会报错,例如现在读取 this.body.feet 就会报错,因为 this.body 并不存在。而且由于传入 Store 中的对象仅仅是一个初始值,在 store 的运行过程中,是会改变的。那么,怎么调整计算属性呢?

添加计算属性

当初始值中不存在你需要的计算属性时,你可以通过 define 方法定义一个新的计算属性。

store.define('key', { get, set })

在第二个参数中传入 getter 和 setter 函数。

修改计算属性

修改计算属性和添加计算属性的方法是一样的。

删除计算属性

使用 del 方法可以删除任意属性,计算属性也会被它所删除。

store.del('key')

实例方法

store 适应所有的框架,因为你可以选择使用 state 来进行对象式操作,还是方法接口操作。

get(keyPath)

通过 keyPath 获取属性值。keyPath 是用来描述一个深层次路径的表达式,可以是一个字符串,也可以是一个数组,例如 ['books', 1] 表示 state.books[1],也可以用字符串表示 'books[1]'。

const name = store.get('name')

set(keyPath, value)

通过 keyPath 设置某个属性的值为 value。

store.set('name', 'tomy')

del(keyPath)

删除对应 keyPath 的属性。删除操作虽然只是一个方法,但是实际上内部比较复杂,因为它要处理原有的依赖收集逻辑。

store.del('name')

update(data, async)

一次性更新多个属性值。

store.update({
  name: 'tomy',
  age: 23,
})

它的第二个参数 async 表示是否异步更新。异步更新的好处在于,我们建立一个很小的事务,当同一个属性被多次更新的时候,只有最后一次更新会被作为结果,这样可以提升性能。

store.watch('age', () => console.log(111)) // 只会出现一次 console,因为两次 update 都是更新 age,而只有第二次更新被使用
store.update({ age: 24 }, true)
store.update({ age: 25 }, true)

它返回一个 Promise,你可以使用 then/catch await 进行下一步操作。

需要注意,由于更新有先后顺序,使用 update 更新数据时,尽量避免具有相互依赖关系的数据同时更新。

define(key, options)

新增或修改计算属性。 当 options 是一个函数时,直接被认为是 getter。

需要注意,define 只能定义顶层属性,不能定义深层属性。

watch(keyPath, fn, deep)

监听某个 keyPath 的变动,当对应 keyPath 值变化时,fn 这个回调函数就会执行。deep 表示是否深层监听,默认为 false,表示只有精确当 keyPath 变化时才会触发响应。而如果 deep 为 true 时,表示如果 keyPath 对应的是一个对象,那么该对象的子属性变化时,也会触发 fn。

当 keyPath 为 '*' 时,表示任意变动都能被监听到。

unwatch(keyPath, fn)

解除 watch 绑定的监听函数。

bind(key)(store, key)

将当前 store 的一个 key 和另外一个 store 的某个 key 进行绑定,绑定之后,另外那个 store 的那个 key 对应的值发生变化时,当前 store 对应的 key 也会重新计算。这可以用在一些计算属性中使用了其他 store 的情况。

Model

终于进入到真正的数据建模讲解中。数据模型(Model,本文简称“模型”)是关于业务的数据管理工具。把前端的数据想象成需要一个数据库来进行管理。当你的数据需要自己把控的时候,你需要怎么入手?你会涉及到哪些操作?在我看来,比较复杂的场景是:编辑表单。你需要从 api 读取数据,并且恢复到表单模型中,表单本身会需要自己的状态来维护,提交表单时需要校验数据,提交结果需要进行规整化(根据后天 api 的接收要求)。在这个过程中,对于本质其实只是一份数据,却要考虑 3 种状态,这种时候,通过 TySheMo 模型进行管理,会非常方便。

TySheMo 的模型具备如下功能:

Model 中保持着一份 Store 和 Schema。Store 用于数据的响应式管理,Schema 用于字段规则的管理。Model 基于 Store,因此,Model 上的数据具有可被观察性,你可以通过 watch 来观察 Model 上的数据变化,从而触发界面更新。Model 基于 Schema 来做数据的模式,也就是说 Model 内管理的数据遵循 Schema 的描述。

创建模型

一个模型是对数据的定义,但 Model 类不是数据本身,而是数据的规定。一个模型是一个数据的抽象,通过定义模型,就定义了这一个数据的字段规则以及变化逻辑。

Schema 声明

创建模型类具有非常强的场景意识,开发者应该非常清楚自己的模型将会需要哪些字段。前面我们提到模型是基于 Schema 的,那么我们怎么去确定一个模型的 Schema 呢?我们需要这样规定一个模型:

import { Model } from 'tyshemo'

export class SomeModel extends Model {
  static field = {
    type: String,
    default: '',
  }
}

创建一个继承自 Model 的类,这个类,就是模型本身,将这个模型需要的字段以静态属性的方式进行写作。每个字段所需包含的配置信息,就是 Schema 属性的配置信息。

有了一个模型之后,我们就可以实例化得到一个模型的实例,这个实例则是数据的容器。

const model = new SomeModel()

实例化的时候,你可以传入一个初始数据,如果不传,则会使用 schema 中的 default 打造一个初始数据。也就是说,模型实例化之后,立即拥有了一个符合 schema 规定的数据。我们可以直接通过 model 读取模型实例的数据。

const field = model.field

而此时,因为 schema 中规定了 field 的 default 为 '',所以我们读取到的 field 也是 ''.

将 Schema 引入到 Model 中是 TySheMo 最重要的一个点。因为有了 Schema,Model 中的数据不再是不可预测的。

创建一个 Model 原来如此简单。

钩子函数

在创建模型时,你可以通过覆盖钩子函数,来实现模型上特定时期的动作。其中最重要的是 onInit 和 onError。

class SomeModel extends Model {
  // 实例化完成时被调用
  onInit() {
    this.watch('*', () => this.doSomeThing())
  }
  // 当数据变换过程中,发生错误时,被调用
  onError(error) {
    console.log(error.message)
  }
}

自定义方法

像普通的 js 类一样,你可以在模型中自定义一些方法,将一些特殊的逻辑集合通过方法的形式对外暴露,从而可以集中管理某些特定业务逻辑。

class SomeModel extends Model {
  doSomeThing() {
    this.name = ''
    this.age = 0
  }
}

使用数据

在你的业务中使用模型上的数据,实际就是要从模型读取数据。由于模型上数据是完全的 js 对象,是一种引用型数据,因此你获得数据之后,还能获得它的变化结果。虽然这和流行的 immutable 不同,但是很明显,有的时候我们需要这种效果。

get

你可以使用 get 方法读取数据。

const some = model.get('some')

get 方法有一个优势,它可以使用连字符读取更深层级的属性。例如

const some = model.get('body.some')

而且,不会因为 body 不存在而报错。

[property]

除了上述方法获取数据之外,在 schema 中定义了的字段,可以在 model 上直接读取。例如:

class SomeModel extends Model {
  static some = {
    type: String,
    default: 'a',
  }
}

const model = new SomeModel()
console.log(model.some) // 'a'
这些字段是响应式的,如果你用过 vue 的话,this.age ++ 这样的操作应该很不陌生。通过 model.some = 'b' 会触发对 some 的 watch 监听。

$views

在模型上,你可能需要即时的获取当前模型上的一些数据状态,你可以通过读取 $views 来得到对于单个字段的一些必要信息。

const { $views } = model
const { some } = $views // 获取字段视图
const { 
  value, // 该字段当前的值
  required, // 该字段当前是否必填
  readonly, // 该字段当前是否只读
  disabled, // 该字段当前是否禁用
  errors, // 该字段当前是否校验不通过,如果不通过,会返回失败的 error 列表,否则返回空数组
  ...extra, // 你可以在定义 schema 时,定一个 extra 对象,该对象的值会被解构到 view 中,方便使用
} = some

value 具有响应式效果,例如:

some.value = 'b'

也会触发 watch 监听。这在 angular 中有非常好的效果。对于某个字段而言,你只需要得到该字段的 view,你就可以得到它的全部。

这在一些系统中非常有用。因为你需要即使的了解一个字段它当前的一些状态情况,以此决定是否要做一些事。最常见的就是错误提示:

<input v-model="model.view.some.value" />
<span v-for="error in model.view.some.errors">{{ error.message }}</span>

通过这种方式非常容易的解决了错误提示问题。

但是需要注意,errors 仅包含 validators 中的校验逻辑极其结果,而不包含必填和类型校验错误。

$views.$errors

在 $views 上存在一个特殊的属性 $errors,它用于获取 $views 所有字段的所有 errors 的并集,通过读取 model.$views.$errors,可以知道当前模型上,所有校验规则都有哪些是没有通过的。这里的校验规则仅包含 validators 规则中配置的规则。

更新数据

更新 model 的数据是一个比较复杂的操作。看上去不就是修改一个数据吗?但是,你要是考虑到具体的业务场景,你就会发现,太复杂了。我们不可能考虑到所有的场景,所以,我提供了以下方式。

set

通过 set 方法更新一个属性。

model.set('name', 'lily')

set 遵循 schema 的 set 逻辑,如果该字段是 disabled, readonly, computed,都是不能被修改的,set 会无效,如果 set 的值不符合字段类型的规定(setter 处理之后),也是不能修改的。这些错误,都可以在 onError 中被捕获到。

$views

利用 view 也可以更新数据。

model.$views.some.value = 'new value'

[property]

通过直接修改 model 上的属性来更新数据:

class SomeModel extends Model {
  static some = {
    type: String,
    default: 'a',
  }
}

const model = new SomeModel()
console.log(model.some) // 'a'

model.some = 'b'
console.log(model.some) // 'b'

update

一次性更新多个字段的值。

model.update({
  name: 'lily',
  age: 12,
})

类似 react 的 setState,update 是一个接收一个对象,批量更新,带事务管理的(当更新失败时,数据会恢复到初始状态),异步的操作。完成数据更新之后,compute 和 watch 回调都会被执行。在短时间内多次调用 update,多次传入的对象会被合并,并且一次性更新,并且返回一个 Promise 实例。如果是 resolved 状态,表示没有问题,可以更新。但是如果是 rejected 状态,则表示更新的数据不符合校验规则,整个更新都不会被执行,数据不会发生任何变化。

它支持第二个参数 async 表示异步更新,当第二个参数为 true 时,它的事务机制才能生效。

model.update({
  name: 'lily',
  age: 12,
}, true)

数据监听

和 angular 一样,你可以通过一个方法来监听某个属性值的变化。

watch

使用 watch 监听某个属性的变化,并且在监听函数中,可以更新另外一个属性。但和 angular 不同的是,watch 默认支持深检查。在监听函数中,可使用 this 指向当前 model。

model.watch('name', ({ key next, prev }) => {
  // key: 当前被修改的属性的 keyPath,数组,注意,key 不一定是这里的 name,而有可能是 name.length 等子属性
  // next: 被修改为什么值
  // prev: 被修改前的值
})

unwatch

取消 watch 绑定的回调函数。

监听任何变动

使用 * 可以监听 model 数据的所有变动,只要数据变动,回调函数都会被执行。

model.watch('*', () => {})

数据校验

在 TySheMo 模型中,进行模型的数据校验非常方便。因为我们在 schema 中已经定义好了所有的校验规则,所以实际上,你只需要对上文中关于 Schema 的 validate 的部分进行了解,就可以知道 Model 的校验逻辑是怎样的。

validate

直接调用 Model 的 validate 方法进行数据校验。

const errors = model.validate()

它不接收任何参数,直接对 model 的数据进行全量校验。返回一个数组,该数组包含了所有校验错误。当全部校验都通过的情况下,返回一个空数组。

需要注意,这里的校验会使用 type, required, validators 进行校验。它的结果和 $views.$errors 不同,多出了 type, required 的校验结果。

你也可以针对单个 字段进行校验。

const errors = model.validate('key')

甚至,你还可以用一个值去看看,这个值是否符合该字段的规则。例如,我现在有一个值 some,我并不知道它是否符合 name 字段的规则,在我打算使用它之前,我先通过下面的方法进行检验。

const errors = model.validator('name', some)

如果 errors 是空的,则说明 some 符合 name 的所有校验逻辑,可以放心使用。当然,一般情况下,不会这样去操作。

数据重置

前文提到,围绕 Model 实际上有三种状态的数据。我们现在要对第一种数据进行处理,然后让数据变为第二种状态的数据。即我们像 model 中注入新数据,使 model 重新拥有一个全新的状态数据。

restore

恢复数据调用 restore 方法即可。这个方法接收新的数据。

model.restore(data)

它实际上用到了 schema 上的多个方法(最主要是 create 规则),保证了恢复的数据的完整性和可靠性。

onParse

在后台 api 返回的数据和 model 进行数据恢复之间,有一个 hook 方法会被调用,即 onParse 方法。你可以在创建模型时重写该方法,在 restore 一开始时,该方法会被调用。

class SomeModel extends Model {
  static some = { ... }
  onParse(data) {
    return {
      ...data,
      user: localStoreage.getItem('USER'),
    }
  }
}

onParse 方法只能在创建模型时进行规定。在 restore 执行之前,onParse 会被调用,得到一个新的数据之后在 restore。因此,你可以在 onParse 里面做任何事情,特别是你知道你的 api 接口返回的数据需要进行结构或字段名调整的时候。

数据提取

将数据输出为另外一个个数,用于表单提交之类的。这是我在工作中积累出来的经验。在提交表单之前,你所需要的数据格式跟你实际在模型中管理的状态格式大部分情况下不同,前端和后段对数据格式的要求非常不一致,这很常见。通过 schema 中的配置选项,你都不需要动脑筋就可以得到一个规整后的数据。

toJson

通过 toJson 方法,你可以得到一个基于规整规则后输出的 js 对象。

const json = model.toJson()

toParams

通过 toParams 方法,你可以得到一个 urlencoded 的数据。

const params = model.toParams()

它的结果如下:

{
  'books[1].name': 'A',
  'books[1].price': 12.5
}

它的属性层级被打平为一个字符串。

toFormData

通过 toFormData 方法,得到一个 FormData 的实例。在一些要上传文件的情况下会用到。

const formdata = model.toFormData()

onExport

在利用 schema 中的规整规则格式化数据之后,你可能还需要再对数据进行一次转化,这时我们提供了一个 hook 方法,即 onExport 方法。它和 onParse 有异曲同工之妙。
class SomeModel extends Model {
  onExport(data) { // 这里的 data 是 toJson 之后得到的对象
    return {
      ...data,
      user: localStoreage.getItem('USER'),
    }
  }
}

这能保证你提交的数据,一定是按照后台 api 需要的。

onParse 和 onExport 这两个 hook 的设计,让模型具有可调整性。当后台 api 返回的数据结构发生变化,或对你提交的数据提出要求时,你完全不必去修改业务中的代码,而是直接修改模型这两个 hook 中的逻辑即可。

嵌套模型

在某些场景下,我们会创建多个模型,并且在模型中嵌套模型。这种情况其实比较常见,因此,tyshemo 内部做了处理,你可以这样使用:

import { Model } from 'tyshemo'

class PersonModel extends Model {
  static name = {
    type: String,
    default: '',
  }
  static age = {
    type: Number,
    default: 0,
  }
}

class InvestorModel extends Model {
  // 复用其他模型的单个属性
  static name = PersonModel.name
  static age = PersonModel.age

  static money = {
    type: Number,
    default: 0,
    setter: v => +v,
    getter: v => v + '',
  }
}

class ProjectModel extends Model {
  // 直接使用其他模型,表示 investor 这个属性的值是 InvestorModel 的一个实例
  // 它将直接继承整个 InvestorModel 的校验、规整化等等规则
  static investor = InvestorModel

  // 数组表示这是一个子模型列表
  static investors = [InvestorModel]

  static name = {
    type: String,
    default: '',
    required: true,
  }
}

const project = new ProjectModel()
console.log(project.investor.money)

同时,它也支持传入一个数组,表示这是一个元素全部是该模型的列表。但是,此时需要注意,它是不支持多元素的模型的。

结语

TySheMo 是一款全新的思考前端数据管理的类库。它主要解决的是在前端不同的使用场景下,如何抽象数据类型和规则逻辑,特别是在一些前后端数据耦合比较严重的系统中。使用 TySheMo 不仅可以解决数据校验问题,还可以解决数据的输入输出结构调整问题。当然,它还会有一些不足,如果你觉得这样的想法对你有一些借鉴意义,或者任何想法,欢迎在下方留言和我讨论。

2019-06-04

已有2条评论
  1. bigsir 2020-02-27 12:45

    good!

  2. 高清壁纸 2019-06-22 15:11

    学习了