GraphQL是这两年兴起的一种查询语言,国内一些比较潮的公司正在使用,它解决了Rest接口方式的一些问题,同时也带来了一些新的问题。对于我们底层程序员来说,学就对了,万一用上了呢。

框架选择

graphql在各种语言,各种框架都有对应的实现,可以查看官网根据情况选择适合自己的实现,概念上都是一致的。本文更着重于实际代码,理论部分请结合官网教程观看。
本文使用nodejs作为开发语言,使用express作为服务器,展示graphql的简单搭建过程,并逐步添加mysql,typescript,type-graphql,typeorm的支持。这个过程是渐进的,如果你不喜欢(学不动)某个部分,跳过就好。注意本文携带大量私货,未必是最佳实践,如果有错误,请评论指出,共同学习,谢谢。

快速实现

首先我们先快速实现一个graphql的服务器

  1. mkdir graphqldemo;
  2. cd graphqldemo;
  3. npm init -yes;
  4. npm i express apollo-server-express;

然后创建一个index.js

  1. const express = require("express");
  2. const { ApolloServer } = require("apollo-server-express");
  3. const PORT = 4000;
  4. const app = express();
  5. const box = {
  6. width: 100,
  7. height: 200,
  8. weight: "100g",
  9. color: "white"
  10. }
  11. const typeDefs = [`
  12. """
  13. 一个盒子模型
  14. """
  15. type Box{
  16. """
  17. 这是盒子的宽度
  18. """
  19. width:Int,
  20. height:Int,
  21. color:String
  22. }
  23. type Query {
  24. getBox: Box
  25. }
  26. type Mutation{
  27. setWidth(width:Int):Box
  28. }
  29. schema {
  30. query: Query,
  31. mutation: Mutation
  32. }`]
  33. const resolvers = {
  34. Query: {
  35. getBox(_) {
  36. return box;
  37. }
  38. },
  39. Mutation: {
  40. setWidth(_, { width }) {
  41. box.width = width;
  42. return box
  43. }
  44. }
  45. };
  46. const server = new ApolloServer({
  47. typeDefs,
  48. resolvers
  49. });
  50. server.applyMiddleware({ app });
  51. app.listen(PORT, () =>
  52. console.log(
  53. `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
  54. )
  55. );

然后运行

  1. node index.js

等出现成功提示后就可以在浏览器打开 http://localhost:4000/graphql,就可以看到graphql提供的playground

GraphQL的前后端实践 - 图1

点击右侧的docs,就能查看到我们设定的type和对应的数据类型,还有我们写的注释,这其实是一份很完备的接口文档了。

代码分析

刚刚我们使用express和Apollo Server实现了一个最简单的graphql服务器(Apollo Server是graphql规范的一个实现)。

  1. const server = new ApolloServer({
  2. typeDefs,
  3. resolvers
  4. });

在new一个ApolloServer的时候,传入了两个参数,一个typeDefs和一个resolvers。
typeDefs是一个字符串或者字符串数组,里面的内容是我们定义的schema,而resolvers是schema的实现,也就是typeDefs里的Query和Mutation,注意所有的schema都要实现之后程序才能启动。
也可以只传入一个schema参数来new ApolloServer,使用buildSchema方法可以将typeDefs和resolvers生成schema(schema这个概念在graphql中到处出现,不要搞混了)。

resolver的返回值需要符合定义的类型,否则会报错。在ApolloServer中,也可以返回对应类型的Promise。

  1. server.applyMiddleware({ app });
  2. 复制代码

这一行将Apollo作为Express的一个中间件

  1. const box = {
  2. width: 100,
  3. height: 200,
  4. weight: "100g",
  5. color: "white"
  6. }
  7. 复制代码

声明一个盒子,作为数据源。graphql并不在意数据是从哪里来的,可以从普通变量,数据库,redis,甚至http请求中获取,只要这个数据的结构能符合定义即可。现在我们向服务器请求一下这个box
graphql一共有三种操作类型,query、mutation 或 subscription,这里演示一下query、mutation

query

query是graphql中的查询操作,在playground左侧输入

  1. query {
  2. getBox {
  3. width
  4. height
  5. color
  6. }
  7. }
  8. 复制代码

点击按钮,可以在右边获得返回值

  1. {
  2. "data": {
  3. "getBox": {
  4. "width": 100,
  5. "height": 200,
  6. "color": "white"
  7. }
  8. }
  9. }
  10. 复制代码

我们可以随意减少getBox里的字段(至少有一个),比如只要width

  1. query {
  2. getBox {
  3. width
  4. }
  5. }
  6. 复制代码

可以看到返回值里只有width属性了。

  1. {
  2. "data": {
  3. "getBox": {
  4. "width": 100
  5. }
  6. }
  7. }
  8. 复制代码

graphql在这里解决了传统接口模式中一个问题,就是后端在向前端传输数据的过程中,会传递很多无效字段,无效字段过多会影响传输效率,前端可以主动获取自己所需的字段。
另一方面,后端的DAO层的一些字段从安全角度也是不应该传递给前端的,在上文的这个例子里,box的weight属性可以理解为一个前端不应可见的字段,因为在graphql中没有被定义,所以被自动过滤了,前端无法查询到。传统后端解决这个问题的方案是在DAO层之上引入一个DTO层。

mutation

mutation代表对数据源会产生副作用的操作,在playground中输入

  1. mutation {
  2. setWidth(width: 108) {
  3. width
  4. height
  5. color
  6. }
  7. }
  8. 复制代码

得到结果

  1. {
  2. "data": {
  3. "setWidth": {
  4. "width": 108,
  5. "height": 200,
  6. "color": "white"
  7. }
  8. }
  9. }
  10. 复制代码

可以看到box的width已经被更新到108了。注意,query和mutation都可以发起多个,服务器内部会顺序执行,但是query和mutation不能同时使用,下面是一个多个mutation的例子,query同理

  1. mutation {
  2. m1:setWidth(width: 108) {
  3. width
  4. }
  5. m2:setWidth(width: 99) {
  6. width
  7. }
  8. }
  9. 复制代码

返回值

  1. {
  2. "data": {
  3. "m1": {
  4. "width": 108
  5. },
  6. "m2": {
  7. "width": 99
  8. }
  9. }
  10. }
  11. 复制代码

因为setWidth重复使用了两次,重名了,所以我们使用m1、m2作为别名(Aliases),语法如上,非常简单。

传参

刚刚的mutation我们直接在语句里写了参数,因为语句本身是字符串不利于组合,同时也不适合传递复杂的参数,所以我们需要定义参数。点击playground左下的Query Variables,在这里可以声明参数,注意需要是标准json格式

  1. {
  2. "length": 128
  3. }

同时将语句改为

  1. mutation($length: Int) {
  2. setWidth(width: $length) {
  3. width
  4. }
  5. }

在length前加一个$就能在语句中使用了,可以查看一下浏览器控制台的请求有什么变化

稍微复杂一点

我们再看一点复杂的模型,现在给盒子里装点随机的小球,将数据源修改为如下形式

  1. class Ball {
  2. constructor() {
  3. this.size = ((Math.random() * 10) | 0) + 5;
  4. this.color = ["black", "red", "white", "blue"][(Math.random() * 4) | 0];
  5. }
  6. }
  7. const box = {
  8. width: 100,
  9. height: 200,
  10. weight: "100g",
  11. color: "white",
  12. balls: new Array(10).fill().map(n => new Ball())
  13. }
  14. 复制代码

然后在typeDefs中增加一个类型,并且修改box的类型

  1. type Box{
  2. width:Int,
  3. height:Int,
  4. color:String,
  5. balls:[Ball]
  6. }
  7. type Ball{
  8. size:Int,
  9. color:String
  10. }
  11. 复制代码

重启服务,进行一次查询

  1. query {
  2. getBox {
  3. width
  4. balls {
  5. size
  6. color
  7. }
  8. }
  9. }

结果

  1. {
  2. "data": {
  3. "getBox": {
  4. "width": 100,
  5. "balls": [
  6. {
  7. "size": 5,
  8. "color": "black"
  9. },
  10. //...
  11. ]
  12. }
  13. }
  14. }

似乎没有报错,不过这种情况并不符合graphql设计的本意。graphql的数据一层应该只携带本层的信息,想象一下这个需求,我需要box和box里所有color为red的球。正确做法如下,先修改box让他有参数

  1. type Box{
  2. width:Int,
  3. height:Int,
  4. color:String,
  5. balls(color:String):[Ball]
  6. }
  7. 复制代码

然后在resolvers里添加一个Box,注意resolver的第一个参数parent指向的是他的父元素也就是box,这一点很重要,如果有复数的盒子,需要这个参数判断返回哪个盒子里的球

  1. const resolvers = {
  2. Query: {
  3. getBox(_) {
  4. return box
  5. },
  6. },
  7. Mutation: {
  8. setWidth(_, { width }) {
  9. box.width = width;
  10. return box
  11. }
  12. },
  13. Box: {
  14. balls(parent, { color }) {
  15. return color ? box.balls.filter(ball => ball.color === color) : box.balls
  16. }
  17. }
  18. };
  19. 复制代码

现在可以使用查询查出所有的颜色为red的球

  1. query {
  2. getBox {
  3. width
  4. balls (color:"red"){
  5. size
  6. color
  7. }
  8. }
  9. }

如果没有参数,就是全部的球。graphql这么设计的好处是,可以在数据库查询中,少写很多的join,坏处是更多的查询次数

前端使用

在使用http请求graphql服务器时的载体仍然是json,所以即使不使用任何特殊的库也可以与graphql服务器通信

axios

先用比较经典的axios来试一下,创建一个html文件

  1. <script src="https://cdn.bootcss.com/axios/0.19.0/axios.min.js"></script>
  2. <script>
  3. const query = `query($color:String) {
  4. getBox {
  5. width
  6. balls (color:$color){
  7. size
  8. color
  9. }
  10. }
  11. }`;
  12. const variables = {
  13. color: "red"
  14. };
  15. axios
  16. .post("http://localhost:4000/graphql", {
  17. query,
  18. variables
  19. })
  20. .then(res => {
  21. console.log("res: ", res);
  22. });
  23. </script>

另外GET也是完全合法的

  1. axios.get("http://localhost:4000/graphql", {
  2. params: { query, variables }
  3. })

或者直接访问

  1. http://localhost:4000/graphql?query=query($color:String){getBox{width,balls(color:$color){size,color}}}&variables={"color":"red"}

相比传统方式,graphql的特点就是返回值可预测,而且因为地址、请求方式和参数名固定,封装起来更简单。
现在看一下专业的客户端是这么做的,既然服务端使用了apollo-server,那客户端就看一下apollo-client怎么做的apollo-client官网。因为提供了错误处理,数据缓存,错误处理等等,配置项稍显复杂,官方提供了一个apollo-boost的东西简化了配置。我们可以自己对照官方实现一个简化版,深入学习一下。

客户端实现

私货警告

以下内容在react16.8+的hooks API和typescript下实现,模仿官方包的api设计,去掉了缓存等功能。缓存可以说是apollo提供的核心功能了,但为了缓存增加了巨量的代码,并不适合学习。 首先我们创建一个新的react工程

  1. create-react-app graphql-client --typescript
  2. 复制代码

接下来我们要参考官方包实现以下几个使用频率最高的模块(超级精简版):ApolloClient、ApolloProvider、useQuery、Query

ApolloClient

入参包括uri,fetchOptions等,实际就是一个http请求库,这部分省点事直接用axios替代吧。注意官方实例,使用了从graphql-tag导出的gql方法处理graphql字符串,包括server端也有这个方法,它的作用是将字符串转换成ast,方便检查编写schema文件时出现的错误,本文中都省略掉了,都直接使用字符串。

  1. import Axios, { AxiosInstance } from "axios";
  2. type config = {
  3. uri: string;
  4. };
  5. class Client {
  6. constructor({ uri }: config) {
  7. this.uri = uri;
  8. this.axios = Axios.create();
  9. }
  10. private uri: string;
  11. private axios: AxiosInstance;
  12. query({ query, variables }: { query: string; variables: any }) {
  13. return this.axios.post(this.uri, { query, variables });
  14. }
  15. }
  16. 复制代码

ApolloProvider

这个组件看得出是将client作为一个context提供给下文,使用createContext即可完成这个组件

  1. interface ProviderProps {
  2. client: Client;
  3. }
  4. const graphqlContext: React.Context<{
  5. client: Client;
  6. }> = React.createContext(null as any);
  7. const GraphProvider: React.FC<ProviderProps> = ({ client, children }) => {
  8. return (
  9. <graphqlContext.Provider value={{ client }}>
  10. {children}
  11. </graphqlContext.Provider>
  12. );
  13. };
  14. 复制代码

useQuery

因为graphql的入参固定,所以创建一个hook很容易。这里使用了一个泛型T去定义预期返回值的类型,官方包在这里还使用了第二个泛型来确定variables参数的类型。

  1. import { useState, useContext, useEffect, Dispatch } from "react";
  2. const useQuery = <T = any>(query: string, variables?: any) => {
  3. const { client } = useContext(graphqlContext);
  4. const [data, setData]: [T, Dispatch<T>] = useState(null as any);
  5. const [loading, setLoading] = useState(true);
  6. const [error, setError] = useState(null as any);
  7. useEffect(() => {
  8. setLoading(true);
  9. setError(null);
  10. client
  11. .query({ query, variables })
  12. .then(res => {
  13. setData(res.data.data);
  14. setLoading(false);
  15. })
  16. .catch(err => {
  17. setError(err);
  18. setLoading(false);
  19. });
  20. }, [query, variables, client]);
  21. return { data, loading, error };
  22. };
  23. 复制代码

到这里就可以使用封装后的组件了 首先是App.tsx

  1. const client = new Client({
  2. uri: "http://localhost:4000/graphql"
  3. });
  4. const App: React.FC = () => {
  5. return (
  6. <Provider client={client}>
  7. <Home></Home>
  8. </Provider>
  9. );
  10. };
  11. 复制代码

然后是Home.tsx

  1. interface ResData {
  2. getBox: {
  3. width: number;
  4. balls: { size: number; color: string }[];
  5. };
  6. }
  7. const query = `
  8. query {
  9. getBox {
  10. width
  11. balls (color:"red"){
  12. size
  13. color
  14. }
  15. }
  16. }`;
  17. const Home: FC = () => {
  18. const { data, loading, error } = useQuery<ResData>(query);
  19. if (loading) return <div>loading</div>;
  20. if (error) return <div>{error}</div>;
  21. return (
  22. <div>
  23. <h2>{data.getBox.width}</h2>
  24. <ul>
  25. {data.getBox.balls.map(n => (
  26. <li>
  27. size:{n.size} color:{n.color}
  28. </li>
  29. ))}
  30. </ul>
  31. </div>
  32. );
  33. }
  34. 复制代码

因为获取的数据是可预测的,所以在写出查询语句的同时完成类型文件。如果到现在编码正确,你的react项目上已经可以看到效果了

Query

该组件在创建hook之后就非常容易了,一笔带过

  1. interface queryProps {
  2. query: string;
  3. variables?: any;
  4. children: React.FC<{ data: any; loading: boolean; error: any }>;
  5. }
  6. const Query: React.FC<queryProps> = ({ query, variables, children }) => {
  7. const { data, loading, error } = useQuery(query, variables);
  8. return children({ data, loading, error });
  9. };
  10. 复制代码

总结

对前端来说,应用graphql并不是难点,只要能写出正确的查询语句,必然能得到正确的查询结果,难点可能是融合进现有项目,使用typescript等工具,加速开发效率。改造的难度依然在后端,想到我们之前的后端太过简陋,现在来优化一下吧,顺便补齐后端非常重要的身份认证等功能

后端目录结构优化

后端一直以来我们都在一个文件里写,随着模型变得复杂,代码开始臃肿了,同时在字符串里写schema也挺别扭,最好能写到单独的graphql/gql文件里去,这样还能有编辑器提供的格式化功能(我使用的是vscode中的Apollo GraphQL插件)。
我在这里的处理是将typeDefs拆分成对应的graphql文件,resolvers也进行文件拆分,然后使用文件扫描器自动依赖。
现在创建一个typeDefs文件夹,然后创建一个index.graphql文件,将原来的typeDefs字符串复制进去。
在同一个目录创建一个ball.gql文件,将index.graphql文件中Ball相关的定义剪贴进去。
接下来创建一个util.js,写一个代码扫描器,因为需要获取的就是字符串,所以直接用fs模块读取文件就行了

  1. const fs = require("fs");
  2. const path = require("path");
  3. function requireAllGql(dir, parentArray) {
  4. let arr = [];
  5. let files = fs.readdirSync(dir);
  6. for (let f of files) {
  7. let p = path.join(dir, f);
  8. let stat = fs.statSync(p);
  9. if (stat.isDirectory()) {
  10. requireAllGql(p, arr);
  11. } else {
  12. let extname = path.extname(p);
  13. if (extname === ".gql" || extname === ".graphql") {
  14. let text = fs.readFileSync(p).toString();
  15. if (!parentArray) {
  16. arr.push(text);
  17. } else {
  18. parentArray.push(text);
  19. }
  20. }
  21. }
  22. }
  23. return arr;
  24. }
  25. module.exports = {
  26. requireAllGql
  27. };
  28. 复制代码

这样index.js里的typeRefs就可以改成这样

  1. const { requireAllGql } = require('./utils.js')
  2. const path = require("path")
  3. const typeDefs = requireAllGql(path.resolve(__dirname, './typeDefs'))
  4. 复制代码

用同样的方式解决resolver,不过要先创建一个dataSource.js,将Ball和box移到这个文件里,然后创建一个resolvers文件夹,然后创建一个query.js文件,一个mutation.js文件,一个box文件(一般根据功能模块分文件,这里是个例子)。比如现在query.js就是这样

  1. const { box } = require('../dataSource.js')
  2. exports.default = {
  3. Query: {
  4. getBox(_) {
  5. return box
  6. }
  7. }
  8. }
  9. 复制代码

其余略过。再在utils.js创建一个resolver扫描器,每个文件的默认导出都是一个普通对象,所以处理起来并不复杂

  1. function requireAllResolvers(dir, parentArray) {
  2. let arr = [];
  3. let files = fs.readdirSync(dir);
  4. for (let f of files) {
  5. let p = path.join(dir, f);
  6. let stat = fs.statSync(p);
  7. if (stat.isDirectory()) {
  8. requireAllResolvers(p, arr);
  9. } else {
  10. let extname = path.extname(p);
  11. if (extname === ".js" || extname === ".ts") {
  12. let resolver = require(p).default;
  13. if (!parentArray) {
  14. arr.push(resolver);
  15. } else {
  16. parentArray.push(resolver);
  17. }
  18. }
  19. }
  20. }
  21. return arr;
  22. }
  23. 复制代码

同理可以搞定index文件里的resolvers

  1. const resolvers = requireAllResolvers(path.resolve(__dirname, './resolvers'))
  2. 复制代码

Apollo会帮我们把数组内的内容进行merge,所以我们只要保证每个文件里的内容符合格式即可。如果一切顺利的话,项目仍然可以正确运行,并没有什么改变,但是却可以在这基础上横向扩展了。

数据库

对于一个web服务来说,数据应该储存在专门的数据库中,比如mysql、redis等,此处以常用的mysql为例,看看graphql在跟数据库结合时有什么不同。还以之前的盒子小球为例,创建一个数据库。

  1. SET NAMES utf8mb4;
  2. SET FOREIGN_KEY_CHECKS = 0;
  3. DROP TABLE IF EXISTS `t_ball`;
  4. CREATE TABLE `t_ball` (
  5. `id` int(10) NOT NULL,
  6. `size` int(255) DEFAULT NULL,
  7. `color` varchar(255) DEFAULT NULL,
  8. `boxId` int(10) DEFAULT NULL,
  9. PRIMARY KEY (`id`)
  10. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  11. BEGIN;
  12. INSERT INTO `t_ball` VALUES (1, 5, 'red', 1);
  13. INSERT INTO `t_ball` VALUES (2, 6, 'blue', 1);
  14. INSERT INTO `t_ball` VALUES (3, 7, 'white', 2);
  15. INSERT INTO `t_ball` VALUES (4, 8, 'black', 2);
  16. COMMIT;
  17. DROP TABLE IF EXISTS `t_box`;
  18. CREATE TABLE `t_box` (
  19. `id` int(10) NOT NULL AUTO_INCREMENT,
  20. `width` int(255) DEFAULT NULL,
  21. `height` int(255) DEFAULT NULL,
  22. `color` varchar(255) DEFAULT NULL,
  23. PRIMARY KEY (`id`)
  24. ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
  25. BEGIN;
  26. INSERT INTO `t_box` VALUES (1, 100, 100, 'red');
  27. INSERT INTO `t_box` VALUES (2, 200, 200, 'blue');
  28. COMMIT;
  29. SET FOREIGN_KEY_CHECKS = 1;
  30. 复制代码

回到express项目,因为模型有点细微的变化,加入了主键id,所以gql文件中的schema需要加入id,下面列出需要修改的schema

  1. type Box {
  2. id: Int
  3. width: Int
  4. height: Int
  5. color: String
  6. balls(color: String): [Ball]
  7. }
  8. type Ball {
  9. id: Int
  10. size: Int
  11. color: String
  12. }
  13. type Query {
  14. getBox: [Box]
  15. }
  16. type Mutation {
  17. setWidth(width: Int, id: Int): Box
  18. }
  19. 复制代码

在项目里增加mysql的包

  1. yarn add mysql
  2. 复制代码

建立连接池,将查询简单封装,就不引入DAO层了,毕竟一共没几句sql

  1. const mysql = require('mysql')
  2. const pool = mysql.createPool({
  3. host: '127.0.0.1',
  4. user: 'root',
  5. password: 'password',
  6. database: 'graphqldemo',
  7. port: 3306
  8. })
  9. const query = (sql, params) => {
  10. return new Promise((res, rej) => {
  11. pool.getConnection(function (err, connection) {
  12. connection.query(sql, params, function (err, result) {
  13. if (err) {
  14. rej(err);
  15. } else {
  16. res(result);
  17. }
  18. connection.release();
  19. });
  20. });
  21. })
  22. }
  23. 复制代码

在resolver中引入sql前需要知道resolver的四个参数。第一个参数parent,是当前元素的父元素,顶级的schema的父元素称为root,大部分教程中用_代替。第二个参数是params,也就是查询参数。第三个参数是config,其中有一个参数dataSources我们过会儿需要用到。第四个参数是context,它的入参是express的Request和Response,可以用来传入身份信息,进行鉴权等操作。
我们把封装好的query函数放进这个dataSources。在index.js中修改

  1. const server = new ApolloServer({
  2. typeDefs,
  3. resolvers,
  4. dataSources: () => ({
  5. query
  6. })
  7. });
  8. 复制代码

接着就可以修改resolvers,先实现第一个getBox,因为现在不止一个盒子,所以返回的是一个数组,schema已经进行了修改

  1. Query: {
  2. getBox(_, __, { dataSources: { query } }) {
  3. return query('select * from t_box')
  4. }
  5. }
  6. 复制代码

query返回的是一个Promise,Apollo是支持这种写法的
然后完成Box的balls,我们需要从parent中拿到父元素的id

  1. Box: {
  2. balls(parent, { color }, { dataSources: { query } }) {
  3. return query('select * from t_ball where box_id=? and color=?', [parent.id, color])
  4. }
  5. }
  6. 复制代码

最后还有一个Mutation需要修改,schema中的定义返回的是被修改后的box,所以需要两条sql来完成这个部分

  1. Mutation: {
  2. async setWidth(_, { width, id }, { dataSources: { query } }) {
  3. await query('update t_box set width=? where id=?', [width, id])
  4. return query('select * from t_box where id=?', [id]).then(res => res[0])
  5. }
  6. }
  7. 复制代码

到这里,基本已经完成了一个graphql项目的基础,在此基础上横向扩展就能够完成一个简单的项目。另外,正式的项目中,还是需要DAO层来管理数据,否则重构会教你做人的。

typescript & type-graphql

迎合潮流,我们需要typescript的加持,否则怎么写都会被认为是玩具。但是我们思考一个问题,typescript的类型和graphql都是对模型的描述,基本一致,只在语法上有一些区别,能不能通用呢。官方提供了相关的API实现这个需求,但是语法并不简洁,type-graphql也许是更好的选择。
先导入typescript和type-graphql,还有之前用到的包的描述文件,另外type-graphql扫描注解用到了reflect-metadata这个还未进入标准的特性,所以需要引入这个包

  1. yarn add typescript type-graphql reflect-metadata @types/mysql @types/express
  2. 复制代码

typescript老规矩,先写tsconfig.json,大概有以下内容就差不多了

  1. {
  2. "compilerOptions": {
  3. "target": "es6",
  4. "module": "commonjs",
  5. "lib": ["es6", "es7", "esnext", "esnext.asynciterable"],
  6. "noImplicitAny": false,
  7. "moduleResolution": "node",
  8. "baseUrl": ".",
  9. "esModuleInterop": true,
  10. "inlineSourceMap": true,
  11. "experimentalDecorators": true,
  12. "emitDecoratorMetadata": true,
  13. "watch": true
  14. }
  15. }
  16. 复制代码

然后把所有require改成import,后缀名js改成ts就行了如果有报错就写个any
运行项目我们使用ts-node,全局安装ts-node之后执行ts-node index.ts即可启动。正式项目我们可以使用pm2指定解释器或者将项目编译成js来运行。然后我们将type-graphql引入项目。
非常遗憾的是引入type-graphql后代码结构发生大改,除了数据库相关的内容基本可以重写了,graphql文件也不需要了。先建立一个models文件夹,新增两个文件Box.ts和Ball.ts

  1. import { ObjectType, Field } from "type-graphql";
  2. @ObjectType()
  3. export default class Ball {
  4. @Field()
  5. id: number;
  6. @Field()
  7. size: number;
  8. @Field()
  9. color: string;
  10. boxId: number;
  11. }
  12. 复制代码
  1. import { ObjectType, Field, Int } from "type-graphql";
  2. import Ball from "./Ball";
  3. @ObjectType({ description: "这是盒子模型" })
  4. export default class Box {
  5. @Field(type => Int)
  6. id: number;
  7. @Field(type => Int, { nullable: true, description: "这是宽度" })
  8. width: number;
  9. @Field(type => Int)
  10. height: number;
  11. @Field()
  12. color: string;
  13. @Field(() => [Ball])
  14. balls: Ball[];
  15. }
  16. 复制代码

ObjectType注解代表这个类是graphql中的对象类型,而被Field注解的属性就是定义到graphql中的属性。Field的第一个参数是个函数用来表示类型,函数的入参没有意义,写type是为了语义化,返回值是类型(typescript的数字类型是number,但graphql的数字类型分为Int和Float,如果不指定为Int,typegraphql默认number为Float);第二个参数是配置项,nullable默认为false,这里可以改为true,description是注释
然后修改一下index.ts,引入graphql之前的东西基本都不要了,就保留数据库的query方法,另外query方法不放到dataSources中了,放到Context中

  1. import "reflect-metadata";
  2. import express from "express";
  3. import { ApolloServer } from "apollo-server-express";
  4. import path from "path";
  5. import query from "./db";
  6. import { buildSchema } from "type-graphql";
  7. const PORT = 4000;
  8. const app = express();
  9. app.use(express.static("public"));
  10. buildSchema({
  11. resolvers: [path.resolve(__dirname, "./resolvers/*.resolver.ts")]
  12. }).then(schema => {
  13. const server = new ApolloServer({
  14. schema,
  15. context: () => ({
  16. query
  17. })
  18. });
  19. server.applyMiddleware({ app });
  20. app.listen(PORT, () =>
  21. console.log(
  22. `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
  23. )
  24. );
  25. });
  26. 复制代码

buildSchema是异步的,所以ApolloServer启动要放在这之后,之前提到过ApolloServer需要提供typeDefs和resolvers两个参数或一个schema参数。看buildSchema这个函数的入参,就知道我们还有resolvers没有改造,在resolvers文件夹下新建一个Box.resolver.ts,把之前的三个resolver改造一下

  1. import {
  2. Resolver,
  3. Query,
  4. Arg,
  5. Mutation,
  6. Ctx,
  7. FieldResolver,
  8. Root
  9. } from "type-graphql";
  10. import Box from "../models/Box";
  11. import Ball from "../models/Ball";
  12. @Resolver(Box)
  13. export default class BoxResolver {
  14. @Query(returns => [Box])
  15. getBox(@Ctx() { query }) {
  16. return query("select * from t_box");
  17. }
  18. @FieldResolver(returns => [Ball])
  19. balls(@Root() box: Box, @Ctx() { query }, @Arg("color") color: string) {
  20. return query("select * from t_ball where boxId=? and color=?", [
  21. box.id,
  22. color
  23. ]);
  24. }
  25. @Mutation(returns => Box)
  26. async setWidth(
  27. @Arg("width") width: number,
  28. @Arg("id") id: number,
  29. @Ctx() { query }
  30. ) {
  31. await query("update t_box set width=? where id=?", [width, id]);
  32. return query("select * from t_box where id=?", [id]).then(res => res[0]);
  33. }
  34. }
  35. 复制代码

简单说一下几个注解的意思。Query和Mutation代表是这两个基础类型下的resolver,参数是个函数,表示预期的返回类型;FieldResolver与类注解Resolver关联,代表Box这个对象类型下的字段balls的resolver;Arg注解的是参数,第一个参数是入参的参数名,它还有第二个参数,可以配置nullable;Root注解的参数是父元素,在balls方法中拿到了父盒子的id;Ctx注解的是Context,从中取到了apolloserver中context的query方法。
到这里,不需要手写graphql文件,依然完成了一个graphql服务器,而且typeDefs和resolvers结合到了一起,不用担心漏写了。并且我们的程序已经大变样,不深入学习一番已经看不懂了,恭喜你离建立技术护城河更进一步。
type-graphql有个内置的权限管理,有兴趣的话可以看看Authorized注解

typeorm

ORM是否要引入项目,主要还是看项目需求。这里使用typeorm,它在写法上与typegraphql非常契合,因为他可以直接复用typegraphql中创建的model,是typegraphql在数据层上非常好的一个实现方式。typeorm本身内容很多,可以单独写一篇文章,本文只介绍与graphql有关的部分,先引入typeorm

  1. yarn add typeorm
  2. 复制代码

然后在项目根目录创建一个ormconfig.json,输入数据库配置

  1. {
  2. "type": "mysql",
  3. "host": "127.0.0.1",
  4. "port": 3306,
  5. "username": "root",
  6. "password": "password",
  7. "database": "graphqldemo",
  8. "synchronize": false,
  9. "logging": false,
  10. "entities": ["./models/*.ts"]
  11. }
  12. 复制代码

其中type是数据库类型,typeorm支持MySQL、MariaDB、Postgres、SQLite、Oracle、MongoDB等多种数据库。synchronize如果为true,typeorm会根据模型自动建表,如果模型有修改还会对表结构进行修改(外键等原因会导致修改失败项目无法启动,需要手动干预或者使用typeorm中的migrations)。logging为true会在控制台打印自动生成的sql语句。
先修改index.ts

  1. import { createConnection } from "typeorm";
  2. //····在app.listen之前添加
  3. createConnection().then(() => {
  4. app.listen(PORT, () =>
  5. console.log(
  6. `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
  7. )
  8. );
  9. });
  10. //····
  11. 复制代码

跟typegraphql的buildSchema一样,createConnection也是个异步的promise,所以为了防止一些意外情况,app.listen的操作要在这两个过程之后。如果已经建立了ormconfig.json文件,createConnection会自动读取其中的配置,否则需要将其作为参数填进去。
然后修改models,之后models可以同时在type-graphql中表示类型,也可以在typeorm中作为数据实体,一模两吃

  1. import { ObjectType, Field, Int } from "type-graphql";
  2. import Box from "./Box";
  3. import {
  4. Column,
  5. ManyToOne,
  6. Entity,
  7. BaseEntity,
  8. PrimaryGeneratedColumn
  9. } from "typeorm";
  10. @Entity({ name: "t_ball" })
  11. @ObjectType()
  12. export default class Ball extends BaseEntity {
  13. @PrimaryGeneratedColumn()
  14. @Field(type => Int)
  15. id: number;
  16. @Column()
  17. @Field(type => Int)
  18. size: number;
  19. @Column({ type: "varchar", length: 255 })
  20. @Field()
  21. color: string;
  22. @Column()
  23. boxId: number;
  24. @ManyToOne(type => Box)
  25. box: Box;
  26. }
  27. 复制代码
  1. import { ObjectType, Field, Int } from "type-graphql";
  2. import Ball from "./Ball";
  3. import {
  4. Entity,
  5. PrimaryGeneratedColumn,
  6. Column,
  7. OneToMany,
  8. BaseEntity
  9. } from "typeorm";
  10. @Entity({ name: "t_box" })
  11. @ObjectType({ description: "这是盒子模型" })
  12. export default class Box extends BaseEntity {
  13. @PrimaryGeneratedColumn()
  14. @Field(type => Int)
  15. id: number;
  16. @Column()
  17. @Field(type => Int, { nullable: true, description: "这是宽度" })
  18. width: number;
  19. @Column()
  20. @Field(type => Int)
  21. height: number;
  22. @Column({ type: "varchar", length: 255 })
  23. @Field()
  24. color: string;
  25. @OneToMany(type => Ball, ball => ball.box)
  26. @Field(() => [Ball])
  27. balls: Ball[];
  28. }
  29. 复制代码

首先将类继承自typeorm中的BaseEntity,并且类上增加了一个Entity注解,参数的配置项中加一个name可以指定表名;Column注解这个属性是数据库的一列,参数可以指定具体的类型和长度;PrimaryGeneratedColumn注解代表这是一个自增主键;OneToMany是个特殊的注解,用来描述实体直接的relations,一共包括OneToMany,ManyToOne,ManyToMany三种,具体用法说来话长,请自行摸索。这样就和之前建立的数据库对应了,感兴趣的可以使用typeorm的自动建表功能看看有什么不同。
现在我们可以抛弃简陋封装的query方法,直接使用typeorm提供的数据获取方式

  1. import {
  2. Resolver,
  3. Query,
  4. Arg,
  5. Mutation,
  6. FieldResolver,
  7. Root,
  8. Int
  9. } from "type-graphql";
  10. import Box from "../models/Box";
  11. import Ball from "../models/Ball";
  12. @Resolver(Box)
  13. export default class BoxResolver {
  14. @Query(returns => [Box])
  15. getBox() {
  16. return Box.find();
  17. }
  18. @FieldResolver(returns => [Ball])
  19. balls(@Root() box: Box, @Arg("color", { nullable: true }) color: string) {
  20. return Ball.find({ boxId: box.id, color });
  21. }
  22. @Mutation(returns => Box)
  23. async setWidth(
  24. @Arg("width", type => Int) width: number,
  25. @Arg("id", type => Int) id: number
  26. ) {
  27. let box = await Box.findOne({ id });
  28. box.width = width;
  29. return box.save();
  30. }
  31. }
  32. 复制代码

整个程序实现非常的优雅,并且很难懂~。typeorm的内容非常多,如果对其他的orm有经验,上手还是很快的。另外如果真的有typeorm写不出来的sql,该手写就手写吧~

总结

这篇文章我很早就开始写了,但是摊子铺的太大,所以一直写不完主要是打怪物猎人冰原。文中选取的模型也非常简单,但是基本完成了一个graphql服务器的框架,当然也留下很多内容没有讲,比如非常重要的标量类型和输入类型。因为本人主业是前端,会一点java后端,所以如果文章中出现概念性错误,请评论指出,共同进步。

作者:Type_Zer0
链接:https://juejin.im/post/6844903984084287496
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。