背景

近一段時間我們團隊的項目大部分都開始使用 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

安裝依賴

  1. 安裝依賴

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)。我們在正式使用中目前也沒有遇到大的問題,該項目目前也比較活躍,很多新的特性也在開發中,建議可以做一些嘗試。


推薦閱讀:
相关文章