TypeScript + GraphQL = TypeGraphQL
背景
近一段時間我們團隊的項目大部分都開始使用 TypeScript 作為開發語言。TypeScript 在項目維護和重構的時候可以帶來非常明顯的好處。之前一個項目中我們使用了 GraphQL 替代了傳統的 REST API。所以在最新的一個 node.js 服務的項目中,我們使用了 TypeScript + GraphQL。下面我會介紹一下 TypeScript 結合 GraphQL 在 egg.js 的一個實踐。
前言
GraphQL 在我們之前的項目中的使用情況非常不錯,後端可以只需要專註於合理的 Schema 設計與開發,並不需要太關心界面上的功能交互,在前端我們用 Apollo GraphQL 替代了 Redux 結合 React 也獲得了很好的開發體驗 (還在用 Redux,要不要試試 GraphQL 和 Apollo?) 。
我們在準備使用 TypeScript 來寫 GraphQL 的時候,我們會有面臨一個最大的問題
GraphQL Schema Type DSL 和數據 Modal 需要寫兩份么?
TypeGraphQL ( type-graphql ) 是我們今天介紹的重點,它通過一些 decorator 幫我們解決了這個問題。下面我會先介紹如何構建一個基於 egg.js 的 TypeScript + GraphQL 工程,然後會介紹一些 TypeGraphQL 常見用法。
構建
初始化工程
egg.js 對 TypeScript 現在已經有了比較好的支持 (參考),下面我們先創建一個基於 TypeScript 的 egg.js 工程。
npx egg-init --type=ts type-graphql-demo
cd type-graphql-demo
yarn && yarn dev
通過 egg.js 提供的腳手架生成後,可以得到下面的一個工程目錄結構
├── app
│ ├── controller
│ │ └── home.ts
│ ├── service
│ │ └── news.ts
│ └── router.ts
├── config
│ ├── config.default.ts
│ ├── config.local.ts
│ ├── config.prod.ts
│ └── plugin.ts
├── test
│ └── **/*.test.ts
├── typings
│ └── **/*.d.ts
├── README.md
├── package.json
├── tsconfig.json
└── tslint.json
安裝依賴
- 安裝依賴
yarn add type-graphql
2. 安裝 reflect-metadata
yarn add reflect-metadata
3. reflect-metadata 需要在入口或者使用 type-graphql 之前被引入,建議在 app.ts 中引入
// ~/app.ts
import "reflect-metadata";
4. 安裝 apollo-server-koa , 處理請求路由( egg.js 是基於 koa )
yarn add apollo-server-koa
集成中間件路由
// ~/app/graphql/index.ts
import * as path from "path";
import { ApolloServer } from "apollo-server-koa";
import { Application } from "egg";
import { GraphQLSchema } from "graphql";
import { buildSchema } from "type-graphql";
export interface GraphQLConfig {
router: string;
graphiql: boolean;
}
export default class GraphQL {
private readonly app: Application;
private graphqlSchema: GraphQLSchema;
private config: GraphQLConfig;
constructor(app: Application) {
this.app = app;
this.config = app.config.graphql;
}
getResolvers() {
const isLocal = this.app.env === "local";
return [path.resolve(this.app.baseDir, `app/graphql/schema/**/*.${isLocal ? "ts" : "js"}`)];
}
async init() {
this.graphqlSchema = await buildSchema({
resolvers: this.getResolvers(),
dateScalarMode: "timestamp"
});
const server = new ApolloServer({
schema: this.graphqlSchema,
tracing: false,
context: ({ ctx }) => ctx, // 將 egg 的 context 作為 Resolver 傳遞的上下文
playground: {
settings: {
"request.credentials": "include"
}
} as any,
introspection: true
});
server.applyMiddleware({
app: this.app,
path: this.config.router,
cors: false
});
this.app.logger.info("graphql server init");
}
// async query({query, var})
get schema(): GraphQLSchema {
return this.graphqlSchema;
}
}
~/app/extend/application.ts
import { Application } from "egg";
import GraphQL from "../graphql";
const TYPE_GRAPHQL_SYMBOL = Symbol("Application#TypeGraphql");
export default {
get graphql(this: Application): GraphQL {
if (!this[TYPE_GRAPHQL_SYMBOL]) {
this[TYPE_GRAPHQL_SYMBOL] = new GraphQL(this);
}
return this[TYPE_GRAPHQL_SYMBOL];
}
};
~/app.ts
import "reflect-metadata";
import { Application } from "egg";
export default async (app: Application) => {
await app.graphql.init();
app.logger.info("started");
}
使用 TypeGraphQL 創建 Schema
下面簡單介紹一下 TypeGraphQL 的一些基本的用法。詳細文檔鏈接
定義 Schema
TypeGraphQL 提供了一些 decorator 來幫助我們通過 class 類來聲明 graphql DSL。
ObjectType & InputType
- @ObjectType 創建 GraphQLObjectType
- @InputType 創建 GraphQLInputType
- @Field 聲明對象的哪些欄位作為 GraphQL 的欄位,複雜類型的欄位需要通過
type => Rate
聲明
@ObjectType({ description: "The recipe model" })
class Recipe {
@Field(type => ID)
id: string;
@Field({ description: "The title of the recipe" })
title: string;
@Field(type => [Rate])
ratings: Rate[];
@Field({ nullable: true })
averageRating?: number;
}
@InputType({ description: "New recipe data" })
class AddRecipeInput implements Partial<Recipe> {
@Field()
title: string;
@Field({ nullable: true })
description?: string;
}
介面與繼承
TypeScript 的介面只是在編譯時存在,所以對於 GraphQL 的 interface,我們需要藉助於抽象類來聲明。
abstract class IPerson {
@Field(type => ID)
id: string;
@Field()
name: string;
@Field(type => Int)
age: number;
}
@ObjectType({ implements: IPerson })
class Person implements IPerson {
id: string;
name: string;
age: number;
}
對於繼承,子類和父類必須有相同的 ObjectType 或者 InputType。
@ObjectType()
class Person {
@Field()
age: number;
}
@ObjectType()
class Student extends Person {
@Field()
universityName: string;
}
??注意:在使用繼承後,Resolver 裡面返回 Plain Object 作為結果的時候會報錯,這個 Bug (#160) 還未修復。
Resolvers
對於 Resolver 的處理,TypeGraphQL 提供了一些列的 decorator 來聲明和處理數據。通過 Resolver 類的方法來聲明 Query 和 Mutation,以及動態欄位的處理 FieldResolver。
- @Resolver:來聲明當前類是數據處理的
- @Query:聲明改方法是一個 Query 查詢操作
- @Mutation:聲明改方法是一個 Mutation 修改操作
- @FieldResovler:對
@Resolver(of => Recipe)
返回的對象添加一個欄位處理
方法參數:
- @Root:獲取當前查詢對象
- @Ctx:獲取當前上下文,這裡可以拿到 egg 的 Context (見上面中間件集成中的處理)
- @Arg:定義 input 參數
@Resolver(of => Recipe)
class RecipeResolver {
// ...
@Query(returns => [Recipe])
async recipes(
@Arg("title", { nullable: true }) title?: string,
@Arg("servings", { defaultValue: 2 }) servings: number,
): Promise<Recipe[]> {
// ...
}
@FieldResolver()
averageRating(@Root() recipe: Recipe, @Ctx() ctx: Context) {
// handle with egg context
}
@Mutation()
addRecipe(
@Arg("data") newRecipeData: AddRecipeInput,
@Ctx() ctx: Context,
): Recipe {
// handle with egg context
}
}
Scalars & Enums & Unions
GraphQL 的其他特性比如 scalar、enum、union、subscriptions 等,TypeGraphQL 都做了很好的支持,在使用 TypeScript 編寫的時候更加方便。
Scalars
默認提供了 3 個基本類型的別名
- Int --> GraphQLInt;
- Float --> GraphQLFloat;
- ID --> GraphQLID;
默認提供了日期類型 Date
的 scalar 處理,它支持兩種時間格式:
- timestamp based (
"timestamp"
) -1518037458374
- ISO format (
"isoDate"
) -"2018-02-07T21:04:39.573Z"
import { buildSchema } from "type-graphql";
const schema = await buildSchema({
resolvers,
dateScalarMode: "timestamp", // "timestamp" or "isoDate"
});
Enums
TypeScript 支持 enum 類型,可以和 GraphQL 的 enum 進行復用。
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
import { registerEnumType } from "type-graphql";
registerEnumType(Direction, {
name: "Direction", // this one is mandatory
description: "The basic directions", // this one is optional
});
Unions
TypeGraphQL 提供了 createUnionType
方法來創建一個 union 類型。
@ObjectType()
class Movie {
...fields
}
@ObjectType()
class Actor {
...fields
}
import { createUnionType } from "type-graphql";
const SearchResultUnion = createUnionType({
name: "SearchResult", // the name of the GraphQL union
types: [Movie, Actor], // array of object types classes
});
其他新特性
Authorization
TypeGraphQL 默認提供了攔截器 AuthChecker
和註解 @Authorized()
來進行許可權校驗。如果校驗不通過,則會返回 null 或者報錯,取決於當前欄位或者操作是否支持 nullable。
實現一個 AuthChecker
:
export const customAuthChecker: AuthChecker<ContextType> =
({ root, args, context, info }, roles) => {
// here you can read user from context
// and check his permission in db against `roles` argument
// that comes from `@Authorized`, eg. ["ADMIN", "MODERATOR"]
return true; // or false if access denied
}
配合 @Authorized
使用
@ObjectType()
class MyObject {
@Field()
publicField: string;
@Authorized()
@Field()
authorizedField: string;
@Authorized("ADMIN")
@Field()
adminField: string;
}
Validation
TypeGraphQL 默認集成了 class-validator
來做數據校驗。
import { MaxLength, Length } from "class-validator";
@InputType()
export class RecipeInput {
@Field()
@MaxLength(30)
title: string;
@Field({ nullable: true })
@Length(30, 255)
description?: string;
}
Middlewares
TypeGraphQL 提供了類似於 koa.js
的中間件處理。
export const ResolveTime: MiddlewareFn = async ({ info }, next) => {
const start = Date.now();
await next();
const resolveTime = Date.now() - start;
console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`);
};
Global middlewares
全局中間件會攔截所有的 query、mutation、subscription、field resolver。可以來做一些全局相關的事情,比如異常攔截,請求跟蹤(數據量大小,深度控制)等
const schema = await buildSchema({
resolvers: [RecipeResolver],
globalMiddlewares: [ErrorInterceptor, ResolveTime],
});
Attaching middlewares
配合 @UseMiddleware()
對單個欄位做攔截
@Resolver()
export class RecipeResolver {
@Query()
@UseMiddleware(ResolveTime, LogAccess)
randomValue(): number {
return Math.random();
}
}
Query complexity
TypeGraphQL 默認提供了查詢複雜度控制,來防止一些惡意或者無意的過度複雜查詢消耗大量的服務端資源,比如資料庫連接等。
詳細使用方法參考文檔
總結
雖然 TypeGraphQL 還沒有正式 1.0.release
,但是目前的版本已經是 MVP (Minimum Viable Product)。我們在正式使用中目前也沒有遇到大的問題,該項目目前也比較活躍,很多新的特性也在開發中,建議可以做一些嘗試。
推薦閱讀: