后端開(kāi)發(fā)就是CRUD?沒(méi)那么簡(jiǎn)單!(后端開(kāi)發(fā)是啥意思)
作為一個(gè)后端開(kāi)發(fā)者,不時(shí)都能聽(tīng)到這么一種論調(diào):后端開(kāi)發(fā)沒(méi)什么技術(shù)含量,就是CRUD而已。此時(shí),我一般會(huì)嘴角抿抿,心里呵呵。
事實(shí)上,從某種程度上說(shuō)這種說(shuō)法并沒(méi)錯(cuò),我們甚至還可以進(jìn)一步去挖掘一下其背后更深層次的本質(zhì):軟件就是一個(gè)I/O系統(tǒng),后端開(kāi)發(fā)就是對(duì)數(shù)據(jù)的I/O處理而已,只需能把數(shù)據(jù)存起來(lái)再放出去即可,的確說(shuō)不上什么高端可言。此外,在國(guó)內(nèi)的大多數(shù)程序員所從事的細(xì)分行業(yè)只能說(shuō)是“應(yīng)用軟件開(kāi)發(fā)”或者“業(yè)務(wù)軟件開(kāi)發(fā)”,說(shuō)白了這些成天處理業(yè)務(wù)邏輯的軟件都沒(méi)什么難的,就是一些低級(jí)邏輯而已,這也是為什么很多非計(jì)算機(jī)專(zhuān)業(yè)的學(xué)生都可以成功轉(zhuǎn)行為程序員的原因(之一)。
然而,同樣一個(gè)業(yè)務(wù)功能,分別讓兩個(gè)工作經(jīng)驗(yàn)不同的程序員去實(shí)現(xiàn),他們的代碼可能完全不一樣。有時(shí),經(jīng)驗(yàn)少的程序員寫(xiě)100行代碼就能實(shí)現(xiàn)的一個(gè)功能,老程序員卻需要寫(xiě)500行,因?yàn)楹笳呖紤]到了對(duì)各種邊界條件的處理,緩存的使用以及對(duì)性能的顧及等。又有時(shí),經(jīng)驗(yàn)少的程序員寫(xiě)了500行代碼實(shí)現(xiàn)的一個(gè)功能,老程序員只花了100行就實(shí)現(xiàn)了,因?yàn)楹笳呤褂昧烁觾?yōu)秀的算法或者采用了能使代碼變得更加簡(jiǎn)潔的工具和原則等。
李書(shū)福說(shuō):“造車(chē)就是一個(gè)沙發(fā)加四個(gè)車(chē)輪”。他說(shuō)的沒(méi)錯(cuò),因?yàn)檫@是汽車(chē)的某種本質(zhì)。然而,真正要造好一臺(tái)汽車(chē),卻需要考慮舒適性、加速性、NVH、操控性、通過(guò)性等諸多方面的因素。軟件也一樣,簡(jiǎn)單的CRUD操作縱然能夠滿(mǎn)足基本的I/O需求,但是在具體落地時(shí)我們還要考慮很多原則和因素以讓人能夠更好地掌控軟件系統(tǒng),其中包含但不限于:高內(nèi)聚低耦合、關(guān)注點(diǎn)分離、依賴(lài)倒置、非功能性需求等等。這里所涉及到的一個(gè)基本命題是:軟件代碼首先是給人腦看的,其次才是給電腦執(zhí)行的。
在本文中,我們將以一個(gè)真實(shí)的軟件項(xiàng)目 —— 碼如云為例,系統(tǒng)性的講解后端在處理請(qǐng)求的過(guò)程中所需要顧及的方方面面,你會(huì)發(fā)現(xiàn)后端開(kāi)發(fā)絕非單純的CRUD這么簡(jiǎn)單。
碼如云(https://www.mryqr.com)是一個(gè)基于二維碼的一物一碼管理軟件,技術(shù)上是一個(gè)無(wú)代碼平臺(tái),全程采用DDD思想進(jìn)行開(kāi)發(fā),對(duì)DDD感興趣的讀者可以參考我們的DDD系列文章。
接下來(lái),我們將圍繞以下業(yè)務(wù)用例展開(kāi)討論:在碼如云中,成員(Member)可以更新自己的手機(jī)號(hào)碼,但如果所使用的手機(jī)號(hào)已經(jīng)被他人占用,則禁止更新。
整個(gè)請(qǐng)求處理的流程如下圖所示:
概括來(lái)看,整個(gè)請(qǐng)求處理流程和我們通常的實(shí)踐并沒(méi)有太大的區(qū)別。首先,請(qǐng)求到達(dá)MemberController,這是Spring MVC處理請(qǐng)求的第一站;然后MemberController調(diào)用MemberCommandService完成該業(yè)務(wù)用例,調(diào)用時(shí)傳入請(qǐng)求數(shù)據(jù)對(duì)象ChangeMyMobileCommand,這里的MemberCommandService在DDD中被稱(chēng)為應(yīng)用服務(wù);MemberCommandService通過(guò)MemberRepository獲取到對(duì)應(yīng)的Member對(duì)象,再通過(guò)MemberDomainService(在DDD中被稱(chēng)為領(lǐng)域服務(wù))完成對(duì)Member的手機(jī)號(hào)更新;最后MemberCommandService 調(diào)用MemberRepository.save()將更新后的Member對(duì)象保存到數(shù)據(jù)庫(kù)。
MemberController
在整個(gè)請(qǐng)求處理的過(guò)程中,首先通過(guò)MemberController接收請(qǐng)求:
@PutMapping(value = "/me/mobile")@ResponseStatus(OK)public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command, @AuthenticationPrincipal User user) { memberCommandService.changeMyMobile(command, user);}
這里,MemberController.changeMyMobile()方法一共只有5行代碼,可不要小瞧這5行代碼,在實(shí)際編碼時(shí)我們卻需要考慮多個(gè)方面的因素:
- Spring MVC的Controller是框架直接相關(guān)的,DDD講求業(yè)務(wù)復(fù)雜度與技術(shù)復(fù)雜度的分離,我們希望自己的代碼實(shí)現(xiàn)能夠盡快的脫離技術(shù)框架,因此MemberController只起到了簡(jiǎn)單的代理作用,也即把請(qǐng)求代理給應(yīng)用服務(wù)MemberCommandService。
- 對(duì)URL的設(shè)計(jì)是有講究的,MemberController采用了REST風(fēng)格的URL,通過(guò)HTTP的PUT方法完成對(duì)mobile資源(me/mobile)的更新,更多關(guān)于REST URL的內(nèi)容,請(qǐng)參考這里。
- 同樣基于REST原則,更新資源后應(yīng)該返回HTTP的200狀態(tài)碼,這里通過(guò)@ResponseStatus(OK)完成(Spring MVC默認(rèn)返回的即是200)。
- 對(duì)于接收到的數(shù)據(jù)請(qǐng)求對(duì)象ChangeMyMobileCommand需要加上@Valid以做數(shù)據(jù)驗(yàn)證,否則后續(xù)對(duì)ChangeMyMobileCommand中的各種JSR-303驗(yàn)證將失效。
- MemberController需要返回void,也即不返回任何數(shù)據(jù),這是因?yàn)榛贑QRS的原則,任何寫(xiě)數(shù)據(jù)的操作不能同時(shí)查詢(xún)數(shù)據(jù),反之亦然。
ChangeMyMobileCommand
命令對(duì)象ChangeMyMobileCommand用于封裝請(qǐng)求數(shù)據(jù),之所以稱(chēng)之為命令(Command)是因?yàn)橐粋€(gè)請(qǐng)求就像外界向軟件系統(tǒng)發(fā)起了一次命令一樣,這里的Command正是來(lái)自于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無(wú)法完成的驗(yàn)證邏輯,但是又不能包含業(yè)務(wù)邏輯 }}
ChangeMyMobileCommand 對(duì)象主要充當(dāng)數(shù)據(jù)容器的作用,其中一個(gè)比較重要的任務(wù)是完成數(shù)據(jù)的初步驗(yàn)證。具體實(shí)踐時(shí)需要考慮以下幾個(gè)方面:
- Command對(duì)象通常是不變的(Immutable),在編碼時(shí)應(yīng)將建模為一個(gè)值對(duì)象,為此我們使用了Lombok中的@Value、@Builder和@AllArgsConstructor(access = PRIVATE)達(dá)到此目的。
- 對(duì)Command對(duì)象中的每一個(gè)字段,都需要判斷是否需要做驗(yàn)證,有些字段可以通過(guò)簡(jiǎn)單的JSR-303內(nèi)建注解完成驗(yàn)證,比如mobile字段中的@NotBlank,而更復(fù)雜的驗(yàn)證則需要自行實(shí)現(xiàn)JSR-303的ConstraintValidator接口,比如mobile字段的@Mobile注解。
- 對(duì)于Command對(duì)象,還需要特別注意其中的容器類(lèi)字段,比如List和Set等,需要對(duì)這些字段做非null檢查(@NotNull),以消除后續(xù)代碼在引用這些字段時(shí)有可能的空指針異常NullPointerException。
- 對(duì)于更加復(fù)雜的驗(yàn)證,比如需要對(duì)多個(gè)字段進(jìn)行關(guān)聯(lián)性驗(yàn)證,通過(guò)自定義JSR-303可能比較麻煩,此時(shí)可以自定義Command接口,通過(guò)實(shí)現(xiàn)該接口的correctAndValidate()方法完成驗(yàn)證目的。
- 對(duì)于字符串類(lèi)字段來(lái)說(shuō),任何時(shí)候都需要通過(guò)@Size注解對(duì)其長(zhǎng)度進(jìn)行限制,除非其他注解中已經(jīng)包含了此限制。
MemberCommandService
應(yīng)用服務(wù)(ApplicationService或者CommandService)是領(lǐng)域模型的門(mén)面,任何對(duì)領(lǐng)域模型的請(qǐng)求都需要通過(guò)應(yīng)用服務(wù)中的公有方法完成。更多關(guān)于應(yīng)用服務(wù)的講解,請(qǐng)參考我們DDD文章系列中的這一篇。
@Transactionalpublic 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());}
在DDD中,應(yīng)用服務(wù)應(yīng)該是很薄的一層,因?yàn)樗荒馨瑯I(yè)務(wù)邏輯,而主要是起協(xié)調(diào)的作用,另外事務(wù)邊界、鑒權(quán)等操作也會(huì)放在應(yīng)用服務(wù)中。在實(shí)現(xiàn)時(shí),應(yīng)該考慮以下幾個(gè)方面:
- 應(yīng)用服務(wù)不能包含業(yè)務(wù)邏輯,這也是很多CRUD程序員經(jīng)常犯的一個(gè)錯(cuò)誤。舉個(gè)例子,在本例中,如果成員的手機(jī)號(hào)已經(jīng)被占用,則禁止更新手機(jī)號(hào),這是一個(gè)典型的業(yè)務(wù)邏輯,因此不應(yīng)該在MemberCommandService 中完成,而應(yīng)該放到領(lǐng)域模型中。通常來(lái)說(shuō),應(yīng)用服務(wù)遵循請(qǐng)求處理“三部曲”原則:(1)獲取需要處理的領(lǐng)域?qū)ο螅ū纠械腗ember),(2)對(duì)領(lǐng)域?qū)ο筮M(jìn)行處理(memberDomainService.changeMyMobile()),(3)將更新后的領(lǐng)域?qū)ο蟊4婊財(cái)?shù)據(jù)庫(kù)(memberRepository.save())。
- 應(yīng)用服務(wù)中的公共方法應(yīng)該與業(yè)務(wù)用例一一對(duì)應(yīng),而每個(gè)業(yè)務(wù)用例又對(duì)應(yīng)一個(gè)數(shù)據(jù)庫(kù)事務(wù),因此應(yīng)用服務(wù)應(yīng)該是事務(wù)的邊界,也即Spring的@Transactional注解應(yīng)該打在應(yīng)用服務(wù)的公用方法上。
- 與Controller一樣,應(yīng)用服務(wù)中負(fù)責(zé)寫(xiě)操作的方法不能返回查詢(xún)數(shù)據(jù),而負(fù)責(zé)查詢(xún)的方法不能更改數(shù)據(jù)。
- 應(yīng)用服務(wù)應(yīng)該是獨(dú)立于技術(shù)框架(本例的Spring)的,如果把領(lǐng)域模型比作CPU中的芯片,那么應(yīng)用服務(wù)便是CPU引腳,整個(gè)CPU放到不同的電腦主板(類(lèi)比到技術(shù)框架)中均能正常使用。不過(guò),在實(shí)際的編碼過(guò)程中,我們做了一些妥協(xié),比如在本例中,@Transactional 則是來(lái)自于Spring的,不過(guò)總的原則是不變的,即應(yīng)用服務(wù)(以及其所包圍著的領(lǐng)域模型)盡量少地依賴(lài)于技術(shù)框架。
- 一些非業(yè)務(wù)性的功能也應(yīng)該在應(yīng)用服務(wù)中完成,比如對(duì)請(qǐng)求的限流(本例中的mryRateLimiter ),限流處理原本可以放到技術(shù)框架中統(tǒng)一處理的,不過(guò)由于碼如云是一個(gè)SaaS軟件,需要對(duì)不同的租戶(hù)單獨(dú)限流,因此我們將其放在了應(yīng)用服務(wù)這一層。
- 一般來(lái)講,對(duì)權(quán)限的檢查也可以放在應(yīng)用服務(wù)中;不過(guò)不同的人對(duì)此有不同的看法,有人認(rèn)為權(quán)限也屬于業(yè)務(wù)邏輯,因此應(yīng)該放到領(lǐng)域模型中,而另外有人認(rèn)為權(quán)限不是業(yè)務(wù)邏輯,應(yīng)該被當(dāng)做一個(gè)單獨(dú)的關(guān)注點(diǎn)來(lái)處理。在碼如云,我們選擇了后者,并且將對(duì)權(quán)限的處理放到了應(yīng)用服務(wù)中。
MemberRepository
資源庫(kù)(Repository)的、可以認(rèn)為是對(duì)數(shù)據(jù)庫(kù)的封裝和抽象,有些類(lèi)似于DAO(Data Access Object),不過(guò)它們最大的區(qū)別是資源庫(kù)是與DDD中的聚合根一一對(duì)應(yīng)的,只有聚合根對(duì)象才“配得上”擁有資源庫(kù),而DAO則沒(méi)有此限制。更多關(guān)于資源庫(kù)的內(nèi)容,可以參考這里。
public interface MemberRepository { boolean existsByMobile(String mobile); Member byId(String id); Optional<Member> byIdOptional(String id); Member byIdAndCheckTenantShip(String id, User user); boolean exists(String arId); void save(Member member); void delete(Member member);}
在實(shí)現(xiàn)資源庫(kù)時(shí),應(yīng)該考慮以下幾個(gè)方面:
- 只對(duì)聚合根對(duì)象創(chuàng)建相應(yīng)的資源庫(kù),并且其操作的對(duì)象是以聚合根為單位的。
- 資源庫(kù)不能包含太多的查詢(xún)方法,大量的查詢(xún)操作可能意味著對(duì)領(lǐng)域模型的污染,此時(shí)可以考慮通過(guò)CQRS將查詢(xún)操作繞過(guò)資源庫(kù)單獨(dú)處理。
- 資源庫(kù)通常分為接口類(lèi)和實(shí)現(xiàn)類(lèi),接口類(lèi)是屬于領(lǐng)域模型的一部分,而實(shí)現(xiàn)類(lèi)則應(yīng)該放到基礎(chǔ)設(shè)施中,落地時(shí)接口類(lèi)應(yīng)該放到domain分包下,而實(shí)現(xiàn)類(lèi)應(yīng)該放到infrastructure分包下,這也意味著,資源庫(kù)的實(shí)現(xiàn)是“可插拔”的,即如果將來(lái)要從MySQL遷移到MongoDB,那么只需要新添加一個(gè)基于MongoDB的資源庫(kù)實(shí)現(xiàn)類(lèi)即可,其他地方可以不變。
- 資源庫(kù)中不能包含業(yè)務(wù)邏輯,其完成的功能只限于將數(shù)據(jù)從內(nèi)存同步到數(shù)據(jù)庫(kù),或者反之。
MemberDomainService
與應(yīng)用服務(wù)不同的是,領(lǐng)域服務(wù)(DomainService)屬于領(lǐng)域模型的一部分,專(zhuān)門(mén)用于處理業(yè)務(wù)邏輯,通常被應(yīng)用服務(wù)所調(diào)用。在本例中,我們使用MemberDomainService 對(duì)“手機(jī)號(hào)是否已經(jīng)被占用”進(jìn)行檢查:
public void changeMyMobile(Member member, String newMobile, String password) { if (!mryPasswordEncoder.matches(password, member.getPassword())) { throw new MryException(PASSWORD_NOT_MATCH, "修改手機(jī)號(hào)失敗,密碼不正確。", "memberId", member.getId()); } if (Objects.equals(member.getMobile(), newMobile)) { return; } if (memberRepository.existsByMobile(newMobile)) { throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手機(jī)號(hào)失敗,手機(jī)號(hào)對(duì)應(yīng)成員已存在。", mapOf("mobile", newMobile, "memberId", member.getId())); } member.changeMobile(newMobile, member.toUser());}
在實(shí)踐時(shí),使用領(lǐng)域服務(wù)應(yīng)該考慮到以下幾個(gè)方面:
- 領(lǐng)域服務(wù)不是必須有的,而是只有當(dāng)領(lǐng)域模型(準(zhǔn)確的講是聚合根)無(wú)法完成某些業(yè)務(wù)邏輯時(shí)才出現(xiàn)的,是“不得已而為之”的結(jié)果。在本例中,檢查“手機(jī)號(hào)是否被占用”需要進(jìn)行跨聚合(Member)的操作,光憑當(dāng)事的Member是無(wú)法做到這一點(diǎn)的,此外這種檢查有屬于業(yè)務(wù)邏輯的一部分,因此我們創(chuàng)建一種可以處理業(yè)務(wù)邏輯的服務(wù)(Service)類(lèi)來(lái)解決,這個(gè)服務(wù)類(lèi)即是領(lǐng)域服務(wù)。在很多項(xiàng)中,應(yīng)用服務(wù)和領(lǐng)域服務(wù)揉雜在一起,功能倒是實(shí)現(xiàn)了,但是各組件之間的耦合也加深了,導(dǎo)致的結(jié)果是軟件在未來(lái)的演進(jìn)中將變得越來(lái)越復(fù)雜,越來(lái)越困難。
- 領(lǐng)域服務(wù)的職責(zé)最多只到更新領(lǐng)域模型在內(nèi)存中的狀態(tài),而不包含保存領(lǐng)域模型的職責(zé),比如在本例中,MemberDomainService 并不調(diào)用memberRepository.save(member)來(lái)保存Member,而是由應(yīng)用服務(wù)MemberCommandService負(fù)責(zé)完成。這樣做的好處是將領(lǐng)域服務(wù)建模為一個(gè)僅僅操作領(lǐng)域模型的“存在”,使其職責(zé)更加的單一化。
Member
領(lǐng)域?qū)ο?/span>(Domain Object)是業(yè)務(wù)邏輯的主要載體,同時(shí)包含了業(yè)務(wù)數(shù)據(jù)和業(yè)務(wù)行為。在本例中,Member對(duì)象則是一個(gè)典型的領(lǐng)域?qū)ο?,在DDD中,Member也被稱(chēng)為聚合根對(duì)象。Member對(duì)象實(shí)現(xiàn)修改手機(jī)號(hào)的代碼如下:
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));}
在實(shí)現(xiàn)領(lǐng)域?qū)ο髸r(shí),應(yīng)該考慮以下幾個(gè)方面:
- 忘掉數(shù)據(jù)庫(kù),不要預(yù)設(shè)性地將領(lǐng)域模型中的字段與數(shù)據(jù)庫(kù)中的字段對(duì)應(yīng)起來(lái),只有這樣才能夠做到架構(gòu)的整潔性以及基礎(chǔ)設(shè)施中立性,正如Bob大叔所說(shuō),數(shù)據(jù)庫(kù)是一個(gè)細(xì)節(jié)。
- 領(lǐng)域模型應(yīng)該保證數(shù)據(jù)一致性,比如在修改訂單項(xiàng)時(shí),訂單的價(jià)格也應(yīng)該相應(yīng)的變化,那么此時(shí)所有相關(guān)的處理邏輯均應(yīng)該在同一個(gè)方法中完成。在本例中,手機(jī)號(hào)修改了之后,應(yīng)該同時(shí)將Member標(biāo)記為“手機(jī)號(hào)已記錄”狀態(tài)(mobileIdentified ),因此對(duì)mobileIdentified 的修改應(yīng)該與對(duì)mobile的修改放在同一個(gè)chagneMyMobile()方法中。在DDD中,這也稱(chēng)為不變條件(Invariants)。
- 在實(shí)現(xiàn)領(lǐng)域邏輯的過(guò)程中,還會(huì)隨之產(chǎn)生領(lǐng)域事件(Domain Event),由于領(lǐng)域事件也是領(lǐng)域模型的一部分,因此一種做法是領(lǐng)域?qū)ο笤谕瓿蓸I(yè)務(wù)操作之后,還應(yīng)發(fā)出領(lǐng)域事件,即本例中的raiseEvent(new MobileChangedEvent(this.getId(), mobile));更多關(guān)于領(lǐng)域事件的內(nèi)容,請(qǐng)參考這里。
- 領(lǐng)域?qū)ο蟛荒艹钟谢蛞闷渌?lèi)型的對(duì)象,包括應(yīng)用服務(wù),領(lǐng)域服務(wù),資源庫(kù)等,因?yàn)轭I(lǐng)域?qū)ο笾皇歉鶕?jù)業(yè)務(wù)邏輯的運(yùn)算完成對(duì)業(yè)務(wù)數(shù)據(jù)的更新,也即領(lǐng)域?qū)ο髴?yīng)該建模為POJO(Plain Old Java Object)。
- 同理于應(yīng)用服務(wù),Member.changeMobile()方法是個(gè)寫(xiě)操作,不能返回任何數(shù)據(jù)。
總結(jié)
在文本中我們看到,哪怕是一個(gè)諸如“用戶(hù)修改手機(jī)號(hào)”這樣簡(jiǎn)單的需求,在整個(gè)實(shí)現(xiàn)過(guò)程中需要考慮的點(diǎn)也達(dá)到了將近30個(gè),真實(shí)情況只會(huì)多不會(huì)少,比如我們可能還需要考慮性能、緩存和認(rèn)證等眾多非功能性需求等。因此,后端開(kāi)發(fā)絕非CRUD這么簡(jiǎn)單,而是需要將諸多因素考慮在內(nèi)的一個(gè)系統(tǒng)性工程,還是那句話(huà),有講究的編程并不是一件易事。