2020-08-25

如何设计一个支付系统账户体系?
面试中遇到相关设计题目,且没有具体要求,只有一个题目的时候,一般情况下,反问面试官tps,pv等信息,确定系统体量,便于针对性设计。但实际项目中,需求难免不太确定,设计上采用最小原则,不要一开始追求大而全,可以等面试官发问后,再不断迭代。
如何设计一个支付系统账户体系,从业务上看,需要准确记录用户的资金变动过程和结果。因此设计一个简单财务账户至少要能包括两个部分:账户余额和账户流水。必须保障记录的准确,完备,不可变更。账户就是余额加流水,支付行业对数据的错误是零容忍的。
从cap理论上说,整个系统考虑性能,准实时记账,实现最终一致性即可。
对于一个工程师来说,如果要追求长远发展,你就不能一直只把自己放在执行者的角色,不能只是一个代码实现者,你还要有独立负责一个系统的能力,能端到端(end to end)开发一个完整的系统。这其中的工作就包括:前期的需求沟通分析、中期的代码设计实现、后期的系统上线维护等。
需求分析
要有产品思维,一方面,学会借鉴,另一方面,借助于搜索引擎。基于这两个输入,我们基本上就大致能摸清楚账户系统该如何设计了。除此之外,我们还要充分了解自己公司的产品,将借鉴来的东西糅合在我们自己的产品中,并做适当的微创新。
笼统地来讲,账户系统无外乎就两个大的功能点,一个是余额查询,另一个是交易明细。此处的账户资金只是虚拟的资金信息。
上面给出的只是非常笼统、粗糙的功能需求。在实际情况中,肯定还有一些业务细节需要考虑。对于这些业务细节,还是那句话,闷头拍脑袋想是想不全面的。以防遗漏,我们还是要有方法可寻。那除了刚刚讲的“借鉴”的思路之外,还可以通过产品的线框图、用户用例(user case )或者叫用户故事(user story)来细化业务流程,挖掘一些比较细节的、不容易想到的功能点。
用户用例有点儿类似我们后面要讲的单元测试用例。它侧重情景化,其实就是模拟用户如何使用我们的产品,描述用户在一个特定的应用场景里的一个完整的业务操作流程。所以,它包含更多的细节,且更加容易被人理解。通过上面方法,罗列需求如下。
账户的基本信息:
账户号
账户类型
余额
币种
账户状态
开户时间

还有权限控制
是否允许充值
是否允许提现
是否允许余额为负
账户的流水信息
何时存入资金
何时取出资金
何时发生账户余额冻结

系统设计
面向对象设计聚焦在代码层面(主要是针对类),那系统设计就是聚焦在架构层面(主要是针对模块),两者有很多相似之处。很多设计原则和思想不仅仅可以应用到代码设计中,还能用到架构设计中。
第一种划分方式,余额增加减少不划分到账户系统中,而是放到更上层的业务系统中。这样账户系统就会变得非常简单,只需要负责增加余额、减少余额、查询余额、查询余额明细等这几个工作。
第二种划分方式是:余额赚取渠道及消费渠道及余额明细的管理和维护,分散在各个相关业务系统中。
第三种划分方式是:所有的功能都划分到账户系统中。
怎么判断哪种模块划分合理呢?实际上,我们可以反过来通过看它是否符合高内聚、低耦合特性来判断。如果一个功能的修改或添加,经常要跨团队、跨项目、跨系统才能完成,那说明模块划分的不够合理,职责不够清晰,耦合过于严重。
除此之外,为了避免业务知识的耦合,让下层系统更加通用,一般来讲,我们不希望下层系统调用上层系统。
所以,综合考虑,我们更倾向于第一种和第二种模块划分方式。但是,不管选择这两种中的哪一种,账户系统所负责的工作是一样的,只包含余额的增、减、查询,以及余额明细的记录和查询。
2. 设计模块与模块之间的交互关系
在面向对象设计中,类设计好之后,我们需要设计类之间的交互关系。类比到系统设计,系统职责划分好之后,接下来就是设计系统之间的交互,也就是确定有哪些系统跟积分系统之间有交互以及如何进行交互。
比较常见的系统之间的交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。第一种方式简单直接,第二种方式的解耦效果更好。
除此之外,上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。
3. 设计模块的接口、数据库、业务模型
刚刚讲了模块的功能划分,模块之间的交互的设计,现在,我们再来看,模块本身如何来设计。实际上,业务系统本身的设计无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计(也就是业务逻辑)。
数据库和接口的设计非常重要,一旦设计好并投入使用之后,这两部分都不能轻易改动。改动数据库表结构,需要涉及数据的迁移和适配;改动接口,需要推动接口的使用者作相应的代码修改。这两种情况,即便是微小的改动,执行起来都会非常麻烦。因此,我们在设计接口和数据库的时候,一定要多花点心思和时间,切不可过于随意。相反,业务逻辑代码侧重内部实现,不涉及被外部依赖的接口,也不包含持久化的数据,所以对改动的容忍性更大。
针对账户系统,我们先来看如何设计数据库。
待续
接下来是账户系统的接口设计。
在这里插入图片描述

接口设计要符合单一职责原则,粒度越小通用性就越好。但是,接口粒度太小也会带来一些问题。比如,一个功能的实现要调用多个小接口,一方面如果接口调用走网络(特别是公网),多次远程接口调用会影响性能;另一方面,本该在一个接口中完成的原子操作,现在分拆成多个小接口来完成,就可能会涉及分布式事务的数据一致性问题(一个接口执行成功了,但另一个接口执行失败了)。所以,为了兼顾易用性和性能,我们可以借鉴 facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。

最后再来看业务模型的设计。
从代码实现角度来说,大部分业务系统的开发都可以分为 Controller、Service、Repository 三层。Controller 层负责接口暴露,Repository 层负责数据读写,Service 层负责核心业务逻辑,也就是这里说的业务模型。
除此之外,常见的两种开发模式,基于贫血模型的传统开发模式和基于充血模型的 DDD 开发模式。前者是一种面向过程的编程风格,后者是一种面向对象的编程风格。不管是 DDD 还是 OOP,高级开发模式的存在一般都是为了应对复杂系统,应对系统的复杂性。
对于我们要开发的账户系统来说,因为业务相对比较简单,所以,选择简单的基于贫血模型的传统开发模式就足够了。从开发的角度来说,我们可以把账户系统作为一个独立的项目,来独立开发,也可以跟其他业务代码(比如业务系统)放到同一个项目中进行开发。从运维的角度来说,我们可以将它跟其他业务一块部署,也可以作为一个微服务独立部署。具体选择哪种开发和部署方式,我们可以参考公司当前的技术架构来决定。
实际上,账户系统业务比较简单,代码量也不多,我更倾向于将它跟业务系统放到一个项目中开发部署。只要我们做好代码的模块化和解耦,让账户相关的业务代码跟其他业务代码之间边界清晰,没有太多耦合,后期如果需要将它拆分成独立的项目来开发部署,那也并不困难。
为什么要分 MVC 三层开发?
我们刚刚提到,大部分业务系统的开发都可以分为三层:Contoller 层、Service 层、Repository 层。对于这种分层方式,我相信大部分人都很认同,甚至成为了一种开发习惯,但你有没有想过,为什么我们要分层开发?很多业务都比较简单,一层代码搞定所有的数据读取、业务逻辑、接口暴露不好吗?你可以把它作为一道面试题,试着自己思考下,然后再看我下面的讲解。对于这个问题,我总结了以下几点原因。

  1. 分层能起到代码复用的作用同一个 Repository 可能会被多个 Service 来调用,同一个 Service 可能会被多个 Controller 调用。比如,UserService 中的 getUserById() 接口封装了通过 ID 获取用户信息的逻辑,这部分逻辑可能会被 UserController 和 AdminController 等多个 Controller 使用。如果没有 Service 层,每个 Controller 都要重复实现这部分逻辑,显然会违反 DRY 原则。
  2. 分层能起到隔离变化的作用分层体现了一种抽象和封装的设计思想。比如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接口。基于接口而非实现编程的设计思想,Service 层使用 Repository 层提供的接口,并不关心其底层依赖的是哪种具体的数据库。当我们需要替换数据库的时候,比如从 MySQL 到 Oracle,从 Oracle 到 Redis,只需要改动 Repository 层的代码,Service 层的代码完全不需要修改。除此之外,Controller、Service、Repository 三层代码的稳定程度不同、引起变化的原因不同,所以分成三层来组织代码,能有效地隔离变化。比如,Repository 层基于数据库表,而数据库表改动的可能性很小,所以 Repository 层的代码最稳定,而 Controller 层提供适配给外部使用的接口,代码经常会变动。分层之后,Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层。
  3. 分层能起到隔离关注点的作用Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑。三层之间的关注点不同,分层之后,职责分明,更加符合单一职责原则,代码的内聚性更好。
  4. 分层能提高代码的可测试性后面讲单元测试的时候,我们会讲到,单元测试不依赖不可控的外部组件,比如数据库。分层之后,Repsitory 层的代码通过依赖注入的方式供 Service 层使用,当要测试包含核心业务逻辑的 Service 层代码的时候,我们可以用 mock 的数据源替代真实的数据库,注入到 Service 层代码中。代码的可测试性和单元测试我们后面会讲到,这里你稍微了解即可。
  5. 分层能应对系统的复杂性所有的代码都放到一个类中,那这个类的代码就会因为需求的迭代而无限膨胀。我们知道,当一个类或一个函数的代码过多之后,可读性、可维护性就会变差。那我们就要想办法拆分。拆分有垂直和水平两个方向。水平方向基于业务来做拆分,就是模块化;垂直方向基于流程来做拆分,就是这里说的分层。还是那句话,不管是分层、模块化,还是 OOP、DDD,以及各种设计模式、原则和思想,都是为了应对复杂系统,应对系统的复杂性。对于简单系统来说,其实是发挥不了作用的,就是俗话说的“杀鸡焉用牛刀”。BO、VO、Entity 存在的意义是什么?在前面的章节中,我们提到,针对 Controller、Service、Repository 三层,每层都会定义相应的数据对象,它们分别是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在实际的开发中,VO、BO、Entity 可能存在大量的重复字段,甚至三者包含的字段完全一样。在开发的过程中,我们经常需要重复定义三个几乎一样的类,显然是一种重复劳动。相对于每层定义各自的数据对象来说,是不是定义一个公共的数据对象更好些呢?实际上,我更加推荐每层都定义各自的数据对象这种设计思路,主要有以下 3 个方面的原因。VO、BO、Entity 并非完全一样。比如,我们可以在 UserEntity、UserBo 中定义 Password 字段,但显然不能在 UserVo 中定义 Password 字段,否则就会将用户的密码暴露出去。VO、BO、Entity 三个类虽然代码重复,但功能语义不重复,从职责上讲是不一样的。所以,也并不能算违背 DRY 原则。在前面讲到 DRY 原则的时候,针对这种情况,如果合并为同一个类,那也会存在后期因为需求的变化而需要再拆分的问题。为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护自己的数据对象,层与层之间通过接口交互。数据从下一层传递到上一层的时候,将下一层的数据对象转化成上一层的数据对象,再继续处理。虽然这样的设计稍微有些繁琐,每层都需要定义各自的数据对象,需要做数据对象之间的转化,但是分层清晰。对于非常大的项目来说,结构清晰是第一位的!既然 VO、BO、Entity 不能合并,那如何解决代码重复的问题呢?从设计的角度来说,VO、BO、Entity 的设计思路并不违反 DRY 原则,为了分层清晰、减少耦合,多维护几个类的成本也并不是不能接受的。但是,如果你真的有代码洁癖,对于代码重复的问题,我们也有一些办法来解决。我们前面讲到,继承可以解决代码重复问题。我们可以将公共的字段定义在父类中,让 VO、BO、Entity 都继承这个父类,各自只定义特有的字段。因为这里的继承层次很浅,也不复杂,所以使用继承并不会影响代码的可读性和可维护性。后期如果因为业务的需要,有些字段需要从父类移动到子类,或者从子类提取到父类,代码改起来也并不复杂。前面在讲“多用组合,少用继承”设计思想的时候,我们提到,组合也可以解决代码重复的问题,所以,这里我们还可以将公共的字段抽取到公共的类中,VO、BO、Entity 通过组合关系来复用这个类的代码。代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢?当下一层的数据通过接口调用传递到上一层之后,我们需要将它转化成上一层对应的数据对象类型。比如,Service 层从 Repository 层获取的 Entity 之后,将其转化成 BO,再继续业务逻辑的处理。所以,整个开发的过程会涉及“Entity 到 BO”和“BO 到 VO”这两种转化。最简单的转化方式是手动复制。自己写代码在两个对象之间,一个字段一个字段的赋值。但这样的做法显然是没有技术含量的低级劳动。Java 中提供了多种数据对象转化工具,比如 BeanUtils、Dozer 等,可以大大简化繁琐的对象转化工作。如果你是用其他编程语言来做开发,也可以借鉴 Java 这些工具类的设计思路,自己在项目中实现对象转化工具类。VO、BO、Entity 都是基于贫血模型的,而且为了兼容框架或开发库(比如 MyBatis、Dozer、BeanUtils),我们还需要定义每个字段的 set 方法。这些都违背 OOP 的封装特性,会导致数据被随意修改。那到底该怎么办好呢?前面我们也提到过,Entity 和 VO 的生命周期是有限的,都仅限在本层范围内。而对应的 Repository 层和 Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫血、定义每个字段的 set 方法,相对来说也是安全的。不过,Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。但是,设计的问题本身就没有最优解,只有权衡。为了使用方便,我们只能做一些妥协,放弃 BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值