Introducing GraphQL
GraphQL 是一種用于 API 的查詢語言, 并且提供已有數(shù)據(jù)查詢的運行時, 它誕生于 2015 年, 由 Facebook 開發(fā), 2018 年 11 月 7 日,F(xiàn)acebook 將 GraphQL 項目轉(zhuǎn)移到新成立的 GraphQL 基金會. 目前 Facebook, Twitter, Netflix, PayPal 各廠已經(jīng)在生產(chǎn)環(huán)境使用 GraphQL 了, GitHub API v4 也已全面使用 GraphQL.

精準、可預測地返回數(shù)據(jù)
傳統(tǒng)的 RESTful 接口, 后端傳遞多少字段, 前端就得接收多少字段. 因此, 有時候前端只需要幾個字段, 但后端返回一大串(尤其是歷史悠久的接口), 這不但對前端篩選接口字段增加了難度, 還可能會造成潛在的性能問題. 而 GraphQL 使得客戶端能夠準確地獲得它需要的數(shù)據(jù), 而且沒有任何冗余, 并且 GraphQL 篩選這些字段的過程不依賴于服務器, 而是它自己運行時.
export const POSTS = gql`query Posts($input: PaginationInput!) {posts(input: $input) {totalpagepageSizeitems {_idtitlesummary}}}
@ObjectType()export class SMSModel {@Field()@IsMobilePhone("zh-CN")@IsNotEmpty()public readonly phoneNumber: string;@Field()@Length(6)@IsNumberString()@IsNotEmpty()public readonly smsCode: string;}
只請求一個接口
下面這個例子是一個經(jīng)典的 RESTful 風格接口, 可以看到一套增刪改查需要請求不同的 url, 這就導致了需要進行多個 TCP 連接. 雖然 HTTP2 提供了多路復用(同域名下所有通信都在單個連接上完成, 同個域名只需要占用一個 TCP 連接, 使用一個連接并行發(fā)送多個請求和響應)的特性. 但在網(wǎng)絡(luò)仍然較慢的移動環(huán)境下, 我們?nèi)韵MM可能的減少 HTTP 請求, GraphQL 的應用也能表現(xiàn)得足夠迅速.
GET /postsGET /post/:idPOST /postPUT /post/:idDELETE /post/:id
{operationName: "Posts",query: "...",variables: {input: {page: 1,pageSize: 10,},},}
SDL(schema definition languages)
Type Language
GraphQL 不依賴于任何編程語言, 因為我們并不依賴于任何特定語言的句法句式, 它有自己的一套模式.
type Language {code: String!name: String!native: String!}type Location {geoname_id: Float!capital: String!languages: [Language!]!country_flag: String!country_flag_emoji: String!country_flag_emoji_unicode: String!calling_code: String!is_eu: Boolean!created_at: DateTime!}
Language代表 GraphQL對象類型, 一般用來約定后端的 response.code,name,native是Language類型上的字段, 這意味著你在查詢Language時只能查找這三個字段中的一個或多個, 查找任何其他字段將會報錯.code: String!意味著code的標量是String, 感嘆號意味著該字段是非空的, 如果后端返回改字段是空的, 也會報錯.languages: [Language!]!意味著languages的類型是Language 數(shù)組, 且該數(shù)組不能為空.
type Query {getPosts(input: PaginationInput!): PostModel!}type Mutation {createPost(input: CreatePostInput!): PostItemModel!}input CreatePostInput {posterUrl: String!title: String!summary: String!content: String!tags: [String!]!lastModifiedDate: String!isPublic: Boolean}
Query 和 Mutation 是兩個內(nèi)置的特殊類型, 你可以將其理解為 RESTful 中的 GET 和 POST, 前者用于查詢, 后者用于增刪改. 雖然使用 Query 可以進行增刪改, 但為了語義化, 建議分開使用.
第一個語句定義一個查詢, getPost 可以類比為 RESTful 接口中的路徑; 而 input 則可以類比放在 body 中的參數(shù), 它是 CreatePostInput 類型, 且是必傳的, input 類型定義一次查詢或變更中傳遞的對象參數(shù); 該查詢返回 PostModel 類型的數(shù)據(jù), 且該數(shù)據(jù)必須為非空. 第二個語句定義一次變更, 語義同理.
Scalar
"標量", 可以理解為 GraphQL 中字段的基礎(chǔ)類型, 默認有 Int, Float, String, Boolean, ID 五種. 有時候你需要擴展適合自己業(yè)務的標量, 每個標量需要實現(xiàn) parseValue, serialize, parseLiteral 三個方法, 如下是 DateScalar.
import { Scalar, CustomScalar } from "@nestjs/graphql";import { Kind, ValueNode } from "graphql";@Scalar("Date")export class DateScalar implements CustomScalar<number, Date> {description = "Date custom scalar type";parseValue(value: number): Date {return new Date(value); // value from the client}serialize(value: Date): number {return value.getTime(); // value sent to the client}parseLiteral(ast: ValueNode): Date {if (ast.kind === Kind.INT) {return new Date(ast.value);}return null;}}
標量的目的是能夠更加精確的確定一個字段的類型, 不過寫個新的確實比較麻煩, 好在 graphql-scalars 預設(shè)了大約 50 個標量, 比如 PositiveInt, NegativeInt, DateTime, Date, EmailAddress, HexColorCode 等等.
Enum
枚舉類型是一種特殊的標量, 它限制在一個特殊的可選值集合內(nèi). 這讓你能夠:
驗證這個類型的任何參數(shù)是可選值的某一個 與類型系統(tǒng)溝通, 一個字段總是一個有限值集合的其中一個值
enum PostStatus {DRAFTPUBLISH}
Interfaces
跟許多類型系統(tǒng)一樣, GraphQL 支持接口. 一個接口是一個抽象類型, 它包含某些字段, 而對象類型必須包含這些字段, 才能算實現(xiàn)了這個接口.
interface Common {status_msg: String!status_code: Int!}type User implements Common {id: ID!name: String!email: String!status_msg: String!status_code: Int!}
代碼優(yōu)先
在真實的開發(fā)中, 我們可以像上面一樣, 通過編寫 GraphQL 原生語言來創(chuàng)建 GraphQL SDL, 當然我們也可以通過代碼優(yōu)先的方式, 即通過 TypeScript 裝飾器來生成. 下面的代碼, 除了定義字段的類型, 比如 posterUrl 的類型是 String 標量, 且為非空; 還能"夾帶私貨", 比如限制 posterUrl 是 url 格式的字符串, 這樣就更加細粒度的對數(shù)據(jù)類型進行限制.
@InputType()export class CreatePostInput {@Field({ nullable: false })@IsString()@IsUrl({ protocols: ["https"], require_protocol: true })@IsNotEmpty()public readonly posterUrl: string;@Field({ nullable: false })@IsString()@MinLength(1)@MaxLength(20)@IsNotEmpty()public readonly title: string;@Field({ nullable: false })@IsString()@IsNotEmpty()public readonly summary: string;@Field({ nullable: false })@IsString()@IsNotEmpty()public readonly content: string;@Field(() => [String], { nullable: false })@IsArray()@IsString({ each: true })@ArrayNotEmpty()@ArrayUnique()@IsNotEmpty()public readonly tags: string[];@Field({ nullable: false })@IsString()@IsNotEmpty()public readonly lastModifiedDate: string;@Field({ nullable: true })public readonly isPublic?: boolean;}
下面的代碼則是 GraphQL 的解析器, 同樣通過注解的方式來創(chuàng)建 Query 和 Mutation:
@Query(() => PostItemModel)代表著返回值為PostItemModel類型;getPostById定義這個查詢的名稱;@Args({ name: "id", type: () => ID })用來定義傳參, 我需要傳遞一個字段 id, 它的標量為 ID
@Resolver()export class PostsResolver {constructor(private readonly postsService: PostsService) {this.postsService = postsService;}@Query(() => PostItemModel)public async getPostById(@Args({ name: "id", type: () => ID }) id: string) {return this.postsService.findOneById(id); // 處理 SQL}@Mutation(() => PostItemModel)@UseGuards(GqlAuthGuard)public async createPost(@Args("input") input: CreatePostInput) {return this.postsService.create(input); // 處理 SQL}}
前端

GraphQL 在前端的本質(zhì)表現(xiàn)就是向你的接口, 如 https://api.example.com/graphql 上發(fā)送一個 POST 請求, 而請求的 body 就如上圖所示. 但為了更加的和 GrapqhQL 語法配合, 前端涌現(xiàn)了一些不錯的庫, 如 Facebook 自家的 relay, relay 經(jīng)歷了兩次重大迭代, 目前 Facebook 官網(wǎng)用的是最新一代, 名字叫 relay morden.

雖然 relay 是一個開源項目, 但它更多是為 Facebook 內(nèi)部業(yè)務服務, 因此外部人用起來比較難受. 目前最廣泛的框架則是 Apollo, 它支持基于 Hooks 的 React 前端框架, 也支持 Vue, Angular, Android 和 iOS, 也提供了基于 Node.js 的后端框架 Appolo Server.

fragment 是用來定義片段, 如下面的例子, 我們定義查詢一篇文章, 返回的是一篇文章實體; 修改一篇文章, 返回的也是文章修改后的實體. 這樣它們的返回值基本都是一樣的, 為了不寫多次, 可以通過 fragment 來進行提取, 簡化代碼書寫.
第二段代碼, 請求的變更是 createPost, 它的參數(shù) input 是 CreatePostInput 類型, 且為必傳. 因為我們使用了 fragment, 因此需要將相應片段注入進來.
第三段代碼就是真正在 jsx 中發(fā)起請求了, 通過 hooks 可以方便的處理請求體, loading, 返回值, 錯誤處理等等...
const POST_FRAGMENT = gql`fragment PostFragment on PostItemModel {_idposterUrltitlesummarycontenttagslastModifiedDatelikepvisPubliccreatedAtupdatedAt}`;export const CREATE_ONE_POST = gql`mutation CreatePost($input: CreatePostInput!) {createPost(input: $input) {...PostFragment}}${POST_FRAGMENT}`;const [createPost, { loading }] = useMutation<CreatePostMutation,CreatePostVars>(CREATE_ONE_POST, {onCompleted(data) {const newPost = data.createPost;enqueueSnackbar("Create success!", { variant: "success" });},onError() {},});
Introspection
在真實的開發(fā)中, 我們會在后端定義一系列的 query, mutation, input, type, enum, scalar, interface. 而 GraphQL 支持一套強大的內(nèi)省系統(tǒng), 通過內(nèi)省系統(tǒng), 我們可以反查后端設(shè)計的 schema 的集合. 內(nèi)省系統(tǒng)的另一個功能則是輔助開發(fā) GraphQL 工具, 通過查詢出來的內(nèi)部 schema, 可以搭建出強大的 IDE. 如下代碼可以查詢出 PostItemModel 這個類型的所有信息.
{__type(name: "PostItemModel") {namefields {nametype {namekind}}}}
{"data": {"__type": {"name": "PostItemModel","fields": [{"name": "_id","type": {"name": null,"kind": "NON_NULL"}},{"name": "posterUrl","type": {"name": null,"kind": "NON_NULL"}},{"name": "title","type": {"name": null,"kind": "NON_NULL"}},{"name": "summary","type": {"name": null,"kind": "NON_NULL"}},{"name": "content","type": {"name": null,"kind": "NON_NULL"}},{"name": "tags","type": {"name": null,"kind": "NON_NULL"}},{"name": "lastModifiedDate","type": {"name": null,"kind": "NON_NULL"}},{"name": "like","type": {"name": null,"kind": "NON_NULL"}},{"name": "pv","type": {"name": null,"kind": "NON_NULL"}},{"name": "isPublic","type": {"name": null,"kind": "NON_NULL"}},{"name": "createdAt","type": {"name": null,"kind": "NON_NULL"}},{"name": "updatedAt","type": {"name": null,"kind": "NON_NULL"}},{"name": "prev","type": {"name": "PostItemModel","kind": "OBJECT"}},{"name": "next","type": {"name": "PostItemModel","kind": "OBJECT"}}]}}}
安全
生產(chǎn)環(huán)境關(guān)閉 debug
如果開啟 debug 模式, 在出錯時會展示錯誤的堆棧信息.

生產(chǎn)環(huán)境關(guān)閉 playground
playground 應當作為一種輔助自測工具, 其不應該暴露到線上.
生產(chǎn)環(huán)境關(guān)閉 introspection
得益于自省, 可以輕松獲取到 GraphQL server 內(nèi)部的信息, 如各種類型, 標量等. 這些信息不應該在線上被三方直接通過代碼采集到.
控制多層深度的查詢
如下可能會造成昂貴的查詢, 重則導致后端崩潰. 可以使用 graphql-depth-limit 來指定最多查詢的層級.
query {author(id: 42) {posts {author {posts {author {posts {author {# and so on...}}}}}}}}
控制分頁數(shù)據(jù)量
如下最多一次將能獲取十萬條數(shù)據(jù), 顯而易見會帶來性能問題. 你可以通過 graphql-input-number 在 resolver 中限制數(shù)字的最大值.
query {authors(first: 1000) {nameposts(last: 100) {titlecontent}}}
當然, 如果你使用了 class-validator, 也可以通過如下方式來限制.
@InputType()export class SomeNumberInput {@IsInt()@Min(1)@Max(10)public readonly pageSize: number;}
參考
9 Ways To Secure your GraphQL API — GraphQL Security Checklist GraphQL | A query language for your API
