小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

lxf2023-11-21 01:50:01

前言

来了来了来了,昨天晚上熬到十点终于把折磨了我一周的GraphQL+NestJS+Neo4j跑通了,说实话这一套是我目前弄下来算是资料比较多的一套系统了,但还是搞了一周,倒也不是说有多难,其实整套逻辑我在第二天就搞好了,但是由于公司要求的数据结构比较复杂,GraphQL使用的又是强类型语言,稍有不慎返回值就会报错,为了让返回值类型能够更好的复合项目要求,大部分时间都用在了类型写法的探索上了,所以才拖了这么长时间。

好在最后终于搞好了,我也有时间来总结一下这一套逻辑的一些搭建过程和思路了,至于为什么说是进阶版,因为我会在做各类选择的时候给大家讲清楚为什么我选择了其中一种而没有选择另外一种,当然也不一定会很清楚啦,但至少会说明一下理由。也别去翻主页了,没有入门教程的,我懒,两个放一起写了。

废话少说,开始吧。

选型

NestJS

NestJS没什么好说的,公司后端语言使用的是nodeJS,熟悉我的朋友都知道,之前搭建的每一个服务也都是NestJS为基础,这个可以略过。

Neo4j / MongoDB

Neo4j对于我们公司来说属于是一个比较核心的数据库了,之前的所有项目使用的都是MongoDB,由于我们的新项目涉及到知识类的数据存储,而Neo4j能够很好的处理知识检索的n+1问题,再加上目前市面上几乎所有知识类的产品使用的都是Neo4j,选型方面毋庸置疑的是Neo4j。

关于n+1问题是什么,简单来说就是在查询时你可能会遇到这样的查询逻辑:请通过作者id获取该作者名下所有文章的共同作者。这无论在关系数据库还是非关系型数据库,都需要通过关联很多张表来查询(别杠嗷,我知道可以加冗余字段,你信不信我再给你多整几个XX的XX),不仅效率低,而且会让逻辑变得异常复杂,Neo4j就很好的解决了这个问题,一个查询语句就能够链接到多个节点:match (author:Author {id: 1}) -[owns:Ownns]-> (article:Article) <-[owns:Ownns]- (together:Author) return together。关键是这玩意入门还简单(当然,mysql入门也简单,难的是各种各样查询优化),再加上活跃的社区群体和图数据库中T0级别的统治力,没理由不跟随这股潮流。

GraphQL / Restful API

GraphQL其实是在选型之前争议比较大的一个选择(当然,选型也是我自己和自己争议,没太大意思),作为一种接口风格,它并没有表现出对Restful压倒性的优势,虽然被业界很多人看好,但现阶段它表现出的劣势也不得不说确实还是很大的。首先它的调用方式就是POST请求,这是我一直以来百思不得其解的问题,你既然都自创了一种风格了,为什么还要沿用POST,是担心用户不习惯吗,给用户一点缓冲时间?其次,它作为强类型的接口风格注定是需要付出很大的学习成本的,毫不夸张的说,稍有不慎就会报错,而你的控制台直到消息返回到客户端之前都会告诉你这次请求成功了,这对我的调试造成了非常大的麻烦,我只能一点一点去拆解到底哪里出问题了,而它的报错信息也往往是让人很难理解的,学习起来非常痛苦。

而优点也是很明显的,它能够根据前端需要去自由调整展示的字段,这对于前端来说是非常友好的,对于数据传输也是很便利的,毕竟字段冗余越少,传输压力越小,而且不需要因为前端只是某个字段不同就需要重写接口。每次提到GraphQL和Restful对比总有人会说GraphQL是单端点,但我认为这只是个噱头而已,你还是需要去了解具体每个方法的调用方式,和Restful并没太大差别。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

开个玩笑,其实单端点不是个简单的噱头,正是因为有了单端点,才让GraphQL有了它最强大的竞争力之一——能够很好的解决n+1问题。

你可以在同一个端点拼接多个查询接口,以保证一次查询返回所有需要的数据,而且字段还不冗余,就是有点费后端程序员的头发,毕竟接口拼接逻辑不是那么好写的。

Neo4j作为Graph database,匹配对应的GraphQL查询语句,看上去就很搭吧,没毛病,所以也有很多教程会跳过后端应用NestJS,使用GraphQL直连Neo4j。

但是为了项目安全和防止前端不懂事乱造数据库,还是中间加一道吧。

code first / schemal first

这个选择相信刚创建项目的同学多少会有点懵,我已经决定要用GraphQL了,但为什么让我选择code first还是schemal first,这两者又有什么区别,当然,也有些比较莽的同学随便选了一个,其实也可以,两者本质上并没有太大差别,理论上来说你用code能实现的用schemal也能实现,反过来也是一样的。但是本着严谨的态度,我说说我为什么选择了code first。

这两种模式直译过来就是代码优先和设计优先,两者各有优缺点,基于代码编写的理念不一样,不同的人会有不同的选择,schemal的优势在于比较贴合GraphQL的思想,先定义接口,再编写逻辑,code的优势在于比较接近Restful的书写习惯,虽然也可以先定义接口再编写逻辑,但是由于和接口调用方式脱离,在可读性方面稍显逊色。但我选择code first的理由除了书写习惯以外还有一点,就是它相较于schemal first面世时间较晚,作为一名对新技术存在憧憬的程序员,追随新版本总是可以理解的吧。

创建新项目

新建项目和模块

在执行这一步之前,我默认你已经拥有了基本的NestJS环境,如果没有安装NestJS环境的话可以搜一下对应的教程,有很多,在这里就不赘述了。

nest new search-service  创建项目,起名叫search-service

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

这里我们选择pnpm,因为项目使用的是Monorepo的管理方式,默认使用pnpm,其实没得挑,对于普通用户来说这三个都可以选择,看自己的使用习惯,影响不大。

cd .\search-service\     进入项目目录
nest g resource search   创建模块,起名叫search

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

调整模块结构

删除原有的app.module,将search.module相关文件提到根目录,将main.ts中的AppModule改为SearchModule。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

这步操作不是必要的,但是考虑到该服务是微服务性质,不存在多个模块,所有功能被集中在search模块中,app模块很多余,且与Restful不同,graphQL独特的单端口使得接口url不那么重要,所以在这里将app模块删除,使用search模块作为替代,这里的项目结构参考了油管上这位大佬的视频,再放上git地址。

为避免后续由于项目结构产生不必要的误解,在这里提一下这件事。

依赖安装

本次项目需要安装的依赖如下:

@apollo/server
@neo4j/graphql
@nestjs/apollo
apollo-server-express
@nestjs/mapped-types
graphql
neo4j-driver
nest-neo4j

由于使用的是pnpm,其中有一些依赖直接使用pnpm add出现了找不到的情况,于是我修改了仓库镜像源:

nrm ls
nrm use npm

但是在使用nrm时系统提示错误:ERR_REQUIRE_ESM,原因是nrm依赖的open包使用了新版本的ES Module规范,需要调整依赖版本

npm install -g nrm open@8.4.2 --save

就可以使用了。

graphQL code first

代码编写的时候关于如何写的问题,我大多是参考的NestJs中文文档,加上大多数框架代码都是由NestJs自己生成好的,所以其实需要修改的部分不多,下面我准备分三部分来介绍一下需要重点关注的修改。

1、开启graphQL自带的测试工具

打开search.module.ts

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

添加imports

GraphQLModule.forRoot<ApolloFederationDriverConfig>({
      driver: ApolloFederationDriver,
      autoSchemaFile: {
        federation: 2
      }
    })

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

这个配置的作用是导入graphQL自带的测试工具,导入后使用命令:npm run start:dev 启动程序,打开浏览器,输入:http://localhost:3000/graphql,就可以看到类似下面的界面了(里面的内容是之前测试用的,不用在意):

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

2、连接neo4j

同样是在search.module.ts

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

    Neo4jModule.forRoot({
      scheme: 'neo4j',
      host: 'localhost',
      port: '7687',
      username: 'neo4j',
      password: '你的密码'
    })

作用如题,连接neo4j,其中的配置如何填呢: scheme表示你设置的连接模式,分别有:'neo4j' | 'neo4j+s' | 'neo4j+scc' | 'bolt' | 'bolt+s' | 'bolt+scc'这几种方式

简单来说就是连接neo4j的不同方式,其中 +s 表示使用加密连接

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

密码如果忘记了的话可以在这里选择重置密码

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

3、graphQL & neo4j

如果只是讲解怎么写这一块代码未免有点太无趣了,而且市面上这一类的文章太多,没什么特地去讲的必要,在这里,我想结合自己在做的时候的一些体验来讲。

首先,这是一种强类型的风格,所谓强类型,就是你明明什么都写对了,但最后返回的时候返回类型和声明的类型不一致他也会报错,这一点在初接触graphQL的时候要格外注意,如果代码内部没有报错但客户端报错的话,优先检查返回值格式和声明格式是否一致。

由于我使用的是代码优先的风格,接下来的例子都是代码优先的样例。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

  @Query(() => Codetest, { name: 'codetest' })
  findOne(@Args('id', { type: () => Int }) id: number) {
    return this.codetestService.findOne(id);
  }
Query

我们从上往下一行一行看,首先是Query,在NestJS中,@Query()装饰器根据方法名称生成 GraphQL 模式查询类型名称,修改时使用的装饰器是@Mutation(),相信各位在生成Code first时已经注意到了。

这一段可以参考NestJs中文文档/解析器

Codetest

Search对应的是自定义的查询返回值,由于项目返回值格式要求比较特殊,需要节点和关系一起返回,而节点又存在不同类型,这就导致了我在获取节点信息的时候需要分情况处理,这就引出了我们接下来要使用的Interface类型。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

@InterfaceType({
    resolveType(value) {
        if (value.labels === 'PLAYER') {
            return Player;
        }
        if (value.labels === 'COACH') {
            return Coach;
        }
        return null;
    },
})
export abstract class NodeTest {
    @Field()
    identity: string;
    @Field()
    labels: string;
    @Field()
    elementId: string;
}

这一段参考的是NestJs中文文档/接口,这里将通常使用的@ObjectType()替换为@InterfaceType(),对应的实现类就可以通过实现这个接口的方式来设置相应字段。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

@ObjectType({
    implements: () => [NodeTest],
})
export class Coach implements NodeTest {
    @Field()
    identity: string;
    @Field()
    labels: string;
    @Field()
    elementId: string;
    @Field()
    property_name: string
}

这么讲可能有点抽象,实际上我在这样处理之前有想过其他几种方案。

1、使用可选字段。将所有节点可能出现的字段放进一个大的节点类中,返回时前端自行获取所需要的节点,这样有个好处,不用区分节点类型,所有节点都可以用这种方式返回,有点类似restful的写法,但是缺点在于节点之间字段差异性很大的情况下会有很多冗余字段,比如只有Coach节点有property_name字段,而其他节点没有,而我想要获取这个字段就必须将其他所有节点都返回property_name: null,这样会造成大量无效字段,显然违背了graphQL的设计初衷,是不合理的。

2、使用联合类型。有很多类型提到了联合类型和接口的区别,写的很长,看上去很复杂的样子,实际上我理解两者的区别其实很简单。当你使用联合类型时,是类似Node = Player & Coach的写法,当你在调用时,就必须将两者的所有字段都注明,即使是相同字段

nodes {
            ... on Player {
              identity
              property_number
            },
            ... on Coach {
              identity
              property_name
            }
        }

比如在这个例子中,请求中identity字段是返回值共有的字段,但如果使用联合类型的话就必须在两个返回类型中都声明一次identity,而不能声明在Player和Coach之外。

由于我们的节点存在很多共同字段,显然这样在写请求的时候会给客户端造成很大的麻烦,所以这种方案也被舍弃了。

最终使用的就是上文提到的接口方案。NodeTest接口在声明的时候定义了四个属性,所有实现这个接口的类都必须定义这四个属性,这就使得在返回时对应的实现类都会存在该属性,避免了使用联合类型时必须每个类都定义一遍属性的情况。

nodes {
            identity
            ... on Player {
              property_number
            },
            ... on Coach {
              property_name
            }
        }

有些同学在使用接口时会遇到一个报错:Abstract type \"NodeTest\" must resolve to an Object type at runtime for field \"Codetest.nodes\". Either the \"NodeTest\" type should provide a \"resolveType\" function or each possible type should provide an \"isTypeOf\" function. 简单来说就在类型返回时,由于这是个抽象类型,而graphQL是个强类型风格,你必须定义抽象类型的具体实现,用人话来说就是你要告诉代码,这个返回值在什么情况下要使用什么类型,当你返回的类型为null或者没定义时,就会报这个错。

举个例子:

    resolveType(value) {
        if (value.labels === 'PLAYER') {
            return Player;
        }
        if (value.labels === 'COACH') {
            return Coach;
        }
        return null;
    },

这一段的意思就是,返回值的labels值为'PLAYER'时,返回的值为'Player'类型,而当返回的value.labels既不是'PLAYER'也不是'COACH'时,就会报上面的错误。

即使这样,也只做到了返回所有的节点类型,要做到同时返回所有节点和所有关系,就需要将接口作为属性返回,而这一点如何实现,在文档里没写。

但是写起来也很简单:

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

虽然看上去很简单也很理所当然,但实际上探索出这种写法差不多花费了我两天时间,期间出现了很多不明所以的报错,具体报错信息没有记录,只能说需要注意以下两点:1、接口定义不要放在当前类的同文件下(很玄学,分开文件放就不报错了,我之前测试的时候就是图方便定义在了同文件下导致报错到怀疑人生);2、返回值类型为数组时,需要在type中指定数组类型(这一点在文档里有明确写到)。

虽然看上去有点过于简单了,但这确实是我两天下来总结的经验,有时遇到奇怪的报错而且网上怎么找也找不到答案的时候真的会绝望的。

name & id

这两个字段用来表示查询名称和条件,看下面的示例图会比较直观:

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

service

关于neo4j的查询怎么写这个问题,我先放一段我代码的截图在这,有点代码基础的同学应该是能看懂的,红框中的部分是查询结果的转换方法,这一段根据自己需要去补充,在这里不做详细讲解,如果调用报错,检查一下返回值类型是否和定义的类型一致。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

补充

前面提到过graphQL可以很好的解决n+1问题,还有一个很好用的装饰器@ResolveField(),可以在查询的内容中衔接其他查询内容,详细的各位同学可以自己查一下文档,写到这有点累了,就当课后作业吧。

小白看看还是能学会的GraphQL+NestJS+Neo4j——进阶版

常见报错及解决方法

1、

Expected Iterable, but did not find one for field \"Query.codetest\

看看自己是不是定义的是个数组,但返回了一个对象,或相反

2、

Cannot return null for non-nullable field Player.identity.

看看自己返回的对象类型中的属性和定义的返回值属性是否一致

3、

Abstract type \"NnTest\" must resolve to an Object type at runtime for field \"Codetest.nodes\". Either the \"NnTest\" type should provide a \"resolveType\" function or each possible type should provide an \"isTypeOf\" function.

抽象类型在使用的时候需要定义对应的实例判断方法(resolveType),或者实例方法的判断条件是不是有问题,返回了null

4、

Cannot find module '@nestjs/core/inspector/graph-inspector'

npm i @nestjs/core && npm i @nestjs/testing

5、

Nest can't resolve dependencies of the ResolversExplorerService (ModulesContainer, MetadataScanner, ExternalContextCreator, GqlModuleOptions, ModuleRef, ?). Please make sure that the argument SerializedGraph at index [5] is available in the GraphQLModule context.

不好意思这个我忘了,好像也是版本问题

6、

apps/graph/search-service
├─┬ @nestjs/apollo 11.0.4
│ └── ✕ unmet peer @nestjs/core@^9.3.8: found 9.2.1
└─┬ @nestjs/graphql 11.0.4
  ├── ✕ unmet peer @nestjs/core@^9.3.8: found 9.2.1
  └── ✕ unmet peer @nestjs/common@^9.3.8: found 9.2.0

修改当前项目对应依赖版本为 9.3.8

总结

这篇文章写下来真的是累麻了,但还是有很多地方没有达到我想要的效果,也有些问题没讲清楚,可能又有很长一段时间不更新了,也有可能想到什么会在这篇文章中补充。

不过如果有人能喜欢这篇文章的话可能我会更有动力一些吧(笑)(目移)。

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!