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