最近接手了一个GraphQL API的项目,代码写得非常烂,一个resolve函数几百行,到处都是复制粘贴,没有分层,没有复用,没有注释,变量名乱七八糟,改一个小功能都要研究半天,还容易改出bug,性能也很差,一个简单的查询要查几十次数据库,响应很慢。
实在忍不了了,我花了两周时间,对这个GraphQL API进行了全面的重构,重构之后,代码结构清晰,分层合理,复用度高,性能提升了好几倍,维护起来也轻松了很多。今天就来分享一下这次GraphQL API代码重构的经验,聊聊原来的代码有多烂,有哪些问题,我是怎么一步步重构的,重构之后的架构是什么样的,用了哪些设计模式和最佳实践,以及重构之后的效果,希望能给大家一些参考,写出更优雅、更易维护的代码。
一、原代码的问题和痛点
先说说原代码有多烂,有哪些问题,为什么必须重构。
这个项目是一个电商类的GraphQL API,用的是PHP + webonyx/graphql-php这个库,大概有几十个Query和Mutation,涉及用户、商品、订单、支付、内容等模块。原代码是几个开发人员快速迭代写出来的,没有什么规范,没有什么设计,怎么快怎么来,结果就写成了一堆烂代码。
1. 没有分层,所有逻辑都在resolve里
这是最大的问题,原代码完全没有分层,所有的逻辑,包括参数校验、业务逻辑、数据库查询、数据组装、权限校验,全都写在resolve函数里,一个resolve函数动辄几百行,甚至上千行,又长又乱,根本看不懂。
比如一个创建订单的Mutation,resolve函数里,先是校验参数,然后查用户,查地址,查商品,查库存,算价格,创建订单,扣库存,加积分,发通知,记录日志,全写在一个函数里,几百行代码,没有任何拆分,没有任何复用,改一个地方,要在几百行代码里找,非常费劲,还容易改出bug。
而且,很多逻辑都是重复的,比如查用户信息,在好多个resolve里都写了一遍,复制粘贴,改的时候要改好几个地方,很容易漏改,导致不一致。
2. 数据库查询混乱,N+1问题严重
原代码的数据库查询非常混乱,没有任何优化,到处都是直接写SQL,或者用ORM查,没有复用,没有缓存,N+1问题非常严重。
比如查询一个商品列表,先查商品列表,然后循环每个商品,查商品的分类,查商品的图片,查商品的库存,查商品的评价,一个列表查询,要查几十次数据库,性能非常差,数据量一大就慢得要死,高峰期数据库直接被打满。
而且,很多查询都是重复的,同一个请求里,同一个用户的信息,可能被查了好多次,同一个商品的信息,也被查了好多次,完全没有缓存,没有复用,浪费了很多数据库资源。
3. 没有统一的错误处理,错误信息混乱
原代码没有统一的错误处理,每个resolve里,遇到错误,有的是throw异常,有的是返回错误数组,有的是直接die,有的是返回null,非常混乱,前端根本不知道怎么处理错误,错误信息也不统一,有的是中文,有的是英文,有的是数据库的原始错误信息,非常不友好,还可能泄露敏感信息。
而且,很多地方根本没有错误处理,数据库查询失败了,直接就报错了,没有try catch,没有降级,没有重试,一个小错误就可能导致整个请求失败,甚至500错误,用户体验很差。
4. 没有权限校验,安全隐患大
原代码的权限校验非常混乱,有的resolve里校验了,有的没校验,有的校验逻辑是复制粘贴的,改的时候漏改了,就会出现权限漏洞,比如普通用户可以修改别人的订单,可以查看别人的信息,安全隐患很大。
而且,权限校验的逻辑都写在resolve里,和业务逻辑混在一起,很难维护,也很难保证所有的接口都做了权限校验,很容易出现遗漏,导致安全问题。
5. 代码不规范,可读性差
原代码非常不规范,变量名乱七八糟,有的是拼音,有的是英文缩写,有的是单个字母,根本看不懂是什么意思;函数名也不规范,有的是动词开头,有的是名词开头,有的是拼音,很混乱;注释很少,关键逻辑也没有注释,新人接手根本看不懂;代码格式也不统一,有的缩进是2个空格,有的是4个,有的是tab,看起来很费劲。
而且,到处都是魔法数字,魔法字符串,比如状态码,直接写个1、2、3,不知道是什么意思,要猜半天;配置也都硬编码在代码里,改个配置要改代码,重新部署,非常麻烦。
6. 没有测试,重构风险大
原代码完全没有测试,单元测试、集成测试都没有,改代码全靠手动测,很容易改出bug,而且很多bug都是上线之后用户反馈了才知道,非常被动。这也导致重构的风险很大,因为没有测试保护,改了之后不知道会不会影响其他功能,只能小心翼翼地改,然后手动测一遍,效率很低。
这些问题加在一起,让这个项目的代码完全没法维护,改一个小功能都要研究半天,还容易改出bug,性能也很差,用户投诉很多,必须进行重构,不然项目根本没法继续迭代。
二、重构的原则和目标
明确了问题之后,我制定了重构的原则和目标,指导整个重构过程,避免走弯路。
重构原则:
- 保证业务不中断,逐步重构:这是最重要的原则,重构不能影响业务,不能停机,不能影响用户,要逐步重构,一部分一部分来,每一部分都能独立验证,出了问题能快速回滚。
- 行为保持一致,先重构再优化:重构的第一步是保持行为一致,也就是重构之后的代码,功能和原来的完全一样,先把结构理清楚,把代码写优雅,然后再做性能优化和功能优化,不要一边重构一边改逻辑,那样很容易出问题,也很难验证。
- 小步快跑,每一步都可验证:不要一下子把所有代码都重构了,那样风险太大,要小步快跑,一个模块一个模块地重构,每个模块重构完,测试通过,上线验证没问题,再重构下一个模块,每一步都可验证,风险可控。
- 引入测试,保护重构:重构之前,先给核心功能写一些集成测试,保证重构之后的行为和原来的一致,有了测试保护,重构就放心多了,改完跑一遍测试,就知道有没有问题。
- 团队共识,规范先行:重构之前,要和团队达成共识,制定好代码规范、架构规范、命名规范等,大家都按照统一的规范来,不要各写各的,不然重构完还是乱的。
重构目标:
- 分层清晰,职责单一:代码要有清晰的分层,Controller层(resolve)、Service层、Repository层、Model层,每层职责单一,resolve只负责参数接收和响应,业务逻辑在Service,数据库查询在Repository,数据模型在Model,每层各司其职,代码清晰易维护。
- 高内聚低耦合,复用度高:相同的逻辑要抽出来复用,不要复制粘贴,模块之间高内聚低耦合,改一个地方只需要改一处,不会影响其他地方,维护起来轻松。
- 性能提升,响应更快:解决N+1问题,引入缓存,优化数据库查询,提升接口性能,响应时间至少降低50%,高峰期数据库压力降低。
- 统一的错误处理和权限校验:统一错误处理,错误信息规范,前端好处理;统一权限校验,通过中间件或者注解的方式,保证所有接口都有权限校验,安全有保障。
- 代码规范,可读性好:统一代码规范,命名规范,格式规范,关键逻辑有注释,代码清晰易读,新人能快速上手。
- 有测试覆盖,质量有保障:核心功能有单元测试和集成测试,改代码有测试保护,不容易出bug,质量有保障。
三、重构后的整体架构
基于这些原则和目标,我设计了重构后的整体架构,采用的是经典的分层架构,结合GraphQL的特点,做了一些调整。
1. 整体分层
重构后的代码,整体分为以下几层:
- Schema层:定义GraphQL的Schema,包括类型定义、Query、Mutation、输入类型等,只定义结构,不写逻辑,用的是Schema Definition Language(SDL)的方式,比用代码定义Schema清晰多了。
- Resolver层(Controller层):对应Schema里的每个字段的resolve函数,这一层很薄,只负责接收参数,调用Service层的方法,处理响应,不写业务逻辑,不直接查数据库,就像传统MVC里的Controller。
- Service层:业务逻辑层,所有的业务逻辑都写在这里,比如创建订单、支付、发货等,每个业务领域一个Service,Service里可以调用其他Service,调用Repository层,处理业务逻辑,这一层是核心。
- Repository层:数据访问层,所有的数据库查询都写在这里,每个表一个Repository,封装了增删改查、复杂查询、批量查询等,Service层通过Repository访问数据库,不直接写SQL,也不直接用ORM,这样数据库的实现细节被封装起来,以后换数据库或者换ORM,只需要改Repository层,不影响业务逻辑。
- Model层:数据模型层,对应数据库的表,是纯数据对象,没有业务逻辑,只有属性和简单的getter/setter,用来在各层之间传递数据。
- 公共层:包括中间件、工具类、异常类、常量、配置等公共的东西,供各层调用。
2. 目录结构
对应的目录结构大概是这样的:
src/
├── GraphQL/
│ ├── Schema/ # Schema定义
│ │ ├── schema.graphql
│ │ ├── user.graphql
│ │ ├── product.graphql
│ │ └── order.graphql
│ ├── Resolvers/ # Resolver层
│ │ ├── UserResolver.php
│ │ ├── ProductResolver.php
│ │ └── OrderResolver.php
│ └── Middleware/ # GraphQL中间件
│ ├── AuthMiddleware.php
│ └── ErrorMiddleware.php
├── Service/ # Service层
│ ├── UserService.php
│ ├── ProductService.php
│ ├── OrderService.php
│ └── PaymentService.php
├── Repository/ # Repository层
│ ├── UserRepository.php
│ ├── ProductRepository.php
│ ├── OrderRepository.php
│ └── OrderItemRepository.php
├── Model/ # Model层
│ ├── User.php
│ ├── Product.php
│ ├── Order.php
│ └── OrderItem.php
├── Common/ # 公共层
│ ├── Exception/ # 异常类
│ ├── Utils/ # 工具类
│ ├── Constants/ # 常量
│ └── Config/ # 配置
└── Tests/ # 测试
├── Unit/
└── Integration/这样的目录结构,非常清晰,每层各司其职,找代码很方便,维护起来也轻松。
3. 核心设计模式和最佳实践
在重构的过程中,我用了一些设计模式和最佳实践,让代码更优雅,更易维护。
- 依赖注入(DI):用了依赖注入容器,各层之间通过接口依赖,不直接new对象,由容器统一管理对象的创建和生命周期,这样代码的耦合度更低,更容易测试,也更容易替换实现。
- 数据加载器(DataLoader):为了解决GraphQL的N+1问题,用了DataLoader模式,把多次查询合并成一次批量查询,大大减少了数据库查询次数,提升了性能。
- 中间件模式:用中间件来处理权限校验、错误处理、日志记录等横切关注点,不用在每个resolve里写,统一处理,代码更简洁,也更容易保证一致性。
- DTO(数据传输对象):在各层之间传递数据的时候,用DTO而不是数组,这样有类型提示,结构清晰,不容易写错,IDE也能自动补全,开发效率更高。
- 值对象(Value Object):对于一些有特殊规则的值,比如价格、日期、地址等,用值对象来封装,把规则内聚在值对象里,而不是到处写判断,代码更健壮,也更容易复用。
- 策略模式:对于一些有多种实现的场景,比如支付方式、物流方式,用策略模式,定义接口,不同的实现,根据情况选择,这样加新的方式的时候,不需要改原来的代码,符合开闭原则。
- 规范先行:制定了统一的代码规范、命名规范、注释规范,用PHP_CodeSniffer检查代码格式,用PHPStan做静态分析,保证代码质量,团队所有人都按照规范来,代码风格统一。
四、关键问题的解决方案
在重构的过程中,有几个关键的问题,需要重点解决,这里分享一下解决方案。
1. N+1问题的解决:DataLoader
GraphQL最常见的性能问题就是N+1,因为GraphQL的resolve是字段级的,每个字段单独resolve,如果每个字段都查一次数据库,就会出现N+1问题,性能很差。
原代码就是这样,查询一个商品列表,先查列表,然后每个商品的分类、图片、库存、评价都单独查,一个请求查几十次数据库,非常慢。
重构的时候,我用DataLoader来解决这个问题。DataLoader的原理是,把同一个请求里的多次查询收集起来,合并成一次批量查询,然后把结果缓存起来,后续再查同样的,直接从缓存里取,不用再查数据库。
比如查询商品列表的分类,先把所有商品的分类ID收集起来,然后用一次IN查询,把所有分类都查出来,放到缓存里,然后每个商品的分类resolve的时候,直接从缓存里取,这样原来要查N次数据库,现在只需要查1次,性能提升非常明显。
我封装了一个通用的DataLoader,每个需要批量查询的实体,都创建一个DataLoader,在请求开始的时候初始化,请求结束的时候销毁,这样同一个请求里的查询会被合并和缓存,不同请求之间不会互相影响,非常方便。
用了DataLoader之后,原来一个请求要查几十次数据库,现在只需要几次,性能提升了好几倍,响应时间从原来的1-2秒,降到了200-300ms,效果非常明显。
2. 统一错误处理
原代码的错误处理非常混乱,重构的时候,我做了统一的错误处理。
首先,定义了统一的异常基类,所有的业务异常都继承这个基类,异常里有错误码、错误信息、HTTP状态码,比如参数错误异常、权限不足异常、资源不存在异常、业务逻辑异常等,每种异常都有对应的错误码和状态码。
然后,在GraphQL的入口,加了一个错误处理中间件,捕获所有的异常,把异常转换成统一格式的错误响应,包含错误码、错误信息、请求ID等,前端可以根据错误码来处理,错误信息是友好的用户提示,不会泄露数据库错误等敏感信息。
对于系统异常,比如数据库连接失败、代码bug等,会记录详细的错误日志,包括堆栈、请求参数、用户信息等,方便排查问题,但是返回给前端的是统一的"系统繁忙,请稍后再试",不会泄露敏感信息。
这样统一之后,错误处理非常规范,前端处理起来很方便,后端也不用在每个地方写try catch,只需要在合适的地方抛出业务异常就行,大大简化了代码,也减少了错误处理的遗漏。
3. 统一权限校验
原代码的权限校验非常混乱,重构的时候,我做了统一的权限校验,用中间件的方式来处理。
首先,定义了权限注解,在Schema定义里,给需要权限校验的字段加上权限注解,比如@auth(需要登录)、@role(admin)(需要管理员角色)、@permission(order:edit)(需要订单编辑权限)等。
然后,写了一个权限校验中间件,在resolve执行之前,先检查这个字段有没有权限注解,如果有,就校验当前用户有没有对应的权限,如果没有,就抛出权限不足异常,不需要在每个resolve里写权限校验的代码。
这样,权限校验统一在中间件里处理,和业务逻辑分离,代码更简洁,也更容易保证所有的接口都做了权限校验,不会出现遗漏,安全有保障。而且,权限规则集中管理,改权限规则只需要改中间件或者注解,不需要改每个resolve,维护起来很方便。
4. 数据库查询优化和缓存
除了DataLoader解决N+1问题,我还做了其他的数据库查询优化和缓存。
首先,所有的查询都走Repository层,在Repository层做查询优化,比如给常用的查询条件加索引,避免全表扫描;优化SQL语句,避免子查询、避免select *,只查需要的字段;复杂查询用EXPLAIN分析执行计划,优化索引和SQL。
然后,引入了Redis缓存,对于热点数据,比如商品分类、热门商品、用户信息等,做缓存,减少数据库查询。缓存的更新策略是"更新数据库+删除缓存",数据更新的时候,先更新数据库,然后删除缓存,下次查询的时候再重新生成,保证缓存和数据库的一致性,同时设置缓存过期时间,就算删除失败了,过期之后也会重新生成。
另外,对于一些计算复杂、但是不经常变化的数据,比如统计数据、报表数据,做定时计算,缓存结果,不用每次查询都实时计算,大大提升了性能。
通过这些优化,数据库的查询次数大大减少,查询速度也大大提升,高峰期数据库的CPU使用率从原来的80-90%,降到了20-30%,效果非常明显。
5. 逐步重构,平滑过渡
这么大的重构,不能一下子全改了,那样风险太大,我是逐步重构,平滑过渡的。
首先,我搭建了新的架构框架,把分层、依赖注入、中间件、统一错误处理这些基础设施搭好,然后写了几个核心接口的新实现,和老的代码并存。
然后,通过路由或者Schema的方式,把新的接口逐步替换老的接口,先替换一个,测试通过,上线验证没问题,再替换下一个,一个模块一个模块地替换,老的代码逐步下线。
在替换的过程中,新老代码是可以互相调用的,新的Service可以调用老的Repository,老的resolve也可以调用新的Service,这样可以逐步迁移,不用一次性全改,风险可控。
整个重构过程,花了大概两周时间,分了好几个阶段,每个阶段都有测试和验证,业务没有受到影响,用户也没有感知,非常平滑。
五、重构后的效果
经过两周的重构,第一阶段完成之后,效果非常明显:
1. 代码质量大幅提升
代码结构清晰,分层合理,每层职责单一,resolve函数从原来的几百行,变成了几行到几十行,只负责接收参数和调用Service,业务逻辑都在Service层,数据库查询都在Repository层,代码非常清晰,新人接手半天就能看懂,改功能也很方便,不会再出现改一个地方影响其他地方的情况。
代码复用度大大提高,原来复制粘贴的逻辑,都抽成了公共的方法或者Service,改的时候只需要改一处,不会再出现漏改导致的不一致。
代码规范统一,命名规范,格式规范,关键逻辑有注释,用PHP_CodeSniffer和PHPStan检查,没有语法错误和明显的bug,代码质量有了保障。
2. 性能大幅提升
通过DataLoader解决N+1问题,加上数据库查询优化和缓存,接口的性能大幅提升,平均响应时间从原来的800ms降到了150ms,95分位响应时间从原来的2s降到了300ms,高峰期数据库CPU使用率从80-90%降到了20-30%,系统能承载的并发量也提升了好几倍,用户体验好了很多,再也不会出现高峰期接口超时的情况。
3. 可维护性大幅提升
分层清晰,职责单一,代码复用度高,规范统一,维护起来非常轻松,改一个功能,只需要改对应的Service或者Repository,不会影响其他地方,bug也少了很多。统一的错误处理和权限校验,也让代码更简洁,更不容易出问题。
而且,有了测试覆盖,核心功能都有单元测试和集成测试,改代码的时候,跑一遍测试就知道有没有问题,不用担心改出bug,开发效率也提升了很多。
4. 安全性大幅提升
统一的权限校验中间件,保证了所有接口都做了权限校验,不会再出现遗漏,也不会出现普通用户能操作别人数据的情况,安全漏洞大大减少。统一的错误处理,不会再泄露数据库错误、服务器信息等敏感信息,安全性也有了提升。输入参数也做了统一的校验和过滤,避免了SQL注入、XSS等安全问题。
总的来说,这次重构是非常成功的,代码质量、性能、可维护性、安全性都有了大幅提升,为后续的业务迭代打下了坚实的基础,团队的开发效率也提升了很多,再也不用面对一堆烂代码头疼了。
六、经验总结
最后,总结一下这次代码重构的经验,给大家一些参考:
- 重构之前,先搞清楚原代码的逻辑和问题:不要一上来就改,先花时间阅读原代码,理解业务逻辑,搞清楚有哪些问题,哪些是最痛的,哪些是最急需改的,制定好重构计划,有针对性地改,不要盲目重构。
- 先加测试,再重构:重构之前,先给核心功能加一些集成测试,保证重构之后的行为和原来的一致,有了测试保护,重构就放心多了,改完跑一遍测试,就知道有没有问题。如果原代码太烂,没法写单元测试,就先写黑盒的集成测试,从接口层面保证行为一致。
- 小步快跑,逐步重构,不要贪多:不要想着一下子把所有代码都重构完,那样风险太大,也容易半途而废。要小步快跑,一个模块一个模块地重构,每个模块重构完,测试通过,上线验证没问题,再重构下一个,每一步都可验证,风险可控。
- 保持行为一致,先重构再优化:重构的第一步是保持行为一致,也就是功能和原来的完全一样,先把结构理清楚,把代码写优雅,然后再做性能优化和功能优化,不要一边重构一边改逻辑,那样很容易出问题,也很难验证重构有没有问题。
- 分层和职责单一,是好代码的基础:不管什么项目,分层清晰,职责单一,都是好代码的基础,resolve/Controller只负责接收和响应,业务逻辑在Service,数据访问在Repository,不要把所有逻辑都写在一个函数里,那样迟早会变成烂代码。
- 解决重复代码,提高复用度:复制粘贴是烂代码的根源,看到重复的逻辑,就要想办法抽出来,做成公共的方法、Service、工具类,复用起来,改的时候只需要改一处,不会出现不一致,也减少了代码量。
- 性能优化要找对瓶颈,不要盲目优化:性能优化之前,先做性能分析,找到瓶颈在哪里,是N+1问题,还是慢SQL,还是缓存没做好,还是计算量大,针对性地优化,不要盲目优化,不然花了很多时间,效果却不明显。
- 规范先行,团队共识很重要:重构不是一个人的事情,要和团队达成共识,制定好代码规范、架构规范、命名规范,大家都按照统一的规范来,不然你重构完了,别人又写回烂代码了,白忙活。可以用代码检查工具、静态分析工具、Code Review来保证规范的执行。
- 重构是持续的,不是一次性的:代码重构不是一次性的事情,不是重构完一次就一劳永逸了,而是持续的,随着业务的发展,代码会不断变化,要不断地重构,不断地优化,保持代码的健康,不要等到烂得没法维护了才想起重构,那时候成本就很高了。
烂代码不可怕,可怕的是面对烂代码无动于衷,或者不知道怎么改。只要方法对,步骤对,烂代码也能重构成优雅的代码,而且重构的过程,也是自己技术能力提升的过程。希望大家都能写出优雅、易维护的代码,也都有勇气和能力去重构烂代码。
七、写在最后
GraphQL API代码重构:从烂代码到优雅代码。
以上就是我这次GraphQL API代码重构的经验分享,从原代码的问题,到重构的原则和目标,到重构后的架构,到关键问题的解决方案,到重构后的效果,到经验总结,做了一个比较全面的分享。
不管是GraphQL API,还是其他的项目,烂代码都是我们程序员经常会遇到的问题,很多人面对烂代码,要么抱怨,要么凑合着改,要么想着推倒重来,但是这些都不是最好的办法。最好的办法,是有计划、有步骤地逐步重构,在保证业务不中断的前提下,把烂代码一点点改造成优雅的代码,这才是真正体现程序员能力和价值的事情。
当然,重构不是目的,写出好维护、高质量的代码,支撑业务的发展,才是目的。所以,我们不仅要学会重构烂代码,更要在平时写代码的时候,就注重代码质量,注重架构设计,注重规范,尽量不要写出烂代码,从源头上避免烂代码的产生。
这次重构,也只是完成了第一阶段,后面还有很多优化要做,还有一些边缘模块要重构,代码优化是一个持续的过程,没有终点,只有不断地改进,不断地完善。
希望我的经验能给大家一些参考,也欢迎大家交流自己的重构经验,一起学习,一起进步,都能写出优雅、高质量、易维护的代码。
最后,用一句话结尾:"代码是写给人看的,只是顺便能在机器上运行。"愿我们都能写出让人看得懂、看得舒服的优雅代码,而不是只有自己能看懂、甚至自己过段时间都看不懂的烂代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录