
事实上,从某种程度上说这种说法并没错,我们甚至还可以进一步去挖掘一下其背后更深层次的本质:软件就是一个I/O系统,后端开发就是对数据的I/O处理而已,只需能把数据存起来再放出去即可,的确说不上什么高端可言。此外,在国内的大多数程序员所从事的细分行业只能说是“应用软件开发”或者“业务软件开发”,说白了这些成天处理业务逻辑的软件都没什么难的,就是一些低级逻辑而已,这也是为什么很多非计算机专业的学生都可以成功转行为程序员的原因(之一)。
然而,同样一个业务功能,分别让两个工作经验不同的程序员去实现,他们的代码可能完全不一样。有时,经验少的程序员写100行代码就能实现的一个功能,老程序员却需要写500行,因为后者考虑到了对各种边界条件的处理,缓存的使用以及对性能的顾及等。又有时,经验少的程序员写了500行代码实现的一个功能,老程序员只花了100行就实现了,因为后者使用了更加优秀的算法或者采用了能使代码变得更加简洁的工具和原则等。
李书福说:“造车就是一个沙发加四个车轮”。他说的没错,因为这是汽车的某种本质。然而,真正要造好一台汽车,却需要考虑舒适性、加速性、NVH、操控性、通过性等诸多方面的因素。软件也一样,简单的CRUD操作纵然能够满足基本的I/O需求,但是在具体落地时我们还要考虑很多原则和因素以让人能够更好地掌控软件系统,其中包含但不限于:高内聚低耦合、关注点分离、依赖倒置、非功能性需求等等。这里所涉及到的一个基本命题是:软件代码首先是给人脑看的,其次才是给电脑执行的。
在本文中,我们将以一个真实的软件项目 —— 码如云(https://www.mryqr.com)为例,系统性的讲解后端在处理请求的过程中所需要顾及的方方面面,你会发现后端开发绝非单纯的CRUD这么简单。
码如云是一个基于二维码的一物一码管理平台,技术上是一个无代码平台,全程采用DDD思想进行开发,对DDD感兴趣的读者可以参考笔者的DDD落地系列文章。
接下来,我们将围绕以下业务用例展开讨论:在码如云中,成员(Member)可以更新自己的手机号码,但如果所使用的手机号已经被他人占用,则禁止更新。
整个请求处理的流程如下图所示:

概括来看,整个请求处理流程和我们通常的实践并没有太大的区别。首先,请求到达MemberController,这是Spring MVC处理请求的第一站;然后MemberController调用MemberCommandService完成该业务用例,调用时传入请求数据对象ChangeMyMobileCommand,这里的MemberCommandService在DDD中被称为应用服务;MemberCommandService通过MemberRepository获取到对应的Member对象,再通过MemberDomainService(在DDD中被称为领域服务)完成对Member的手机号更新;最后MemberCommandService 调用MemberRepository.save()将更新后的Member对象保存到数据库。
在整个请求处理的过程中,首先通过MemberController接收请求:
- @PutMapping(value = "/me/mobile")
- @ResponseStatus(OK)
- public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
- @AuthenticationPrincipal User user) {
- memberCommandService.changeMyMobile(command, user);
- }
这里,MemberController.changeMyMobile()方法一共只有5行代码,可不要小瞧这5行代码,在实际编码时我们却需要考虑多个方面的因素:
MemberController只起到了简单的代理作用,也即把请求代理给应用服务MemberCommandService。MemberController采用了REST风格的URL,通过HTTP的PUT方法完成对mobile资源(me/mobile)的更新,更多关于REST URL的内容,请参考这里。@ResponseStatus(OK)完成(Spring MVC默认返回的即是200)。ChangeMyMobileCommand需要加上@Valid以做数据验证,否则后续对ChangeMyMobileCommand中的各种JSR-303验证将失效。MemberController需要返回void,也即不返回任何数据,这是因为基于CQRS的原则,任何写数据的操作不能同时查询数据,反之亦然。命令对象ChangeMyMobileCommand用于封装请求数据,之所以称之为命令(Command)是因为一个请求就像外界向软件系统发起了一次命令一样,这里的Command正是来自于CQRS中的“C”。
- @Value
- @Builder
- @AllArgsConstructor(access = PRIVATE)
- public class ChangeMyMobileCommand implements Command {
- @Mobile
- @NotBlank
- private final String mobile;
-
- @NotBlank
- @VerificationCode
- private final String verification;
-
- @NotBlank
- @Password
- private final String password;
-
- @Override
- public void correctAndValidate() {
- //用于JSR-303无法完成的验证逻辑,但是又不能包含业务逻辑
- }
- }
源码出处:com/mryqr/core/member/command/ChangeMyMobileCommand.java
ChangeMyMobileCommand 对象主要充当数据容器的作用,其中一个比较重要的任务是完成数据的初步验证。具体实践时需要考虑以下几个方面:
@Value、@Builder和@AllArgsConstructor(access = PRIVATE)达到此目的。mobile字段中的@NotBlank,而更复杂的验证则需要自行实现JSR-303的ConstraintValidator接口,比如mobile字段的@Mobile注解。List和Set等,需要对这些字段做非null检查(@NotNull),以消除后续代码在引用这些字段时有可能的空指针异常NullPointerException。Command接口,通过实现该接口的correctAndValidate()方法完成验证目的。@Size注解对其长度进行限制,除非其他注解中已经包含了此限制。应用服务(ApplicationService或者CommandService)是领域模型的门面,任何对领域模型的请求都需要通过应用服务中的公有方法完成。更多关于应用服务的讲解,请参考我们DDD文章系列中的这一篇。
- @Transactional
- public void changeMyMobile(ChangeMyMobileCommand command, User user) {
- mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);
-
- String mobile = command.getMobile();
- verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);
-
- Member member = memberRepository.byId(user.getMemberId());
- memberDomainService.changeMyMobile(member, mobile, command.getPassword());
- memberRepository.save(member);
- log.info("Mobile changed by member[{}].", member.getId());
- }
源码出处:com/mryqr/core/member/command/MemberCommandService.java
在DDD中,应用服务应该是很薄的一层,因为它不能包含业务逻辑,而主要是起协调的作用,另外事务边界、鉴权等操作也会放在应用服务中。在实现时,应该考虑以下几个方面:
MemberCommandService 中完成,而应该放到领域模型中。通常来说,应用服务遵循请求处理“三部曲”原则:(1)获取需要处理的领域对象(本例中的Member),(2)对领域对象进行处理(memberDomainService.changeMyMobile()),(3)将更新后的领域对象保存回数据库(memberRepository.save())。@Transactional注解应该打在应用服务的公用方法上。@Transactional 则是来自于Spring的,不过总的原则是不变的,即应用服务(以及其所包围着的领域模型)尽量少地依赖于技术框架。mryRateLimiter ),限流处理原本可以放到技术框架中统一处理的,不过由于码如云是一个SaaS软件,需要对不同的租户单独限流,因此我们将其放在了应用服务这一层。资源库(Repository)的、可以认为是对数据库的封装和抽象,有些类似于DAO(Data Access Object),不过它们最大的区别是资源库是与DDD中的聚合根一一对应的,只有聚合根对象才“配得上”拥有资源库,而DAO则没有此限制。更多关于资源库的内容,可以参考这里。
- public interface MemberRepository {
- boolean existsByMobile(String mobile);
- Member byId(String id);
- Optional
byIdOptional(String id); - Member byIdAndCheckTenantShip(String id, User user);
- boolean exists(String arId);
- void save(Member member);
- void delete(Member member);
- }
在实现资源库时,应该考虑以下几个方面:
domain分包下,而实现类应该放到infrastructure分包下,这也意味着,资源库的实现是“可插拔”的,即如果将来要从MySQL迁移到MongoDB,那么只需要新添加一个基于MongoDB的资源库实现类即可,其他地方可以不变。与应用服务不同的是,领域服务(DomainService)属于领域模型的一部分,专门用于处理业务逻辑,通常被应用服务所调用。在本例中,我们使用MemberDomainService 对“手机号是否已经被占用”进行检查:
- public void changeMyMobile(Member member, String newMobile, String password) {
- if (!mryPasswordEncoder.matches(password, member.getPassword())) {
- throw new MryException(PASSWORD_NOT_MATCH, "修改手机号失败,密码不正确。", "memberId", member.getId());
- }
-
- if (Objects.equals(member.getMobile(), newMobile)) {
- return;
- }
-
- if (memberRepository.existsByMobile(newMobile)) {
- throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手机号失败,手机号对应成员已存在。",
- mapOf("mobile", newMobile, "memberId", member.getId()));
- }
-
- member.changeMobile(newMobile, member.toUser());
- }
在实践时,使用领域服务应该考虑到以下几个方面:
Member)的操作,光凭当事的Member是无法做到这一点的,此外这种检查有属于业务逻辑的一部分,因此我们创建一种可以处理业务逻辑的服务(Service)类来解决,这个服务类即是领域服务。在很多项中,应用服务和领域服务揉杂在一起,功能倒是实现了,但是各组件之间的耦合也加深了,导致的结果是软件在未来的演进中将变得越来越复杂,越来越困难。MemberDomainService 并不调用memberRepository.save(member)来保存Member,而是由应用服务MemberCommandService负责完成。这样做的好处是将领域服务建模为一个仅仅操作领域模型的“存在”,使其职责更加的单一化。领域对象(Domain Object)是业务逻辑的主要载体,同时包含了业务数据和业务行为。在本例中,Member对象则是一个典型的领域对象,在DDD中,Member也被称为聚合根对象。Member对象实现修改手机号的代码如下:
- public void changeMobile(String mobile, User user) {
- if (Objects.equals(this.mobile, mobile)) {
- return;
- }
-
- this.mobile = mobile;
- this.mobileIdentified = true;
- raiseEvent(new MobileChangedEvent(this.getId(), mobile));
- }
在实现领域对象时,应该考虑以下几个方面:
Member标记为“手机号已记录”状态(mobileIdentified ),因此对mobileIdentified 的修改应该与对mobile的修改放在同一个chagneMyMobile()方法中。在DDD中,这也称为不变条件(Invariants)。raiseEvent(new MobileChangedEvent(this.getId(), mobile));更多关于领域事件的内容,请参考这里。Member.changeMobile()方法是个写操作,不能返回任何数据。在文本中我们看到,哪怕是一个诸如“用户修改手机号”这样简单的需求,在整个实现过程中需要考虑的点也达到了将近30个,真实情况只会多不会少,比如我们可能还需要考虑性能、缓存和认证等众多非功能性需求等。因此,后端开发绝非CRUD这么简单,而是需要将诸多因素考虑在内的一个系统性工程,还是那句话,有讲究的编程并不是一件易事。