【译】Graphql, gRPC和端对端类型检验

原文地址:Graphql, gRPC, and End-to-End Type Coverage
作者:Kaitlyn Barnard

注:本文采用意译

背景介绍

StackPath最近发布了新的门户网站,它让用户可以一站式地配置我们所提供的服务(CDN,WAF, DNS以及Monitoring)。这个项目涉及到整合不同的数据源,以及一些现有和全新的系统。虽然我们认为开发效率的优先级在一个新启动的项目中是最高的,但我们还是希望在保证足够快的开发进度的前提下,尽可能早地做一些能够保证产品长期稳定运行的技术投资,以便我们能够持续不断地在一个健壮的基础设施上添加新的功能特性。最终我们选择了Apollo GraphQL+gRPC+React+TypeScript这样一套技术栈,并对使用它们的结果感到满意。在这篇博客中,我们会解释为何选择这些技术栈,并通过一个简单的示例项目进行论述。

GraphQL

当听到我们需要在这个项目中“整合许多不同的数据源”时,我立即意识到使用GraphQL作为API网关会是一个不错的选择。

我们并不是为了故意揭REST API的短,而是基于我们自己的React应用来看使用GraphQL的主要优势在于:

  • 对前端屏蔽了后端技术的复杂性,让那些后端服务更好地保持了它们的原子性。
  • 由于GraphQL查询可以一次请求多种资源,因此可以减少网络请求的次数。
  • 未来可以很方便地增加服务端接口。只需要在我们的schema中增加查询(queries)和变更(mutations)字段,就可以在应用中使用(consuming)这些数据了。
  • 使用apollo-client和react-apollo简化了前端代码对缓存和数据的管理。
  • 灵活的查询方式方便我们在未来构建移动端以及内部应用。
  • GraphQL schema的自检性让我们可以方便的查询系统中的全部可用数据。

(如果你想更深入的学习GraphQL,我推荐你去看看官方指引)

我们的GraphQL服务主要是干数据透传的活儿。我们所有的解析器(resolvers)都是遵循以下模式:从后端服务请求一些数据,可能会做轻量的数据转化工作,使得返回的数据复合我们的schema。在这些解析器中几乎木有业务逻辑。

结果,静态类型很好的保证了服务端响应和数据转化逻辑能够匹配我们的schema。由于GraphQL schema本身就是一种类型集合,可以根据它很方便地生成TypeScript类型。我们使用graphql-code-generator基于我们的schema来生成对应的Typescript typings, 并且在写解析器的时候使用这些Typescript typings。

GraphQL示例

我们的示例应用会是一个标准的TODO MVC,支持列表展示、创建和删除TODO事项。这三个操作对应的GraphQL schema如下:

const typeDefs = gql`
 type Query {
   todos: [Todo!]
 }

 type Mutation {
   createTodo(input: CreateTodoInput!): CreateTodoPayload
   deleteTodo(input: DeleteTodoInput!): DeleteTodoPayload
 }

 type Todo {
   id: String!
   title: String
 }

 input CreateTodoInput {
   title: String!
 }

 type CreateTodoPayload {
   todo: Todo
 }

 input DeleteTodoInput {
   id: ID!
 }

 type DeleteTodoPayload {
   success: Boolean
 }
`;

我们的package.json的一些可执行脚本定义:

 "scripts": {
   "start": "ts-node src/index.ts",
   "genTypes": "graphql get-schema && gql-gen --schema schema.json --template graphql-codegen-typescript-template --out ./src/types.ts"
 },

通过这些schema,我们可以使用yarn genTypes来生成types.ts文件中的types,并在实现解析器时使用它们。

const resolvers: { [key: string]: any } = {
 Query: {
   todos: (): Promise<Array<Todo>> => {
     // Get Todos
   }
 },
 Mutation: {
   createTodo: (
     _obj: object,
     args: CreateTodoMutationArgs
   ): Promise<CreateTodoPayload> => {
    // Create Todo
   },

   deleteTodo: (
     _obj: object,
     args: DeleteTodoMutationArgs
   ): Promise<DeleteTodoPayload> => {
     // Delete Todo
   }
 }
};

每个解析器的主体部分涉及到请求我们的后端服务,我们将在下一个示例中去实现。

gRPC

一开始,我们本来打算使用REST API来集成我们的后端服务。然而我们的后端团队已经使用了gRPC来标准化后端服务之间的通信方式。

(可能有些人还不熟悉这项技术,gRPC是Google基于HTTP/2和protocol buffers开源地一个远程方法调用(RPC)框架。在gPPC中,.proto文件用来描述后端服务的可调用方法名,以及这些方法输入输出的字段类型。通过这些proto文件,protoc(protocol buffer编译器)可以同时生成客户端/服务端的请求/响应代码。你可以在这里阅读到更多有关于gRPC的内容)

通过grpc-gateway我们依然可以选择使用REST API来暴露接口,但是我们依然想通过使用gRPC来探索它能给我们带来什么好处。下面是我们所体会到的gRPC的主要优势:

  • 生成对应我们全部后端服务接口的客户端类型代码是一件灰常简单的事情,我们使用这个插件来生成TypeScript definitions。
  • 通过使用gRPC能够让后端和前端团队之间更加方便的分享知识和互通有无(译注:不太懂这里的意思,难道是指两端的团队共同学习HTTP2和proto buffer?)。

使用类型化的客户端代码是一件令人愉快的事情。每一个服务端所对应的客户端代码都是基于后端接口的请求和响应信息来进行类型化。我们不需要再去查询每个API的接口文档,因为客户端代码里拥有开发者和IDE所需要知道的全部信息。并且我们知道它们一定是正确的,因为它们是基于proto文件自动生成的。

gRPC示例

我们会像在GraphQL schema中所做的那样在proto文件中定义相同的三种操作:

syntax = "proto3";

service TodoManager {
 rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse) {}

 rpc GetTodos(GetTodosRequest) returns (GetTodosResponse) {}

 rpc DeleteTodo(DeleteTodoRequest) returns (DeleteTodoResponse) {}
}

message CreateTodoRequest {
 string title = 1;
}

message CreateTodoResponse {
 Todo todo = 1;
}

message GetTodosRequest {}

// GetStacksRequest returns stacks
message GetTodosResponse {
 repeated Todo results = 2;
}

message DeleteTodoRequest {
 string todo_id = 1;
}

message DeleteTodoResponse {
 bool success = 1;
}

message Todo {
 string id = 1;
 string title = 2;
}

基于这个todo.proto文件,我们使用protoc来生成客户端代码和服务端的骨架代码(只需要往里面填充每个方法的具体实现就可以了)

#!/bin/sh

PROTOC_PLUGIN="node_modules/.bin/grpc_tools_node_protoc_plugin"
OUT_DIR="."

./node_modules/.bin/grpc_tools_node_protoc \
 --js_out="import_style=commonjs,binary:${OUT_DIR}" \
 --grpc_out="${OUT_DIR}" \
 --plugin="protoc-gen-grpc=${PROTOC_PLUGIN}" \
 src/todo.proto

protoc \
 --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
 --ts_out="${OUT_DIR}" \
 src/todo.proto

运行这个脚本会生成下面四个文件:

  1. todo_grpc_pb.d.ts
  2. todo_grpc_pb.js
  3. todo_pb.d.ts
  4. todo_pb.js

todo_pb.js 包含了请求体对象(message objects),todo_grpc_pb.js包含了服务端/客户端对象(service/client object)。.d.ts文件是以上每个对象的TypeScript definitions。我们服务端代码的简易实现:

import { TodoManagerService } from "./todo_grpc_pb";
import {
 CreateTodoRequest,
 CreateTodoResponse,
 GetTodosRequest,
 GetTodosResponse,
 DeleteTodoRequest,
 DeleteTodoResponse,
 Todo
} from "./todo_pb";
import grpc, { ServerUnaryCall, sendUnaryData } from "grpc";

let id = 1;
let todos: Array<Todo> = [];

const createTodo = (
 call: ServerUnaryCall<CreateTodoRequest>,
 callback: sendUnaryData<CreateTodoResponse>
) => {
 const title = call.request.getTitle();
 const todo = new Todo();
 todo.setId(String(id++));
 todo.setTitle(title);
 todos = [...todos, todo];

 const response = new CreateTodoResponse();
 response.setTodo(todo);
 callback(null, response);
};

const getTodos = (
 call: ServerUnaryCall<GetTodosRequest>,
 callback: sendUnaryData<GetTodosResponse>
) => {
 const response = new GetTodosResponse();
 response.setResultsList(todos);

 callback(null, response);
};

const deleteTodo = (
 call: ServerUnaryCall<DeleteTodoRequest>,
 callback: sendUnaryData<DeleteTodoResponse>
) => {
 const id = call.request.getTodoId();
 todos = todos.filter(item => item.getId() !== id);
 const response = new DeleteTodoResponse();
 response.setSuccess(true);

 callback(null, response);
};

function main() {
 const server = new grpc.Server();
 server.addService(TodoManagerService, { createTodo, getTodos, deleteTodo });
 server.bind("0.0.0.0:50051", grpc.ServerCredentials.createInsecure());
 server.start();
}

main();

说回我们的GraphQL server,我们现在可以导入gRPC的客户端模块,并完善我们的解析器。

import {
 GetTodosRequest,
 CreateTodoRequest,
 DeleteTodoRequest
} from "../../grpc/src/todo_pb";
import { createClient } from "../../grpc/src/client";

const todoClient = createClient();

const resolvers: { [key: string]: any } = {
 Query: {
   todos: (): Promise<Array<Todo>> => {
     const request = new GetTodosRequest();
     return new Promise((resolve, reject) => {
       todoClient.getTodos(request, (error, response) => {
         if (error) {
           reject(error);
         }
         return resolve(response.toObject().resultsList);
       });
     });
   }
 },
 Mutation: {
   createTodo: (
     _obj: object,
     args: CreateTodoMutationArgs
   ): Promise<CreateTodoPayload> => {
     const request = new CreateTodoRequest();
     request.setTitle(args.input.title);
     return new Promise((resolve, reject) => {
       todoClient.createTodo(request, (error, response) => {
         if (error) {
           reject(error);
         }
         return resolve(response.toObject());
       });
     });
   },

   deleteTodo: (
     _obj: object,
     args: DeleteTodoMutationArgs
   ): Promise<DeleteTodoPayload> => {
     const request = new DeleteTodoRequest();
     request.setTodoId(args.input.id);
     return new Promise((resolve, reject) => {
       todoClient.deleteTodo(request, (error, response) => {
         if (error) {
           reject(error);
         }
         return resolve(response.toObject());
       });
     });
   }
 }
};

现在我们拥有了完整的GraphQL server,它可以使用gRPC来与后端服务进行通信。

React

我们并没有花费太多时间来讨论这一选择。我们团队的主要经验都是在构建React应用上,而且我们也没有找到任何令人信服的理由来换到别的选项上。为了保证GraphQL server和前端之间的类型安全,我们使用Apollo CLI的代码生成器:使用命令行来生成我们所有GraphQL查询的类型:

React示例

在我们的应用中需要用到三种查询:

query GetTodos {
 todos {
   id
   title
 }
}

mutation CreateTodo($input: CreateTodoInput!) {
 createTodo(input: $input) {
   todo {
     id
     title
   }
 }
}

mutation DeleteTodo($input: DeleteTodoInput!) {
 deleteTodo(input: $input) {
   success
 }
}

基于这些GraphQL查询和我们的服务端GraphQL schema,我们可以给这些查询生成typescript types。在此基础上,我们进一步使用apollo-typed-components来给每项操作生成react-apollo组件,也就是ApolloComps.tsx文件中的GetTodosQuery组件、CreateTodoMutation组件、和DeleteTodoMutation组件。

需要注意的是TypeScript使用.ts和.tsx文件扩展名,而不是.js和.jsx。然而,不像.jsx/.js之间那样宽松,当文件中包含任何JSX代码时,你必须使用.tsx扩展名,这样TypeScript才能消除JSX和其他TypeScript语言特性之间的歧义。举个例子,尖括号断言(angle bracket assertions):

const foo = <Foo>bar

这样在.ts文件中是有效的,在.tsx中是无效的。在.tsx中,需要使用as操作符来重写这一表达式:

const foo = bar as Foo

我们在ApolloComps.tsx文件中生成出来的Query/Mutation组件看起来像下面这样:

/* Generated using apollo-typed-components */
import * as React from "react";
import { Mutation } from "react-apollo";
import { CreateTodo } from "./queries.graphql"
import { CreateTodo as CreateTodoType, CreateTodoVariables } from "types";

type GetComponentProps<T> = T extends React.Component<infer P> ? P : never;
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

class CreateTodoMutationClass extends Mutation<CreateTodoType, CreateTodoVariables> {};

export const CreateTodoMutation = (props: Omit<GetComponentProps<CreateTodoMutationClass>, "mutation">) => <CreateTodoMutationClass mutation={CreateTodo} {...props} />;

CreateTodoType和CreateTodoVariables都是通过Apollo的代码生成器用命令行生成的。CreateTodoVariables是GraphQL mutation的入参字段类型,CreateTodoType是GraphQL mutation操作的返回字段类型。CreateTodoMutationClass是继承自react-apollo的Mutation组件的一个子类,它的构造函数两个入参类型就是CreateTodoType和CreateTodoVariables,我们使用它就可以拥有一个variablesdata都是指定类型的组件。我们最终实际暴露的组件是CreateTodoMutation,它是基于CreateTodoMutationClass封装的,并且将之前在queries.graphql中定义的CreateTodo传入组件。我们使用了两个工具类型来定义CreateTodoMutation的属性类型:GetComponentProps和Omit。GetComponentProps接收一个React组件T,然后返回组件T的props所期望的类型。Omit接收一个T类型的对象和K类型的一个键,然后返回T的类型定义,并把K传入的键从返回中移除。结合这两个工具类型,我们可以将CreateTodoMutation的props类型表示为CreateTodoMutationClass中除了mutation相关的props类型,并且由这个封装类来自动提供。

不能否认的是在ApolloComps.tsx文件中,我们不得不在mutation定义、typescript types和React组件之间复制一些代码片段。幸运地是,我们通过自动生成的方式让开发者不用为每个操作准确无误地去整合这些片段,他们只需要关注每个操作所对应的独立的React组件。

整合全部三个自动生成的组件后,我们最终的前端代码:

import React, { Component } from "react";
import { GetTodos } from "./queries.graphql";
import { GetTodos_todos } from "./types";
import {
 GetTodosQuery,
 CreateTodoMutation,
 DeleteTodoMutation
} from "./ApolloComps";

type TodosListProps = {
 onDelete: (id: string) => {};
 todos: Array<GetTodos_todos>;
};
const TodosList = ({ onDelete, todos }: TodosListProps) => {
 return (
   <section className="main">
     <ul className="todo-list">
       {todos.map(todo => (
         <li key={todo.id}>
           <div className="view">
             <label>{todo.title}</label>
             <button className="destroy" onClick={() => onDelete(todo.id)} />
           </div>
         </li>
       ))}
     </ul>
   </section>
 );
};

type AppProps = {
 onCreate: (title: string) => {};
 onDelete: (id: string) => {};
 todos: Array<GetTodos_todos>;
};
type AppState = {
 newTodo: string;
};

class App extends Component<AppProps, AppState> {
 state = {
   newTodo: ""
 };

 render() {
   const { onCreate, onDelete, todos } = this.props;

   return (
     <section className="todoapp">
       <header className="header">
         <h1>{"todos"}</h1>
         <input
           className="new-todo"
           placeholder="What needs to be done?"
           value={this.state.newTodo}
           onKeyDown={(e: React.KeyboardEvent) => {
             if (e.keyCode !== 13) {
               return;
             }
             e.preventDefault();

             var val = this.state.newTodo.trim();

             if (val) {
               onCreate(val);
               this.setState({ newTodo: "" });
             }
           }}
           onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
             this.setState({ newTodo: e.target.value })
           }
           autoFocus={true}
         />
       </header>
       {todos.length ? <TodosList onDelete={onDelete} todos={todos} /> : null}
     </section>
   );
 }
}

const ApolloApp = () => {
 return (
   <GetTodosQuery>
     {({ data }) => (
       <DeleteTodoMutation>
         {deleteTodo => (
           <CreateTodoMutation>
             {createTodo => {
               if (!data || !data.todos) {
                 return null;
               }

               const todos = data.todos;

               return (
                 <App
                   onCreate={(title: string) =>
                     createTodo({
                       variables: {
                         input: {
                           title
                         }
                       },
                       update: (cache, response) => {
                         const createdTodo =
                           response.data &&
                           response.data.createTodo &&
                           response.data.createTodo.todo;
                         if (createdTodo) {
                           cache.writeQuery({
                             query: GetTodos,
                             data: {
                               todos: [...todos, createdTodo]
                             }
                           });
                         }
                       }
                     })
                   }
                   onDelete={(id: string) =>
                     deleteTodo({
                       variables: {
                         input: {
                           id
                         }
                       },
                       update: cache => {
                         cache.writeQuery({
                           query: GetTodos,
                           data: {
                             todos: todos.filter(item => item.id !== id)
                           }
                         });
                       }
                     })
                   }
                   todos={todos}
                 />
               );
             }}
           </CreateTodoMutation>
         )}
       </DeleteTodoMutation>
     )}
   </GetTodosQuery>
 );
};

export default ApolloApp;

结语

通过使用Apollo GraphQL、gRPC、React和TypeScript,我们既享受了查询数据的灵活性,也保证了我们后端服务之间的原子性。此外,由于实现了端对端的类型检验,很难出现数据的错误使用或是引入向前不兼容的变更。如果我们需要引入向前不兼容的变更,也很容易在发生变更之前决定我们系统中的哪些部分是需要进行修改的。

我们这次的主要收获之一,就是意识到了强制性的数据描述的强大之处(在这个例子中就是GraphQL schema和gRPC的proto文件)。通过生成类型文件,并且强制你的实现符合定义,能够确认系统中不同部分的网络数据交换的安全性。无论是采用哪种技术栈,服务端和客户端之间的类型安全的确能够增加对系统整体稳定性的信心。

本文示例代码地址:https://github.com/TLadd/todo-graphql-grpc

你可能感兴趣的:(NodeJS)