朱晔的互联网架构实践心得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
  • 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只代表后端是否成功处理了请求,不代表请求代表的业务逻辑是否成功处理。举一个例子,如果这个请求是异步支付请求,那么success==true代表前端给的参数都正确,后端正确接受了支付申请,不代表支付成功
4、在success==true的情况下再去解析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年+经验丰富的程序员应当有能力开一个好头,或者说愿意在老代码上去做一些改变,否则你的价值在哪里呢?

本文只是展开了一些想到的内容,每一点都有很多东西可以写,也没时间一些子展开说太多,这些细节留在今后的文章慢慢展开了。

posted @ 2019-01-05 16:02 lovecindywang 阅读(...) 评论(...) 编辑 收藏