天天看點

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

王争《設計模式之美》筆記

錢包業務背景介紹

一般來講,每個虛拟錢包賬戶都會對應使用者的一個真實的支付賬戶,有可能是銀行卡賬戶,也有可能是三方支付賬戶(比如支付寶、微信錢包)。為了友善後續的講解,我們限定錢包暫時隻支援充值、提現、支付、查詢餘額、查詢交易流水這五個核心的功能,其他比如當機、透支、轉贈等不常用的功能,我們暫不考慮。為了讓你了解這五個核心功能是如何工作的,接下來,我們來一塊兒看下它們的業務實作流程。

1. 充值

使用者通過三方支付管道,把自己銀行卡賬戶内的錢,充值到虛拟錢包賬号中。這整個過程,我們可以分解為三個主要的操作流程:第一個操作是從使用者的銀行卡賬戶轉賬到應用的公共銀行卡賬戶;第二個操作是将使用者的充值金額加到虛拟錢包餘額上;第三個操作是記錄剛剛這筆交易流水。

2. 支付

使用者用錢包内的餘額,支付購買應用内的商品。實際上,支付的過程就是一個轉賬的過程,從使用者的虛拟錢包賬戶劃錢到商家的虛拟錢包賬戶上,然後觸發真正的銀行轉賬操作,從應用的公共銀行賬戶轉錢到商家的銀行賬戶(注意,這裡并不是從使用者的銀行賬戶轉錢到商家的銀行賬戶)。除此之外,我們也需要記錄這筆支付的交易流水資訊。

3. 提現

除了充值、支付之外,使用者還可以将虛拟錢包中的餘額,提現到自己的銀行卡中。這個過程實際上就是扣減使用者虛拟錢包中的餘額,并且觸發真正的銀行轉賬操作,從應用的公共銀行賬戶轉錢到使用者的銀行賬戶。同樣,我們也需要記錄這筆提現的交易流水資訊。

4. 查詢餘額

查詢餘額功能比較簡單,我們看一下虛拟錢包中的餘額數字即可。

5. 查詢交易流水

查詢交易流水也比較簡單。我們隻支援三種類型的交易流水:充值、支付、提現。在使用者充值、支付、提現的時候,我們會記錄相應的交易資訊。在需要查詢的時候,我們隻需要将之前記錄的交易流水,按照時間、類型等條件過濾之後,顯示出來即可。

錢包系統的設計思路

根據剛剛講的業務實作流程和資料流轉圖,我們可以把整個錢包系統的業務劃分為兩部分,其中一部分單純跟應用内的虛拟錢包賬戶打交道,另一部分單純跟銀行賬戶打交道。我們基于這樣一個業務劃分,給系統解耦,将整個錢包系統拆分為兩個子系統:虛拟錢包系統和三方支付系統。

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

接來下隻聚焦于虛拟錢包系統的設計與實作。對于三方支付系統以及整個錢包系統的設計與實作,你可以自己思考下。

現在我們來看下,如果要支援錢包的這五個核心功能,虛拟錢包系統需要對應實作哪些操作。下面有一張圖,列出了這五個功能都會對應虛拟錢包的哪些操作。注意,交易流水的記錄和查詢,暫時在圖中打了個問号,那是因為這塊比較特殊,我們待會再講。

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

從圖中我們可以看出,虛拟錢包系統要支援的操作非常簡單,就是餘額的加加減減。其中,充值、提現、查詢餘額三個功能,隻涉及一個賬戶餘額的加減操作,而支付功能涉及兩個賬戶的餘額加減操作:一個賬戶減餘額,另一個賬戶加餘額。

現在,我們再來看一下圖中問号的那部分,也就是交易流水該如何記錄和查詢?我們先來看一下,交易流水都需要包含哪些資訊。我覺得下面這幾個資訊是必須包含的。

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

從圖中我們可以發現,交易流水的資料格式包含兩個錢包賬号,一個是入賬錢包賬号,一個是出賬錢包賬号。為什麼要有兩個賬号資訊呢?這主要是為了相容支付這種涉及兩個賬戶的交易類型。不過,對于充值、提現這兩種交易類型來說,我們隻需要記錄一個錢包賬戶資訊就夠了,是以,這樣的交易流水資料格式的設計稍微有點浪費存儲空間。

實際上,我們還有另外一種交易流水資料格式的設計思路,可以解決這個問題。我們把“支付”這個交易類型,拆為兩個子類型:支付和被支付。支付單純表示出賬,餘額扣減,被支付單純表示入賬,餘額增加。這樣我們在設計交易流水資料格式的時候,隻需要記錄一個賬戶資訊即可。我畫了一張兩種交易流水資料格式的對比圖,你可以對比着看一下。

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

那以上兩種交易流水資料格式的設計思路,你覺得哪一個更好呢?

答案是第一種設計思路更好些。因為交易流水有兩個功能:一個是業務功能,比如,提供使用者查詢交易流水資訊;另一個是非業務功能,保證資料的一緻性。這裡主要是指支付操作資料的一緻性。

支付實際上就是一個轉賬的操作,在一個賬戶上加上一定的金額,在另一個賬戶上減去相應的金額。我們需要保證加金額和減金額這兩個操作,要麼都成功,要麼都失敗。如果一個成功,一個失敗,就會導緻資料的不一緻,一個賬戶明明減掉了錢,另一個賬戶卻沒有收到錢。

保證資料一緻性的方法有很多,比如依賴資料庫事務的原子性,将兩個操作放在同一個事務中執行。但是,這樣的做法不夠靈活,因為我們的有可能做了分庫分表,支付涉及的兩個賬戶可能存儲在不同的庫中,無法直接利用資料庫本身的事務特性,在一個事務中執行兩個賬戶的操作。當然,我們還有一些支援分布式事務的開源架構,但是,為了保證資料的強一緻性,它們的實作邏輯一般都比較複雜、本身的性能也不高,會影響業務的執行時間。是以,更權重衡的一種做法就是,不保證資料的強一緻性,隻實作資料的最終一緻性,也就是我們剛剛提到的交易流水要實作的非業務功能。

對于支付這樣的類似轉賬的操作,我們在操作兩個錢包賬戶餘額之前,先記錄交易流水,并且标記為“待執行”,當兩個錢包的加減金額都完成之後,我們再回過頭來,将交易流水标記為“成功”。在給兩個錢包加減金額的過程中,如果有任意一個操作失敗,我們就将交易記錄的狀态标記為“失敗”。我們通過背景補漏 Job,拉取狀态為“失敗”或者長時間處于“待執行”狀态的交易記錄,重新執行或者人工介入處理。

如果選擇第二種交易流水的設計思路,使用兩條交易流水來記錄支付操作,那記錄兩條交易流水本身又存在資料的一緻性問題,有可能入賬的交易流水記錄成功,出賬的交易流水資訊記錄失敗。是以,權衡利弊,我們選擇第一種稍微有些備援的資料格式設計思路。

現在,我們在思考這樣一個問題:充值、提現、支付這些業務交易類型,是否應該讓虛拟錢包系統感覺?換句話說,我們是否應該在虛拟錢包系統的交易流水中記錄這三種類型?

答案是否定的。虛拟錢包系統不應該感覺具體的業務交易類型。我們前面講到,虛拟錢包支援的操作,僅僅是餘額的加加減減操作,不涉及複雜業務概念,職責單一、功能通用。如果耦合太多業務概念到裡面,勢必影響系統的通用性,而且還會導緻系統越做越複雜。是以,我們不希望将充值、支付、提現這樣的業務概念添加到虛拟錢包系統中。

但是,如果我們不在虛拟錢包系統的交易流水中記錄交易類型,那在使用者查詢交易流水的時候,如何顯示每條交易流水的交易類型呢?

從系統設計的角度,我們不應該在虛拟錢包系統的交易流水中記錄交易類型。從産品需求的角度來說,我們又必須記錄交易流水的交易類型。聽起來比較沖突,這個問題該如何解決呢?

我們可以通過記錄兩條交易流水資訊的方式來解決。我們前面講到,整個錢包系統分為兩個子系統,上層錢包系統的實作,依賴底層虛拟錢包系統和三方支付系統。對于錢包系統來說,它可以感覺充值、支付、提現等業務概念,是以,我們在錢包系統這一層額外再記錄一條包含交易類型的交易流水資訊,而在底層的虛拟錢包系統中記錄不包含交易類型的交易流水資訊。

為了讓你更好地了解剛剛的設計思路,下面有一張圖,你可以對比着上面的講解一塊兒來看。

php 餘額當機設計_設計模式之美(十):如何利用基于充血模型的DDD開發虛拟錢包系統...錢包業務背景介紹錢包系統的設計思路基于貧血模型的傳統開發模式基于充血模型的 DDD 開發模式辯證思考與靈活應用重點回顧思考

通過查詢上層錢包系統的交易流水資訊,去滿足使用者查詢交易流水的功能需求,而虛拟錢包中的交易流水就隻是用來解決資料一緻性問題。實際上,它的作用還有很多,比如用來對賬等。

整個虛拟錢包的設計思路到此講完了。接下來,我們來看一下,如何分别用基于貧血模型的傳統開發模式和基于充血模型的 DDD 開發模式,來實作這樣一個虛拟錢包系統?

基于貧血模型的傳統開發模式

這是一個典型的 Web 後端項目的三層結構。其中,Controller 和 VO 負責暴露接口,具體的代碼實作如下所示。注意,Controller 中,接口實作比較簡單,主要就是調用 Service 的方法,是以,我省略了具體的代碼實作。

public class VirtualWalletController {  // 通過構造函數或者IOC架構注入  private VirtualWalletService virtualWalletService;    public BigDecimal getBalance(Long walletId) { ... } //查詢餘額  public void debit(Long walletId, BigDecimal amount) { ... } //出賬  public void credit(Long walletId, BigDecimal amount) { ... } //入賬  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //轉賬}
           
public class VirtualWalletController {  // 通過構造函數或者IOC架構注入  private VirtualWalletService virtualWalletService;    public BigDecimal getBalance(Long walletId) { ... } //查詢餘額  public void debit(Long walletId, BigDecimal amount) { ... } //出賬  public void credit(Long walletId, BigDecimal amount) { ... } //入賬  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //轉賬}
           

Service 和 BO 負責核心業務邏輯,Repository 和 Entity 負責資料存取。Repository 這一層的代碼實作比較簡單,不是講解的重點,是以也省略掉了。Service 層的代碼如下所示。注意,這裡省略了一些不重要的校驗代碼,比如,對 amount 是否小于 0、錢包是否存在的校驗等等。

public class VirtualWalletBo {//省略getter/setter/constructor方法  private Long id;  private Long createTime;  private BigDecimal balance;}public class VirtualWalletService {  // 通過構造函數或者IOC架構注入  private VirtualWalletRepository walletRepo;  private VirtualWalletTransactionRepository transactionRepo;    public VirtualWalletBo getVirtualWallet(Long walletId) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    VirtualWalletBo walletBo = convert(walletEntity);    return walletBo;  }    public BigDecimal getBalance(Long walletId) {    return virtualWalletRepo.getBalance(walletId);  }    public void debit(Long walletId, BigDecimal amount) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    BigDecimal balance = walletEntity.getBalance();    if (balance.compareTo(amount) < 0) {      throw new NoSufficientBalanceException(...);    }    walletRepo.updateBalance(walletId, balance.subtract(amount));  }    public void credit(Long walletId, BigDecimal amount) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    BigDecimal balance = walletEntity.getBalance();    walletRepo.updateBalance(walletId, balance.add(amount));  }    public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();    transactionEntity.setAmount(amount);    transactionEntity.setCreateTime(System.currentTimeMillis());    transactionEntity.setFromWalletId(fromWalletId);    transactionEntity.setToWalletId(toWalletId);    transactionEntity.setStatus(Status.TO_BE_EXECUTED);    Long transactionId = transactionRepo.saveTransaction(transactionEntity);    try {      debit(fromWalletId, amount);      credit(toWalletId, amount);    } catch (InsufficientBalanceException e) {      transactionRepo.updateStatus(transactionId, Status.CLOSED);      ...rethrow exception e...    } catch (Exception e) {      transactionRepo.updateStatus(transactionId, Status.FAILED);      ...rethrow exception e...    }    transactionRepo.updateStatus(transactionId, Status.EXECUTED);  }}
           

以上便是利用基于貧血模型的傳統開發模式來實作的虛拟錢包系統。盡管我們對代碼稍微做了簡化,但整體的業務邏輯就是上面這樣子。其中大部分代碼邏輯都非常簡單,最複雜的是 Service 中的 transfer() 轉賬函數。我們為了保證轉賬操作的資料一緻性,添加了一些跟 transaction 相關的記錄和狀态更新的代碼,了解起來稍微有點難度,你可以對照着之前講的設計思路,自己多思考一下。

基于充血模型的 DDD 開發模式

再來看一下,如何利用基于充血模型的 DDD 開發模式來實作這個系統?

基于充血模型的 DDD 開發模式,跟基于貧血模型的傳統開發模式的主要差別就在 Service 層,Controller 層和 Repository 層的代碼基本上相同。是以,我們重點看一下,Service 層按照基于充血模型的 DDD 開發模式該如何來實作。

在這種開發模式下,我們把虛拟錢包 VirtualWallet 類設計成一個充血的 Domain 領域模型,并且将原來在 Service 類中的部分業務邏輯移動到 VirtualWallet 類中,讓 Service 類的實作依賴 VirtualWallet 類。具體的代碼實作如下所示:

public class VirtualWallet { // Domain領域模型(充血模型)  private Long id;  private Long createTime = System.currentTimeMillis();;  private BigDecimal balance = BigDecimal.ZERO;    public VirtualWallet(Long preAllocatedId) {    this.id = preAllocatedId;  }    public BigDecimal balance() {    return this.balance;  }    public void debit(BigDecimal amount) {    if (this.balance.compareTo(amount) < 0) {      throw new InsufficientBalanceException(...);    }    this.balance.subtract(amount);  }    public void credit(BigDecimal amount) {    if (amount.compareTo(BigDecimal.ZERO) < 0) {      throw new InvalidAmountException(...);    }    this.balance.add(amount);  }}public class VirtualWalletService {  // 通過構造函數或者IOC架構注入  private VirtualWalletRepository walletRepo;  private VirtualWalletTransactionRepository transactionRepo;    public VirtualWallet getVirtualWallet(Long walletId) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    VirtualWallet wallet = convert(walletEntity);    return wallet;  }    public BigDecimal getBalance(Long walletId) {    return virtualWalletRepo.getBalance(walletId);  }    public void debit(Long walletId, BigDecimal amount) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    VirtualWallet wallet = convert(walletEntity);    wallet.debit(amount);    walletRepo.updateBalance(walletId, wallet.balance());  }    public void credit(Long walletId, BigDecimal amount) {    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);    VirtualWallet wallet = convert(walletEntity);    wallet.credit(amount);    walletRepo.updateBalance(walletId, wallet.balance());  }    public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {    //...跟基于貧血模型的傳統開發模式的代碼一樣...  }
           

看了上面的代碼,你可能會說,領域模型 VirtualWallet 類很單薄,包含的業務邏輯很簡單。相對于原來的貧血模型的設計思路,這種充血模型的設計思路,貌似并沒有太大優勢。這也是大部分業務系統都使用基于貧血模型開發的原因。不過,如果虛拟錢包系統需要支援更複雜的業務邏輯,那充血模型的優勢就顯現出來了。比如,我們要支援透支一定額度和當機部分餘額的功能。這個時候,我們重新來看一下 VirtualWallet 類的實作代碼。

public class VirtualWallet {  private Long id;  private Long createTime = System.currentTimeMillis();;  private BigDecimal balance = BigDecimal.ZERO;  private boolean isAllowedOverdraft = true;  private BigDecimal overdraftAmount = BigDecimal.ZERO;  private BigDecimal frozenAmount = BigDecimal.ZERO;    public VirtualWallet(Long preAllocatedId) {    this.id = preAllocatedId;  }    public void freeze(BigDecimal amount) { ... }  public void unfreeze(BigDecimal amount) { ...}  public void increaseOverdraftAmount(BigDecimal amount) { ... }  public void decreaseOverdraftAmount(BigDecimal amount) { ... }  public void closeOverdraft() { ... }  public void openOverdraft() { ... }    public BigDecimal balance() {    return this.balance;  }    public BigDecimal getAvaliableBalance() {    BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);    if (isAllowedOverdraft) {      totalAvaliableBalance += this.overdraftAmount;    }    return totalAvaliableBalance;  }    public void debit(BigDecimal amount) {    BigDecimal totalAvaliableBalance = getAvaliableBalance();    if (totoalAvaliableBalance.compareTo(amount) < 0) {      throw new InsufficientBalanceException(...);    }    this.balance.subtract(amount);  }    public void credit(BigDecimal amount) {    if (amount.compareTo(BigDecimal.ZERO) < 0) {      throw new InvalidAmountException(...);    }    this.balance.add(amount);  }}
           

領域模型 VirtualWallet 類添加了簡單的當機和透支邏輯之後,功能看起來就豐富了很多,代碼也沒那麼單薄了。如果功能繼續演進,我們可以增加更加細化的當機政策、透支政策、支援錢包賬号(VirtualWallet id 字段)自動生成的邏輯(不是通過構造函數經外部傳入 ID,而是通過分布式 ID 生成算法來自動生成 ID)等等。VirtualWallet 類的業務邏輯會變得越來越複雜,也就很值得設計成充血模型了。

辯證思考與靈活應用

對于虛拟錢包系統的設計與兩種開發模式的代碼實作,你應該有個比較清晰的了解了。不過,還有兩個問題值得讨論一下。

第一個要讨論的問題是:在基于充血模型的 DDD 開發模式中,将業務邏輯移動到 Domain 中,Service 類變得很薄,但在我們的代碼設計與實作中,并沒有完全将 Service 類去掉,這是為什麼?或者說,Service 類在這種情況下擔當的職責是什麼?哪些功能邏輯會放到 Service 類中?

差別于 Domain 的職責,Service 類主要有下面這樣幾個職責。

1.Service 類負責與 Repository 交流。在上面的設計與代碼實作中,VirtualWalletService 類負責與 Repository 層打交道,調用 Respository 類的方法,擷取資料庫中的資料,轉化成領域模型 VirtualWallet,然後由領域模型 VirtualWallet 來完成業務邏輯,最後調用 Repository 類的方法,将資料存回資料庫。

之是以讓 VirtualWalletService 類與 Repository 打交道,而不是讓領域模型 VirtualWallet 與 Repository 打交道,那是因為我們想保持領域模型的獨立性,不與任何其他層的代碼(Repository 層的代碼)或開發架構(比如 Spring、MyBatis)耦合在一起,将流程性的代碼邏輯(比如從 DB 中取資料、映射資料)與領域模型的業務邏輯解耦,讓領域模型更加可複用。

2.Service 類負責跨領域模型的業務聚合功能。VirtualWalletService 類中的 transfer() 轉賬函數會涉及兩個錢包的操作,是以這部分業務邏輯無法放到 VirtualWallet 類中,是以,我們暫且把轉賬業務放到 VirtualWalletService 類中了。當然,雖然功能演進,使得轉賬業務變得複雜起來之後,也可以将轉賬業務抽取出來,設計成一個獨立的領域模型。

3.Service 類負責一些非功能性及與三方系統互動的工作。比如幂等、事務、發郵件、發消息、記錄日志、調用其他系統的 RPC 接口等,都可以放到 Service 類中。

第二個要讨論問題是:在基于充血模型的 DDD 開發模式中,盡管 Service 層被改造成了充血模型,但是 Controller 層和 Repository 層還是貧血模型,是否有必要也進行充血領域模組化呢?

答案是沒有必要。Controller 層主要負責接口的暴露,Repository 層主要負責與資料庫打交道,這兩層包含的業務邏輯并不多,前面我們也提到了,如果業務邏輯比較簡單,就沒必要做充血模組化,即便設計成充血模型,類也非常單薄,看起來也很奇怪。

盡管這樣的設計是一種面向過程的程式設計風格,但我們隻要控制好面向過程程式設計風格的副作用,照樣可以開發出優秀的軟體。那這裡的副作用怎麼控制呢?

就拿 Repository 的 Entity 來說,即便它被設計成貧血模型,違反面相對象程式設計的封裝特性,有被任意代碼修改資料的風險,但 Entity 的生命周期是有限的。一般來講,我們把它傳遞到 Service 層之後,就會轉化成 BO 或者 Domain 來繼續後面的業務邏輯。Entity 的生命周期到此就結束了,是以也并不會被到處任意修改。

再來說說 Controller 層的 VO。實際上 VO 是一種 DTO(Data Transfer Object,資料傳輸對象)。它主要是作為接口的資料傳輸承載體,将資料發送給其他系統。從功能上來講,它理應不包含業務邏輯、隻包含資料。是以,将它設計成貧血模型也是比較合理的。

重點回顧

基于充血模型的 DDD 開發模式跟基于貧血模型的傳統開發模式相比,主要差別在 Service 層。在基于充血模型的開發模式下,我們将部分原來在 Service 類中的業務邏輯移動到了一個充血的 Domain 領域模型中,讓 Service 類的實作依賴這個 Domain 類。

在基于充血模型的 DDD 開發模式下,Service 類并不會完全移除,而是負責一些不适合放在 Domain 類中的功能。比如,負責與 Repository 層打交道、跨領域模型的業務聚合功能、幂等事務等非功能性的工作。

基于充血模型的 DDD 開發模式跟基于貧血模型的傳統開發模式相比,Controller 層和 Repository 層的代碼基本上相同。這是因為,Repository 層的 Entity 生命周期有限,Controller 層的 VO 隻是單純作為一種 DTO。兩部分的業務邏輯都不會太複雜。業務邏輯主要集中在 Service 層。是以,Repository 層和 Controller 層繼續沿用貧血模型的設計思路是沒有問題的。

思考

  • 歡迎在留言區說一說你對 DDD 的看法。

:我對DDD的看法就是,它可以把原來最重的service邏輯拆分并且轉移一部分邏輯,可以使得代碼可讀性略微提高,另一個比較重要的點是使得模型充血以後,基于模型的業務抽象 在不斷的疊代之後會越來越明确,業務的細節會越來越精準,通過閱讀模型的充血行為代碼,能夠極快的了解系統的業務,對于開發來說能說明顯的提升開發效率。

另外,在平時開發的時候,我更喜歡将service分成多層(可以了解為2層),一層是基于資料層的簡單封裝操作dataService,另一層,是專門用來組合調用dataService方法的,這樣既有封裝又有能讓開發人員更好的了解。

你如何看待呢?

參考:https://time.geekbang.org/column/intro/250?code=gLit0LpsKZQ6vOVqS1htGOSAKYLCYeMuklw2dwajH-4%3D