使用TypeScript根据MongoDB(mongoose)的模式生成GraphQL的解析器和模式
从个人角度来看,我认为MongoDB(面向文档的数据库)和GraphQL的兼容性很好,主要是因为可以在一定程度上减轻所谓的“应将数据库表结构直接转化为GraphQL模式”的设计上的复杂点。在GraphQL模式设计中,反模式与文档模式设计的反模式基本相同,因此,如果能够正确设计文档模式(或者更准确地说是在GraphQL中连接),就可以直接重用到GraphQL模式中,这种想法还算可以接受吧。
现在有一个叫作graphql-compose的工具包。它的概念是使用JavaScript而不是SDL来编写GraphQL模式(代码优先),并提供了一种从mongoose的Model对象中生成GraphQL模式的功能作为插件。此外,它还会自动创建简单的CRUD解析器,因此如果能正确使用它,编码量会大大减少,非常方便。
不过,“好好使用”这一点非常麻烦,具体说来就是类型标记麻烦。这是种种原因造成的情况,不得已而为之呢。
所以在本文中,我们将解释如何在TypeScript中正确使用这些库,包括graphql-compose、graphql-compose-mongoose和mongoose。基本上,
-
- これ → TypeScript Support – mongoose と
- これ → An full example using TypeScript with mongoose – graphql-compose-mongoose
为了基于这个讨论的基础进行下去,如果您还没有阅读过,请务必一读。
撰写时的版本
node: 16.14.2
typescript: 4.6.4
ts-node: 10.7.0
mongoose: 6.3.2
graphql: 16.4.0
graphql-compose: 9.0.8
graphql-compose-mongoose: 9.7.1
这篇文章的目标是什么?
-
- mongooseのSchemaおよびModelに適切な型をつける
-
- ModelをもとにシンプルなリゾルバおよびGraphQLSchemaオブジェクトを生成する
-
- Modelにつけた型をもとにリゾルバを自作する
- 生成されたGraphQLSchemaオブジェクトを.graphqlファイルに書き出す
解說
步骤1:模式及模型(mongoose)
interface Character {
name: string
class: Class
level: number
}
const Class = [
"Hero",
"Bandit",
"Astrologer",
"Warrior",
"Prisoner",
"Confessor",
"Wretch",
"Vagabond",
"Prophet",
"Samurai",
] as const
type Class = typeof Class[number]
请准备一个基本类型。由于将枚举定义为枚举会导致麻烦,所以请参考以下步骤进行定义。
参考:再见,TypeScript枚举。
下一个步骤是根据这个模型创建模式。在官方文档中是这样描述的。
const characterSchema = new Schema<Character & Document>({
name: {
type: String,
required: true,
}
...
})
![image.png](https://cdn.silicloud.com/blog-img/blog/img/657d8006913a08637a6a85fe/16-0.png)
建议您先创建一个 SchemaDefinition 类型的对象,然后再使用 new Schema() 进行操作。
import { SchemaDefinition, Schema, Document} from "mongoose"
const characterSchemaDefinition: Required<SchemaDefinition<Character>> = {
name: {
type: String,
required: true,
},
class: {
type: String,
required: true,
enum: Class,
},
level: {
type: Number,
required: true,
min: 1,
max: 713,
},
}
type ICharacter = Character & Document
// オプションはお好みで
const characterSchema = new Schema<ICharacter>(characterSchemaDefinition, {
versionKey: false,
timestamps: true,
})
从最后定义的Schema创建模型,就这样结束了。
import { Model, models } from "mongoose"
const characterModel: Model<ICharacter> =
models.Character || model("Character", characterSchema, "characters")
依照文件所述。
const characterModel = model("Character", characterSchema, "characters")
必须注意,在这样的写法下,在热重载时会尝试重新定义模型,可能会引发错误。
步骤2:嵌入式解析器和GraphQL模式
graphql-compose-mongoose这个库可以根据Model的定义自动生成具备基本功能的解析器,其中包括我个人非常推荐的分页功能等等。这里列出了提供的解析器列表。
import { schemaComposer } from "graphql-compose"
import { composeMongoose, ObjectTypeComposerWithMongooseResolvers } from "graphql-compose-mongoose"
// これもホットリロード時対策です
const characterTC = schemaComposer.has("Character")
? (schemaComposer.getOTC("Character") as ObjectTypeComposerWithMongooseResolvers<ICharacter>)
: composeMongoose(characterModel)
schemaComposer.Query.addFields({
character: characterTC.mongooseResolvers.findById(),
characters: characterTC.mongooseResolvers.pagination(),
charactersCount: characterTC.mongooseResolvers.count(),
})
schemaComposer.Mutation.addFields({
createCharacter: characterTC.mongooseResolvers.createOne(),
updateCharacter: characterTC.mongooseResolvers.updateById(),
removeCharacter: characterTC.mongooseResolvers.removeById(),
})
const schema = schemaComposer.buildSchema()
让我们在这里查看一次生成的GraphQL模式的内容。
import { printSchema } from "graphql"
console.log(printSchema(schema))
type Query {
character(_id: MongoID!): Character
characters(
"""Page number for displaying"""
page: Int
""""""
perPage: Int = 20
"""Filter by fields"""
filter: FilterFindManyCharacterInput
sort: SortFindManyCharacterInput
): CharacterPagination
charactersCount(
"""Filter by fields"""
filter: FilterCountCharacterInput
): Int
}
type Character {
name: String!
class: EnumCharacterClass!
level: Float!
_id: MongoID!
updatedAt: Date
createdAt: Date
}
enum EnumCharacterClass {
Hero
Bandit
Astrologer
Warrior
Prisoner
Confessor
Wretch
Vagabond
Prophet
Samurai
}
"""
The `ID` scalar type represents a unique MongoDB identifier in collection. MongoDB by default use 12-byte ObjectId value (https://docs.mongodb.com/manual/reference/bson-types/#objectid). But MongoDB also may accepts string or integer as correct values for _id field.
"""
scalar MongoID
scalar Date
"""List of items with pagination."""
type CharacterPagination {
"""Total object count."""
count: Int
"""Array of objects."""
items: [Character!]
"""Information to aid in pagination."""
pageInfo: PaginationInfo!
}
type PaginationInfo {
currentPage: Int!
perPage: Int!
pageCount: Int
itemCount: Int
hasNextPage: Boolean
hasPreviousPage: Boolean
}
""""""
input FilterFindManyCharacterInput {
name: String
class: EnumCharacterClass
level: Float
_id: MongoID
updatedAt: Date
createdAt: Date
"""List of *indexed* fields that can be filtered via operators."""
_operators: FilterFindManyCharacterOperatorsInput
OR: [FilterFindManyCharacterInput!]
AND: [FilterFindManyCharacterInput!]
}
"""For performance reason this type contains only *indexed* fields."""
input FilterFindManyCharacterOperatorsInput {
_id: FilterFindManyCharacter_idOperatorsInput
}
input FilterFindManyCharacter_idOperatorsInput {
gt: MongoID
gte: MongoID
lt: MongoID
lte: MongoID
ne: MongoID
in: [MongoID]
nin: [MongoID]
exists: Boolean
}
enum SortFindManyCharacterInput {
_ID_ASC
_ID_DESC
}
""""""
input FilterCountCharacterInput {
name: String
class: EnumCharacterClass
level: Float
_id: MongoID
updatedAt: Date
createdAt: Date
"""List of *indexed* fields that can be filtered via operators."""
_operators: FilterCountCharacterOperatorsInput
OR: [FilterCountCharacterInput!]
AND: [FilterCountCharacterInput!]
}
"""For performance reason this type contains only *indexed* fields."""
input FilterCountCharacterOperatorsInput {
_id: FilterCountCharacter_idOperatorsInput
}
input FilterCountCharacter_idOperatorsInput {
gt: MongoID
gte: MongoID
lt: MongoID
lte: MongoID
ne: MongoID
in: [MongoID]
nin: [MongoID]
exists: Boolean
}
type Mutation {
"""
Create one document with mongoose defaults, setters, hooks and validation
"""
createCharacter(record: CreateOneCharacterInput!): CreateOneCharacterPayload
"""
Update one document: 1) Retrieve one document by findById. 2) Apply updates to mongoose document. 3) Mongoose applies defaults, setters, hooks and validation. 4) And save it.
"""
updateCharacter(_id: MongoID!, record: UpdateByIdCharacterInput!): UpdateByIdCharacterPayload
"""
Remove one document: 1) Retrieve one document and remove with hooks via findByIdAndRemove. 2) Return removed document.
"""
removeCharacter(_id: MongoID!): RemoveByIdCharacterPayload
}
type CreateOneCharacterPayload {
"""Document ID"""
recordId: MongoID
"""Created document"""
record: Character
"""
Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
"""
error: ErrorInterface
}
interface ErrorInterface {
"""Generic error message"""
message: String
}
""""""
input CreateOneCharacterInput {
name: String!
class: EnumCharacterClass!
level: Float!
updatedAt: Date
createdAt: Date
}
type UpdateByIdCharacterPayload {
"""Document ID"""
recordId: MongoID
"""Updated document"""
record: Character
"""
Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
"""
error: ErrorInterface
}
""""""
input UpdateByIdCharacterInput {
name: String
class: EnumCharacterClass
level: Float
updatedAt: Date
createdAt: Date
}
type RemoveByIdCharacterPayload {
"""Document ID"""
recordId: MongoID
"""Removed document"""
record: Character
"""
Error that may occur during operation. If you request this field in GraphQL query, you will receive typed error in payload; otherwise error will be provided in root `errors` field of GraphQL response.
"""
error: ErrorInterface
}
太棒了,您做得非常出色。
步骤3:自己创建的解析器
我认为有时候必须自己制作解析器,即使如此。
一般来说,在解析器接收的参数中,有些必须自己指定类型。
-
- source: 親リゾルバがある場合その戻り値。parentとも
-
- args: このクエリの引数
- context: 全てのリゾルバが共通で用いる値
以下是三个选项,但是graphql-compose对于这种指定方式非常晦涩难懂。
首先是背景。如果给上下文中的类型加上schemaComposer,则应该从graphql-compose中导入SchemaComposer,而不是schemaComposer。
import { SchemaComposer } from "graphql-compose"
interface TContext {
req: Request
}
const schemaComposer = new SchemaComposer<TContext>()
![image.png](https://cdn.silicloud.com/blog-img/blog/img/657d8006913a08637a6a85fe/37-0.png)
然后将source和args的类型传递给我刚创建的schemaComposer的createResolver方法。
schemaComposer.Mutation.addFields({
createRandomCharacter: schemaComposer.createResolver<undefined, { name?: string }>({
name: "createRandomCharacter",
type: characterTC,
args: { name: "String" },
resolve: async (params): Promise<ICharacter> => {
const character = new characterModel()
character.name = params.args.name || Math.random().toString(36).slice(-8)
character.class = Class[Math.floor(Math.random() * Class.length)]
character.level = Math.floor(Math.random() * 713 + 1)
character.save()
return character
},
}),
})
![image.png](https://cdn.silicloud.com/blog-img/blog/img/657d8006913a08637a6a85fe/40-0.png)
步骤4:导出模式
為什麼你要做這樣的事情呢?原因很簡單,就是為了全力搭乘GraphQL生態系統的便利性。
现有的GraphQL工具都
-
- GraphQLスキーマを作るもの
- GraphQLスキーマを元になんかするもの
由于可以根据需要随意组合,所以可以先创建一个.graphql的机制,以便后期可以自由地互换前者和后者的组合。
例如,前端现在使用vue2,所以使用Apollo,但是如果将来转换到vue3后想转换到urql,只需将typescript-vue-apollo插件更换为typescript-vue-urql即可,而API则会在Go中实现!这样即使是大胆的替换操作,也可以规划出一条路径。
因此,无论使用何种工具,都应将创建的模式编写到外部文件中。这是绝对必要的。
现在转入正题,我认为在TS中最简单的方法是,将导入的模式编写到fs.write()脚本中,然后让ts-node运行该脚本。
// ~/schema.ts
export const schema = schemaComposer.buildSchema()
// ~/sdlgen.ts
import { write } from "fs"
import { printSchema } from "graphql"
import { schema } from "~/schema"
write("Schema.graphql", printSchema(schema))
// package.json
"scripts": {
"codegen": "ts-node -r tsconfig-paths/register -O {\\\"module\\\":\\\"commonjs\\\"} sdlgen.ts
}
只需在前面提到的GraphQL Code Generator中编写您选择的Document.graphql和codegen.yml文件,然后运行graphql-codegen,即可生成前端代码。
结束
理解了要把模板交给谁之后的开发体验非常好。在使用GraphQL时,我不太愿意手写SDL,所以能几乎跳过那一部分真是太好了。
实际上,在第一步之后还要对模式进行自定义,这一部分有时候会有些复杂,但我打算以后再加上这部分的内容。