MongoDB 在 NodeJS 社区有着非常广泛的使用. 虽然灵活是 MongoDB 的一大优势, 但是真正业务中不希望它 ‘过于灵活’, 因此一般都会搭配 mongoose 来使用, 所以基本也会定义 Schema.

由于 mongoose 出现很早, 它是以 Object 的形式来定义 Schema. 但是随着 Typescript 的流行, ts 和 mongoose 结合使用时, 定义 Model 类型时不能直接使用 Schema 类型, 所以我们一般都要根据对应的 Schema 类型再写一个类型: type UserModel = mongoose.Model<UserType>.

综上, 我决定写一个自动化工具来解放双手, 减少人工失误.

工具使用介绍

import { ModelGenerator } from '@zcong/ts-mongoose-code-generator'

const testSchema = new Schema({
  name: String,
  age: {
    type: Number
  },
  requiredName: {
    type: String,
    required: true
  }
}

const dtoGen = new ModelGenerator({
  filename: `${__dirname}/modelGen.ts`,
  useInterface: true, // 使用 interface 或者 class 类型
  arrayStyle: 'squareBrackets', // 数组生成格式 squareBrackets(T[]) or generic(Array<T>)
  // 是否优化 enum, if set true, String type with enum field
  // ['test1', 'test2'] will be type `'test1' | 'test2'`
  stringEnumUseUnionType: true,
})
dtoGen.generateModelBySchema(testSchema, 'Test')
dtoGen.getFile().saveSync() // save generated code as file
dtoGen.getGeneratedCode() // get generated code content

// export interface TestModel {
//   name?: string;
//   age?: number;
//   requiredName: string;
// }

原理

因为 mongoose Schema 已经是结构化的了, 所以这件事情基本分为两步, 将 Schema 转化成方便处理的类型和生成最终代码.

parser

首先想想我们需要的字段信息:

  • type
  • required
  • enum
  • isArray
  • ref
Schema {
  paths: {
    name: SchemaString {
      enumValues: [],
      path: 'name',
      instance: 'String',
      options: {
        required: true,
        unique: true
      },
      isRequired: true
    }
  }
}

对于基本类型(‘ObjectID’, ‘String’, ‘Number’, ‘Date’, ‘Boolean’, ‘Mixed’, ‘Buffer’, ‘Map’, ‘Decimal128’), 上面的这些包含了我们想要的信息.

接着要处理数组类型, 嵌套类型, 嵌套 Schema 这几种情况.

对于数组类型

SchemaArray {
  caster: SchemaString {
    enumValues: [],
    path: 'arr',
    instance: 'String',
  },
  '$isMongooseArray': true,
  path: 'arr',
  instance: 'Array',
}

可以看出 instance 类型是 Array, caster 字段就是数组元素的类型信息, 递归处理.

对于嵌套 Schema

SingleNestedPath {
  schema: Schema {
    paths: {
      // ...
    },
  },
  '$isSingleNested': true,
  path: 'nested',
  instance: 'Embedded'
}

可以看出 instance 类型是 Embedded, schema 字段是子文档的类型信息, 递归处理.

(直接)嵌套类型

new Schema({
  nested2: {
    name: String,
    age: Number
  },
})

{
  'nested2.name': SchemaString {
    enumValues: [],
    path: 'nested2.name',
    instance: 'String',
    options: {},
  },
  'nested2.age': SchemaNumber {
    path: 'nested2.age',
    instance: 'Number',
    options: {},
  }
}

此种类型 Schema 处理的比较诡异, 结构被扁平化处理了, 所以我们需要先把它结构恢复回去, 并标记成 Embedded 类型.

于是我们就可以把每一个字段处理成下面这个结构:

export interface PropertyType {
  type: string
  isArray?: boolean
  enumValues?: any[]
  $__schemaType?: any
  rawOpts?: any
}

export interface ParsedField {
  type: PropertyType
  options?: { required?: boolean; ref?: string }
  schema?: ParsedType
}

export interface ParsedType {
  [key: string]: ParsedField
}

相关处理代码可在 zcong1993/mongoose-schema-parser 查看.

生成代码

有了结构化的数据, 把他们转化成 TS 代码并不难. 但是如果直接使用字符串拼接, 不太具有结构化, 而且缩进处理起来不是很舒服, 代码看起来也不清晰. 所以我们选择 ts-morph 这个库来生成代码.

简单介绍下它的使用:

const project = new Project({
  manipulationSettings: {
    indentationText: IndentationText.TwoSpaces,
    quoteKind: QuoteKind.Single,
  },
})
const file = project.createSourceFile(opts.filename || 'tmp.ts', '', {
  overwrite: true,
})

// add interface
const inter = file.addInterface({
  name: 'Test',
  isExported: true,
})

// add property
inter.addProperty({
  hasQuestionToken: true,
  name: 'name',
  type: 'string',
})

console.log(file.getFullText())
// Output:
// interface Test {
//   name?: string
// }

可以看到代码非常清晰, 而且缩进之类的也会处理得当.

言归正传, 需要我们做的其实是两点:

  • 基本类型处理成 ts 类型或者 mongoose 提供的类型
  • 复杂类型递归生成子类型结构

基本类型对应 ts 类型可参考下面表格:

MongoDB TS
Boolean boolean
String string
Number number
Date Date
ObjectID mongoose.Types.ObjectID
Mixed any
Buffer Buffer
Map any
Decimal128 mongoose.Types.Decimal128

对于 enum 类型, 可优化为 enum 字面量的联合类型, 例如: type: ‘A’ | ‘B’

遇到 SchemaEmbedded 类型时, 递归处理生成子类型就好了.

相关处理代码可在 ts-mongoose-code-generator 查看.

后记

我一直认为如果更改一个字段声名需要改动两个地方以上, 就很容易出现不一致的情况, 因为人在做重复劳动时总是会犯错, 所以我们需要一些工具来做一些有固定模式化的事情.

但是我觉得 ORM 的未来肯定是基于 注解式 声名, 这样就能统一 Schema 结构和 Model 类型, 就像 typeorm 那样.