发布于

graphql

Authors
  • avatar
    Name
    田中原
    Twitter

graphql

为什么要分享graphql

16年的公司用java实现了一套自定义的查询结构,将业务逻辑层由前端开发负责,所有App、桌面应用、小程序、H5都由前端组合查询结构去完成业务的开发,体验非常不错。 与此同时了解到了graphql,觉得graphql的实现更加优雅更简洁,但是在国内普及程度并不高,所以在这里分享下,也算为graphql在国内的普及推广做点贡献。 推荐大家可以自己尝试一下,也许会爱上它。

graphql是什么

graphql(graph query language)是一种API的查询语言,由Facebook设计并开源。通常会拿来与REST风格的API做对比。 下文将会通过一些代码示例来类比下graphql与大家经常使用的restful的api不同。

以下代码是依据与apollo实现的。

具体其他graphql的实现,可以浏览

awesome-graphql graphql官网

需求

假设我们要实现电影的列表:

前端需要的的数据结构为:

[{
  "title": "电影名",
  "actors": [{
    "name": "演员1",
    "movies": ["出演过的电影1", "出演过的电影2"]
  }]
},...]

后端的数据存储结构为

电影movies:

[
  {
    "id": 1,
    "title": "The Shawshank Redemption",
    "release_year": 1993,
    "tags": ["Crime", "Drama"],
    "rating": 9.3,
    "actors": [1, 2],
    "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BODU4MjU4NjIwNl5BMl5BanBnXkFtZTgwMDU2MjEyMDE@._V1_UX182_CR0,0,182,268_AL_.jpg"
  }
]

演员actors:

[
  {
    "id": 1,
    "name": "Tim Robbins",
    "dob": "10/16/1958",
    "num_credits": 73,
    "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMTI1OTYxNzAxOF5BMl5BanBnXkFtZTYwNTE5ODI4._V1_.jpg"
  }
]

restful实现

以下是restful的接口:

接口描述
/movies电影列表
/movie/:id根据id返回对应的单条电影
/movie/:id/actors返回单条电影的演员列表
/actors演员列表
/actor/:id根据id返回演员
/actor/:id/movies返回演员出演的所有电影
router.get('/movies', (ctx) => (ctx.body = movies.value()))

router.get('/movie/:id', (ctx, next) => {
  return (ctx.body = movies.find({ id: ~~ctx.params.id }).value())
})

router.get('/movie/:id/actors', (ctx, next) => {
  let ids = movies.find({ id: ~~ctx.params.id }).value().actors
  return (ctx.body = ids ? actors.value().filter((x) => ids.includes(x.id)) : [])
})

router.get('/actors', (ctx) => (ctx.body = actors.value()))

router.get('/actor/:id', (ctx, next) => {
  return (ctx.body = movies.find({ id: ~~ctx.params.id }).value())
})

router.get('/actor/:id/movies', (ctx, next) => {
  return (ctx.body = movies.filter((m) => m.actors.includes(~~ctx.params.id)).value() || [])
})

如果我们只用restful的接口去完成我们的需求,那么前端代码将会变成

// 获取所有电影
axios.get(`${base}/movies`).then((result) => {
  let moviesList = result.data

  // 获取所有电影的演员
  moviesList.forEach((x, xInd) => {
    axios.get(`${base}/movie/${x.id}/actors`).then((res) => {
      res.data.forEach((element, ind) => {
        // 获取演员出演过的电影
        axios.get(`${base}/actor/${element.id}/movies`).then((r) => {
          console.log('element.movies', r.data)
          element.movies = r.data.map((y) => y.title) || []
          xInd === moviesList.length - 1 && ind === x.actors.length - 1 && setMovies(moviesList) //数据组合完成通过setMovies更新到页面
        })
      })
      x.actors = res.data
    })
  })
})

为了完成我们的需求,总共请求了1 + 电影的总数 + 所有电影中演员的数量

在实际使用中,标准的restful就不适合当前场景了。 此时需要为这个需求特殊定制一个接口moviesAndActor,在后端对数据进行聚合

router.get('/moviesAndActor', (ctx, next) => {
  return ctx.body = movies.value().map(x => {
    let obj = {
      ...x
    }
    obj.actors = actors.filter(y => {
      if (x.actors.includes(y.id)) {
        y.movies = [].concat(movies.filter(m => m.actors.includes(y.id)).value())
        return true
      } else {
        return false
      }
    })
    return obj
  })

产品说:现在我们有个新需求,要求展示所有的演员,以及列出演员所有的电影时 那么/moviesAndActor就不再满足我们的需求了,需要再定制一个新的接口。先放下restful的思路,看看graphql的效果。

graphql实现

GraphQL没有依赖HTTP结构,比如动词和URI。(默认都是post的方式)。 而是在数据之上提出了直观的查询语言和强大的type系统层,提供客户端和服务器之间的强约定,查询语言提供了一种让客户端开发者可以永久获取任何页面想要的任意数据的机制。

GraphQL鼓励把数据看作一个虚拟信息图,包含信息的实体叫做type,这些type可以通过fields彼此关联。查询从根开始,遍历这个虚拟图需要的信息

这个“虚拟图”叫做schema,schema是构成API数据模型的type、interface、enum和union的集合。GraphQL还包含了一种方便的schema语言,可以用来定义我们的API。

定义schema

const typeDefs = gql`
  type Query {
    movies: [Movie]
    actors: [Actor]
    movie(id: Int!): Movie
    actor(id: Int!): Actor
  }

  type Movie {
    id: Int
    title: String
    image: String
    release_year: Int
    tags: [String]
    rating: Float
    actors: [Actor]
  }

  type Actor {
    id: Int
    name: String
    image: String
    dob: String
    num_credits: Int
    movies: [Movie]
  }
`

编写resolve

module.exports = {
  Query: {
    movies: (patent, arg, ctx) => movies.value(),
    actors: (patent, arg, ctx) => actors.value(),
    movie: (patent, arg, ctx) => movies.find({ id: ~~arg.id }).value(),
    actor: (patent, arg, ctx) => movies.find({ id: ~~arg.id }).value(),
  },
  Movie: {
    actors(parent) {
      return actors.filter((a) => parent.actors.includes(a.id)).value()
    },
  },
  Actor: {
    movies(parent) {
      return movies.filter((m) => m.actors.includes(parent.id)).value()
    },
  },
}

前端请求

在前端来声明自己要查询哪些字段

import { gql } from '@apollo/client'

const ACTOR = gql`
  fragment Actor on Actor {
    id
    name
    image
    dob
    num_credits
    movies {
      ...Movie
    }
  }
`

// 避免循环引用。actors放在query里写
const MOVIE = gql`
  fragment Movie on Movie {
    id
    title
    image
    release_year
    tags
    rating
  }
`

export const getMovies = gql`
  query getMovies {
    movies {
      ...Movie
      actors {
        ...Actor
      }
    }
  }
  ${MOVIE}
  ${ACTOR}
`

在react中发起请求

const { data } = useQuery(getMovies)

精准请求

restful中请求回来的数据对于前端来说很多字段是冗余的。 而在graphql中,前端使用哪些字段可以自由控制,之请求需要返回的字段。 当一个表字段非常多的场景下可以有效的减少请求的体积。

示例:打开 http://localhost:4000/graphql体验

接口聚合与复用

上面提到的两种需求,不管是通过电影获取相关演员信息,还是通过演员,获取出演电影的信息。只要在graphqlschemaresolve中描述好数据间的关系,不需要再定制单独的接口。

示例:打开 http://localhost:4000/graphql体验

从电影查演员

query {
  movies {
    id
    title
    actors {
      id
      name
      movies {
        id
        title
      }
    }
  }
}

从演员查电影

query {
  actors {
    id
    name
    num_credits
    movies {
      title
      tags
    }
  }
}

接口迭代

以表为例,当表增加字段后,新增加的字段会自动流入restful的接口中。 而graphql中,除非前端的请求中增加对新字段的声明,否则不会获得。

假设actor新增了一个phone字段,那么使用select * from actor的接口都会无意间暴露演员的电话号码。

graphql核心

1. Schema

描述了 数据的组织形态 以及服务器上的那些数据能够被查询,Schemas提供了你数据中可用的数据的对象类型,GraphQL中的对象是强类型的,因此schema中定义的所有的对象必须具备类型。Schemas可用是两种类型Query和Mutation。

查询类型

查询类型可以视为接口的路由和入口。

类型描述
Query查询
Mutation更改
Subscription订阅

Subscription一般用于websocket等从服务端推送消息到客户端的场景

传参
type Query {
    actor(id: Int!): Actor
}

标量类型

用于描述接口的抽象数据模型,有Scalar(标量)和Object(对象)两种,Object由Field组成,同时Field也有自己的Type。

类型描述
Int32 位整数。
Float双精度浮点值
StringUTF‐8 字符串
Booleantrue 或者 false。
ID标量类型表示一个唯一标识符
enum枚举

大部分的 GraphQL 服务实现中,都有自定义标量类型的方式。通过scalar定义,实现其序列化、反序列化和验证。

scalar Date

对象类型

通过type声明,内部包含Field(可以是任何标量或对象类型),用来表示对象。

类型修饰符

类型描述
[]数组
!非空

其他类型

接口类型,联合类型...

directives

指令是一个标识符,其后跟一个@字符,并可选地后面是一个命名参数列表。可以标注到字段上。 GraphQL默认提供三种指令:@deprecated, @skip, and @include.

指令名描述
@deprecated(reason: String)标记字段弃用,当调用时会有警告
@skip(if: Boolean!)为true时,跳过该字段解析器
@include(if: Boolean!)如果为true,则调用带注释字段的解析器

自定义directives

apollo自定义指令

2.Resolver

查询类型的Resolver

在Schema中声明的查询需要设置一个同名的Resolver。用来处理查询的逻辑,可以类比koa或express中路由的controller。

对象类型的Resolver

在Resolver中与Schema中对象同名的Resolver会被用来处理这个对象的逻辑,最后应该返回符合该对象类型的数据。

链式解析

# A library has a branch and books
type Library {
  branch: String!
  books: [Book!]
}

# A book has a title and author
type Book {
  title: String!
  author: Author!
}

# An author has a name
type Author {
  name: String!
}

type Query {
  libraries: [Library]
}
```

```graphql
query GetBooksByLibrary {
  libraries {
    books {
      title
      author {
        name
      }
    }
  }
}
// Resolver
const resolvers = {
  Query: {
    libraries() {
      return libraries
    },
  },
  Library: {
    books(parent) {
      return books.filter((book) => book.branch === parent.branch)
    },
  },
  Book: {
    author(parent) {
      return {
        name: parent.author,
      }
    },
  },
}
image

GraphQL解析流程就是遇到一个Query之后,尝试使用它的Resolver取值,之后再对返回值进行解析,这个过程是递归的,直到所解析Field的类型是Scalar Type(标量类型)为止。解析的整个过程会行程一个很长的Resolver Chain(解析链)。

Query和Mutation在解析时不同的。Query是并行执行的。Mutation因为涉及到更改,则是串行执行的,只有上一步的resolve成功才会调用下一个resolve。

3. 前端查询

操作类型与操作名称

  1. 操作类型可以是 query、mutation 或 subscription,描述你打算做什么类型的操作。操作类型是必需的,除非你使用查询简写语法,在这种情况下,你无法为操作提供名称或变量定义。

  2. 操作名称是可选的,但是鼓励使用操作名称,因为它对于调试和服务器端日志记录非常有用。

# query为操作类型, HeroNameAndFriends操作名称
query HeroNameAndFriends {
  hero {
    name
    friends {
      name
    }
  }
}

变量

  1. 变量前缀必须为 $,后跟其类型,本例中为 Episode。

  2. 所有声明的变量都必须是标量、枚举型或者输入对象类型。所以如果想要传递一个复杂对象到一个字段上,你必须知道服务器对应的类型。

  3. 我们决不能使用用户提供的值来字符串插值以构建查询。

# { "graphiql": true, "variables": { "episode": JEDI } }
query HeroNameAndFriends($episode: Episode) {
  hero(episode: $episode) {
    name
    friends {
      name
    }
  }
}

fragment

当我们前端的进行复杂查询时,我们需要将一些字段复用,可以使用fragment

{
  leftComparison: hero(episode: EMPIRE) {
    ...comparisonFields
  }
  rightComparison: hero(episode: JEDI) {
    ...comparisonFields
  }
}

fragment comparisonFields on Character {
  name
  appearsIn
  friends {
    name
  }
}

使用graphql要注意的问题

GraphQL注入

前端在传参时决不能使用用户提供的字符串插值以构建查询, 否则GraphQL查询请求前就被拼接进完整的GraphQL语句中。攻击者就可以进行注入攻击,改变原来查询的结构。

let id = `
query  {
  movies {
    id
    title
    image
    release_year
    tags
    rating
    actors {
      id
      name
      image
      dob
      num_credits
      movies {
        id
        title
        image
        release_year
        tags
        rating
        actors {
          id
          name
        }
      }
    }
  }
}
`

export const danger = gql`
  ${id}
`

正确的传参方式

export const getMovieById = gql`
  query getMovie($id: Int!) {
    movie(id: $id) {
      ...Movie
    }
  }
  ${MOVIE}
`
let { data: movie } = useQuery(getMovieById, {
  variables: {
    id: 1,
  },
})
传递参数

循环引用

存在互相引用关系的查询,可以通过构建无限循环的查询结构,造成服务器死循环。

query {
  movies {
    title
    actors {
      name
      movies {
        title
        actors {
          name
          movies {
            title
          }
        }
      }
    }
  }
}

一般要在graphql服务器限制查询深度,同时在设计接口时尽量避免或做好限制。

文档

RESTful API 阮一峰 graphql中文 graphql规范

apollo apollo