朱晔的互联网架构实践心得S2E1:业务代码究竟难不难写?
注意,这是我的架构实践心得的第二季的系列文章,第一季有10篇你也可以回顾。 见https://www.cnblogs.com/lovecindywang/category/1296779.html
最近我一直在思考几个问题:
- 业务代码究竟难不难写?
- 一直开发业务代码是不是完全学不到东西?
- 5年+开发经验的老程序员的价值在哪里?
- 如何通过面试来区分业务代码开发的水平?
其实,这几个问题或多或少是相互关联的。有的时候大家也会自嘲说,“程序员接手的代码永远是烂摊子,然后自己继续在这个烂摊子上产出代码,留给又一波后人接手”。十几年来经历过十来个公司,我看了不少差的代码,也看了不少好的代码,自己产出过垃圾代码,也带领团队实现过一些自认为不错的代码。你可能会说,业务代码就是增删改查,和框架代码的难度不能比,完全是机械劳动,其实我觉得不完全是这样,甚至完全不是这样,我个人认为写出能跑的业务代码不难,但要写出好的业务代码其实是挺难的,更重要的是如果系统设计的足够好,在很长一段时间内系统的可维护性是可控的,只需要简单扩展即可,如果基础打的不够好,那么项目可能就是一次性项目,下面我列出业务系统我关注的一些点,你想想是不是有道理。
标准化
标准的项目结构
我自己非常注重搭建项目结构的起步过程,模块的划分、目录(包)的命名,我觉得非常重要,如果做的足够好,别人导入项目后可能只需要10分钟就可以大概了解结构了。
1、有些名词是约定俗成的,大家一眼就能看出是啥东西的,比如:
- controllers
- services
- configs
- utils
- commons
- jobs
比较重要的是确定先进行分类再分业务,还是先分业务再分类,在代码里混用这两种风格的结构就会很混乱:
- controllers
- order
- user
- jobs
- order
- user
- order
- services
- mappers
- user
- services
- mappers
对于直筒的三层架构的纯数据表驱动的代码我建议第一层是分类,第二层是业务功能:
- 看一眼controllers目录我们知道项目对外的Api能力
- 看一眼services目录我们知道项目的逻辑复杂度
- 看一眼mappers目录我们知道项目的表结构
对于有一些项目,不一定每一个逻辑都涉及到三层架构,数据流量比较复杂,我建议是按照业务功能先来分,下一层视情况也不一定完全是需要按照组件类型分二级目录,也可以是按功能来分:
- core
- storage
- service
- dispatcher
- engine
- context
- callback
- gateway
- handler
- notification
- sms
- push
对于这种目录结构一眼望去就能知道大概项目数据流和架构了,core对内,dispatcher做分发的感觉,callback是外部来的回调数据,notification是通知外部的数据流。这种数据流向复杂的项目,使用这种结构会比前一种合理的多,因为我们需要先关注数据流,而不是三层结构的层次,甚至对于core、dispatcher、notification我们知道其实是没有controller的。
2、有些名词可能就需要内部有一个统一,比如不同的层次面向数据库,面向业务,面向UI,面向RPC需要有不同的POJO,我们需要明确一套对应的命名,能明确就好,比如下面的这些POJO我们其实挺难分辨其用途的,需要进行规范,并且放置于匹配的目录结构中:
- CreateOrderRequest / CreateOrderResponse
- CreateOrderParam / CreateOrderDto
我们可以约定第一组用于服务本身访问外部(的Rpc服务也好,REST服务也好),第二组用于服务本身对外提供的Web Api,比如:
- controllers
OrderController
- queryOrder()
- createOrder()
QueryOrderParam
QueryOrderDto
CreateOrderParam
CreateOrderDto
- rpcs
- UserService
- login()
- register()
LoginRequest
LoginResponse
RegisterRequest
RegisterResponse
- UserService
- services
OrderService
OrderServiceImpl
OrderEntity
- storages
OrderMapper
OrderModel
总之,虽然可能10+人在维护相同的项目,目录结构的风格、命名、专用名词的使用一定要统一。
统一的框架
这个需要在开展项目之前明确下来,我见过有项目中同时使用了Spring MVC和Jersey做Web API,同时使用了Spring Scheduler和Quartz做任务调度。最好是项目开展前明确框架的版本并且搭建好项目脚手架,大概涉及:
- Web API / Web MVC
- Job Scheduler
- Micro Service
- Config Center
- Redis Client
- Data Access
- Entity Mapper
- MQ Client
当然,我们也可以独立出依赖管理的项目,专门由独立模块进行依赖版本管理。最差也要在Wiki上进行明确。
统一的API
如果项目涉及到对外提供API,那么非常有必要在初期就规范API的框架定义,涉及到:
- 包装类
Result<T>
的定义(见过一个项目用了三种包装类的)以及遇到错误的情况下,Http状态码的体现
比如这样的包装类格式:
public class ApiResult<T> {
boolean success;
String code;
String message;
String path;
long time;
T data;
}
我们可以这么和客户端的开发来明确:
1、即使遇到错误,Http状态码还是200,Http状态码如果是500或是404的话那一定是网关层面的错误了,这个错误不是后端服务返回的
2、在Http状态码还是200的时候代表收到了后端的返回,前端去按照ApiResult以Json格式反序列化Http Body的报文
3、然后查看success(如果没success也行,我们可以约定code是200就是成功),如果是success代表后端服务成功处理了请求,如果不成功,则根据后端给的错误代码映射表根据code进行处理或直接提示message中的内容。注意,这里的success只代表后端是否成功处理了请求,不代表请求代表的业务逻辑是否成功处理。举一个例子,如果这个请求是异步支付请求,那么successtrue代表前端给的参数都正确,后端正确接受了支付申请,不代表支付成功
4、在successtrue的情况下再去解析data中的内容,拿取客户端需要的信息,还是前面的例子,data里可以是{"orderStatus":"PROCESSING", "orderId":"1234"}
,这个才是真正业务逻辑的数据和状态,success并不代表订单支付操作就是成功的,也可能是处理中的状态
所以这是几个层次的事情,Http Status->ApiResult.status->ApiResult.data.orderStatus
-
加解密规范和签名规范
Api的加密解密以及签名最好在设计的时候就考虑进去,而且要仔细斟酌,否则以后很难变更,特别Api的使用方是客户端的情况,客户端很难轻易强更。如果做SAAS服务,建议参考大厂的规范,比如亚马逊AWS的API规范或阿里云API的规范,不建议自己造轮子,大厂做的API规范都是经过安全方面的专家深度思考的。 -
版本管理规范(比如Url path路由还是Http header路由)
如果使用了老版本的话,是否需要在返回内容中提示新版本的Url、版本号、老版本最后维护时间呢?这个就不展开了
所以统一Api这个事情不仅仅是Api的格式还涉及到安全处理、版本处理、客户端操作方式等等。对于一些需要服务端驱动客户端的业务(UI逻辑动态)来说,我们可以定义一套更复杂的ApiResult,让服务端告知客户端这个时候应该是提示还是跳转还是返回等等。
统一的源码工作模式
现在大家都使用Git,分支如何管理每一个公司(在Gitflow的基础上)都会略有不同,也需要和大家明确:
- 分支的定义(master、develop、release、hotfix、feature)
- 分支命名规范
- checkout、merge request流程
- 提测流程
- 上线流程
- Hotfix流程
别小这个,虽然这个和代码质量和架构无关,但是梳理清楚可以:
- 提高开发和测试的工作效率,人多也乱
- 减少甚至杜绝代码管理导致的线上事故
- 让项目管理者和架构师可以明确什么代码现在在哪里
- 方便运维处理发布和回滚
- 让项目的开发可以灵活适应多变的需求
容错性
见过一些项目在实现业务代码的时候是不考虑任何异常处理、事务处理、锁处理的,在流量小无并发的情况下,这些项目不容易爆发出严重的问题,基本能用。但是对于高并发的项目或将来可能会高速发展的项目,如果不考虑这些问题会死的很难看。
我们来想一下,如果现在在设计一个订单服务,如果因为网络问题、并发问题导致数据错乱、服务中断的可能在千分之一,如果一个业务一天只有1000次请求,1天才遇到这样1次问题,即使遇到了问题用户也不一定会来反馈,即使来反馈往往客服也能通过后台取消订单等操作来处理,这个问题不会爆发出来,如果一天的单量是1000万,那么每天可能就会有10000单异常的订单,这个可能就超过了客服的处理能力了。
很少有项目真正100%完全做好了所有细节,只不过往往是因为量小得不到大家的重视罢了。但我们想一下,如果遇到数据库或中间件级别大规模故障的情况下,如果一致性处理的不好,那么数据库恢复后可能会留下一大堆异常数据需要修复,如果处理的好,业务数据不会错乱,数据库恢复后业务马上可以恢复。在遇到事故的时候,系统这方面的设计功力就体现出来了。
一致性处理
在实现代码的时候需要考虑如下事情:
- 本地事务处理:见过一些代码完全不考虑事务,或者是只是知其然使用
@Transactional
,但是方法内部完全catch了所有异常的情况- 事务包含的方法块
- 嵌套事务、事务传播
- 什么时候遇到什么异常应该回滚
@Transactional
是否真正生效了?
- 外部服务调用的事务问题
- 调用外部服务出现异常的时候,本地事务怎么处理
- 调用的外部服务是否允许重试(幂等调用)
- 调用外部服务出现未知结果后,怎么进行补偿
- 补偿是否有上限,是否存在死信数据卡死补偿的情况?
- 如果有2+外部服务连同本地数据库存储都需要有事务性,怎么实现
- 数据重复和顺序问题
- 先本地事务提交还是先调用外部接口(如果先调用外部接口,可能会遇到外部回调的时候本地事务还没提交找不到数据的情况)
- 从MQ收到的消息顺序问题怎么解决?
- 重新入MQ的延迟消息或重试消息乱序是否会有问题?
- 对外提供的Api或回调方法是否支持幂等?
- 锁的问题
- 哪个层面做锁?服务层分布式锁还是数据库层面锁?
- 乐观锁还是悲观锁?
- 你确信你的Redis锁方案是可靠的吗?
- 你是否知道多少请求再排队等待,又是为什么?
这些要做好真的很难,每一步都需要认证考虑,但是很遗憾见过的很多具有复杂业务的代码,在Service中一连串调用了N个外部服务进行写操作,方法内也实现了N个表的写操作,即不考虑外部服务的事务和补偿问题,本地也没有事务控制,出了错只是打印了堆栈然后客户端看到的是一个服务器忙。
异常处理
异常处理不仅仅是狭义上遇到了Exception怎么去处理,还有各种业务逻辑遇到错误的时候我们怎么去处理。
就拿记日志这一件事情来说:
- WARN和ERROR的选择需要好好考虑,WARN一般我倾向于记录可自恢复但值得关注的错误,ERROR代表了不能自己恢复的错误。对于业务处理遇到问题用ERROR不合理,对于catch到了异常也不是全用ERROR。
- 记录哪些信息,最好打印一定的上下文(用户Id、订单Id、外部传来的关键数据)而不仅仅是打印线程栈。
- 记录了上下问信息,是否要考虑日志脱敏问题?可以在框架层面实现,比如自定义实现logback的
ClassicConverter
我们知道catch到了异常或遇到了业务错误,我们除了记录日志还有很多选择,也需要认真考虑什么时候应该做什么:
- 直接返回
- 抛出异常
- 重试处理
- 恢复处理
- 熔断处理
- 降级处理
- 甚至关闭业务
这又涉及到了弹力设计的话题,我们的系统往往会对接各种外部服务、Api,大部分服务都不会有SLA,即使有在大并发下我们也需要考虑外部服务不可用对自己的影响,会不会把自己拖死。我们总是希望:
- 尽可能以小的代价通过尝试让业务可以完成
- 如果外部服务基本不可用,而我们又同步调用外部服务的话,我们需要进行自我保护直接熔断,否则在持续的并发的情况下自己就会垮了
- 如果外部服务特别重要,我们往往会考虑引入多个同类型的服务,根据价格、服务标准做路由,在出现问题的时候自动降级
架构设计
表
表的设计和Api的定义类似,属于那种开头没有开好,以后改变需要花10x代价的,我知道,一开始在业务不明确的情况下,设计出良好的一步到位的表结构很困难,但是至少我们可以做的是有一个好的标准:
- 统一的附加字段,create_time,update_time,version等
- 表的命名标准,比如
[domain]_[tablename]_[tabletype]
- 字段类型、长度标准
- 虽没有外键,但是外表关联字段和主表字段的命名标准
- _id还是_no等字段命名的区别
除了标准,尽可能需要结合业务以及业务可能的扩展思考一下:
- 1:N的可能性,是有1就足够了,还是一开始就要设计1:N的层次关系
- 如果表字段可能会很多,业务变化多,是否考虑1:1甚至1:N的扩展表,把扩展字段从主表分开
- 表的领域职责,表可能也会分上游、中游、下游,什么字段应该在哪里太重要了(我觉得表的领域相当于之前提到的项目结构中的包的分类,这个最好一开始定义清楚)
- 关联表字段冗余冗余到什么程度,冗余字段的同步
- 枚举的维护方式,是否考虑字典表?
对于表结构文档,我觉得列出字段类型、长度、说明是不够的,如果能结合业务代码梳理清楚下面这些,那这个文档就是真正有价值的表结构文档:
- 记录由哪个业务模块创建
- 数据重要程度
- 数据归档方案
- 字段数据源头
- 字段会由谁更改
- 字段可能会在哪里缓存
设计模式
我想90%的业务项目都是所谓的三层结构,Web层处理参数调用Service层做Db层的聚合,Db层基本就是代码生成或Orm框架补充少量的手写SQL。对于这样的项目,大部分人认为是没有设计的,也不需要设计。我认为那是因为没有好好思考:
- 在我们写下if-else的时候,我们就可以考虑使用抽象类+具体实现类的方式来替代
- 在实现层次化业务处理的时候,就可以考虑使用Filter或职责链模式来实现
- 在封装外部Api的时候与其每次都写一套解析逻辑,我们是否考虑进行动态封装呢
- 在数据改变后我们要记录改版轨迹,与其复制粘贴是否考虑过发布订阅模式
说白了就是利用各种设计模式和OO思想,来尽可能在业务变化需要扩展的时候:
- 只是新增代码而不是修改代码
- 尽可能减少重复代码复制粘贴
- 尽可能让同类代码都呆在一起
- 尽可能让直筒式的代码有层次
往大了说
在一个公司层面,如果有几十个,几百个业务项目,我们看这个公司的技术水平到了什么程度,我个人认为不仅仅是用了什么新技术,而是是否:
- 具有统一的开发、服务框架
- 具有统一的运维、监控、中间件、测试平台
- 具有清晰的纵向领域划分
- 具有清晰的横向基础平台服务和基础业务服务
- 具有统一的代码工作模式
最简单的一个例子,一个业务从前到后跨10个事业部,100个服务,实现灰度测试,想想这件事情有多难?整个公司层面要实现步调一致的这些东西还确实很难,不仅仅是技术能力的体现,没有良好的组织架构,人心不齐,恐怕这些无法实现,实现了也无法推广,推广了也无法持续……当然,这些已经超出个人能做的了,作为程序员的我们应该从我做起,认真考虑前面提到的这些问题,至少在项目内部做良好的设计。
再来看看文首的问题,你看,虽然只是写业务代码,如果要写的足够好,必须要了解设计模式、理解各种弹力设计、理解事务、熟悉框架、了解中间件原理,怎么可能学不到东西,要实现健壮的业务代码,其实很难,要考虑的东西太多了,如果说写框架我们需要考虑不同的使用方和使用环境,这很难,写业务代码我们要考虑到千奇百怪的使用行为,要考虑到层次不起的对接方,这不比写框架简单。对于5年+经验丰富的程序员应当有能力开一个好头,或者说愿意在老代码上去做一些改变,否则你的价值在哪里呢?
本文只是展开了一些想到的内容,每一点都有很多东西可以写,也没时间一些子展开说太多,这些细节留在今后的文章慢慢展开了。