當前位置:
首頁 > 知識 > SOFA 應用架構詳解

SOFA 應用架構詳解

1. 前言

從業這麼多年,接觸過銀行的應用,Apple 的應用,eBay 的應用和現在阿里的應用,雖然分屬於不同的公司,使用了不同的架構,但有一個共同點就是都很複雜。

導致複雜性的原因有很多,如果從架構的層面看,主要有兩點,一個是架構設計過於複雜,層次太多能把人繞暈。

另一個是根本就沒架構,ServiceImpl 作為上帝類包攬一切,一桿捅到 DAO(就簡單場景而言,這種Transaction Script也還湊合,至少實現上手都快),這種人為的複雜性導致系統越來越臃腫,越來越難維護,醬缸的老代碼發出一陣陣惡臭。

新來的同學,往往要捂著鼻子摳幾天甚至幾個月,才能理清系統和業務脈絡,然後又一頭扎進各種 bug fix,業務修補的惡性循環中,暗無天日!

CRM 作為阿里最老的應用系統,自然也逃不過這樣的宿命。不甘如此的我們開始反思到底是什麼造成了系統複雜性?

我們到底能不能通過架構來治理這種複雜性?基於這個出發點,我們團隊開始了一段非常有意義的架構重構之旅(Redefine the Arch),期間我們參考了 SalesForce、TMF2.0、匯金和盒馬的架構。

從他們那裡汲取了很多有價值的輸入,再結合我們自己的思考最終形成了我們自己現在的基於擴展點 + 元數據 + CQRS + DDD 的應用架構。

該架構的特點是可擴展性好,很好的貫徹了 OO 思想,有一套完整的規範標準,並採用了 CQRS 和領域建模技術,在很大程度上可以降低應用的複雜度。本文主要闡述了我們的思考過程和架構實現,希望能對在路上的你有所幫助。

SOFA 應用架構詳解

2. 複雜性來自哪裡

經過我們分析、討論,發現造成現在系統異常複雜的罪魁禍首主要來自以下四個方面:

2.1 可擴展性差

對於只有一個業務的簡單場景,並不需要擴展,問題也不突出,這也是為什麼這個點經常被忽略的原因,因為我們大部分的系統都是從單一業務開始的。

但是隨著支持的業務越來越多,代碼裡面開始出現大量的 if-else 邏輯,這個時候代碼開始有壞味道,沒聞到的同學就這麼繼續往上堆,聞到的同學會重構一下,但因為系統沒有統一的可擴展架構,重構的技法也各不相同,這種代碼的不一致性也是一種理解上的複雜度。

久而久之,系統就變得複雜難維護。像我們 CRM 應用,有 N 個業務方,每個業務方又有 N 個租戶,如果都要用 if-else 判斷業務差異,那簡直就是慘絕人寰。

其實這種擴展點(Extension Point),或者叫插件(Plug-in)的設計在架構設計中是非常普遍的。比較成功的案例有 Eclipse 的 plug-in 機制,集團的 TMF2.0 架構。

還有一個擴展性需求就是欄位擴展,這一點對 SaaS 應用尤為重要,因為有很多客戶定製化需求,但是我們很多系統也沒有統一的欄位擴展方案。

2.2 面向過程

是的,不管你承認與否,很多時候,我們都是操著面向對象的語言干著面向過程的勾當。

面向對象不僅是一個語言,更是一種思維方式。在我們追逐雲計算、深度學習、區塊鏈這些技術熱點的時候,靜下心來問問自己我們是不是真的掌握了 OOD;

在我們強調工程師要具備業務 Sense,產品 Sense,數據 Sense,演算法 Sense,XXSense 的時候,是不是忽略了對工程能力的要求。

據我觀察大部分工程師(包括我自己)的 OO 能力還遠沒有達到精通的程度,這種 OO 思想的缺乏主要體現在兩個方面,一個是很多同學不了解 SOLID 原則,不懂設計模式,不會畫 UML 圖,或者只是知道,但從來不會運用到實踐中;

另一個是不會進行領域建模,關於領域建模爭論已經很多了,我的觀點是 DDD 很好,但不是銀彈,用和不用取決於場景。但不管怎樣,請你拋開偏見,好好的研讀一下 Eric Evans 的《領域驅動設計》,如果有認知升級的感悟,恭喜你,你進階了。

我個人認為 DDD 最大的好處是將業務語義顯現化,把原先晦澀難懂的業務演算法邏輯,通過領域對象(Domain Object),統一語言(Ubiquitous Language)將領域概念清晰的顯性化表達出來。

相信我,這種表達帶來的代碼可讀性的提升,會讓接手你代碼的人對你心懷感恩的。借用 Abelson 的一句話是:


Programs must be written for people to read, and only incidentally for machines to execute

所以強烈譴責那些不顧他人感受的編碼行為。

2.3 分層不合理

俗話說的好,All problems in computer science can be solved by another level of indirection(計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決),怎樣? 是不是感受到間接層的強大了。

分層最大的好處就是分離關注點,讓每一層只解決該層關注的問題,從而將複雜的問題簡化,起到分而治之的作用。

我們平時看到的 MVC,pipeline,以及各種 valve 的模式,都是這個道理。好吧,那是不是層次越多越好,越靈活呢。

當然不是,就像我開篇說的,過多的層次不僅不能帶來好處,反而會增加系統的複雜性和降低系統性能。

就拿 ISO 的網路七層協議來說,你這個七層分的很清楚,很好,但也很繁瑣,四層就夠了嘛。

再比如我前面提到的過度設計的例子,如果沒記錯的話應該是 Apple 的 Directory Service 應用,整個系統有 7 層之多,把什麼 validator,assembler 都當成一個層次來對待,能不複雜么。

所以分層太多和沒有分層都會導致系統複雜度的上升,因此我們的原則是不可以沒有分層,但是只分有必要的層。

2.4 隨心所欲

隨心所欲是因為缺少規範和約束。這個規範非常非常非常的重要(重要事情說三遍),但也是最容易被無視的點,其結果就是架構的 consistency 被嚴重破壞,代碼的可維護性將急劇下降,國將不國,架構將形同虛設。

有同學會說不就是個 naming 的問題么,不就是個分包的問題么,不就是 2 個 module 還是 3 個 module 的問題么,只要功能能跑起來,這些問題都是小問題。是的,對於這些同學,我再丟給你一句名言 「Just because you can, doesn』t mean you should」。

就拿 package 來說,它不僅僅是一個放一堆類的地方,更是一種表達機制,當你將一些類放到 Package 中時,相當於告訴下一位看到你設計的開發人員要把這些類放在一起考慮。

理想很豐滿,現實很骨感,規範的執行是個大問題,最好能在架構層面進行約束,例如在我們架構中,擴展點必須以 ExtPt 結尾,擴展實現必須以 Ext 結尾,你不這麼寫就會給你拋異常。

但是架構的約束畢竟有限,更多的還是要靠 Code Review,暫時沒想到什麼更好的辦法。

這種對架構約束的近似嚴苛 follow,確保了系統的 consistency,最終形成了一個規整的收納箱(如下圖所示),就像我和團隊說的,我們在評估代碼改動點時,應該可以像 Hash 查找一樣,直接定位到對應的 module,對應的 package 裡面對應的 class。而不是到 「一鍋粥」 里去慢慢摳。

SOFA 應用架構詳解

本章節最後,上一張我們老系統中比較典型的代碼,也許你可以從中看到你自己應用的影子。

SOFA 應用架構詳解

3. 複雜性應對之道

知道了問題所在,接下來看下我們是如何一個個解決這些問題的。回頭站在山頂再看這些解決方案時,每個都不足為奇,但當你還「身在此山中」的時候,這個撥開層層迷霧,看到山的全貌的過程,並不是想像的那麼容易。慶幸的是我團隊在艱難跋涉之後,終有所收穫。

3.1 擴展點設計

擴展點的設計思想主要得益於 TMF2.0 的啟發,其實這種設計思想也一直在用,但都是在局部的代碼重構和優化。

比如基於 Strategy Pattern 的擴展,但是一直沒有找到一個很好的固化到框架中的方法。

直到毗盧到團隊分享,給了我們兩個關鍵的提示,一個是業務身份識別,用他的話說,如果當時 TMF1.0 如果有身份識別的話,就沒有 TMF2.0 什麼事了;另一個是抽象的擴展點機制。

3.2 身份識別

業務身份識別在我們的應用中非常重要,因為我們的 CRM 系統要服務不同的業務方,而且每個業務方又有多個租戶。

比如中供銷售,中供拍檔,中供商家都是不同的業務方,而拍檔下的每個公司,中供商家下的每個供應商又是不同的租戶。

所以傳統的基於多租戶(TenantId)的業務身份識別還不能滿足我們的要求,於是在此基礎上我們又引入了業務碼(BizCode)來標識業務。所以我們的業務身份實際上是(BizCode,TenantId)二元組。

在每一個業務身份下面,又可以有多個擴展點(ExtensionPoint),所以一個擴展點實現(Extension)實際上是一個三維空間中的向量。

借鑒 Maven Coordinate 的概念我給它起了個名字叫擴展坐標(Extension Coordinate),這個坐標可以用(ExtensionPoint,BizCode,TenantId)來唯一標識。

SOFA 應用架構詳解

有了業務身份這個關鍵抽象之後,通過身份來獲取擴展實現的過程就變得水到渠成了,具體流程如下:

SOFA 應用架構詳解

3.3 擴展點

擴展點的設計是這樣的,所有的擴展點(ExtensionPoint)必須通過介面申明,擴展實現(Extension)是通過 Annotation 的方式標註的,Extension 裡面使用 BizCode 和 TenantId 兩個屬性用來標識身份,框架的 Bootstrap 類會在 Spring 啟動的時候做類掃描,進行 Extension 註冊,在 Runtime 的時候,通過 TenantContext 來選擇要使用的 Extension。

TenantContext 是通過 Interceptor 在調用業務邏輯之前進行初始化的。整個過程如下圖所示:

SOFA 應用架構詳解

3.4 實例展示

比如在一個 CRM 系統里,客戶要添加聯繫人 Contact 是一個,但是在添加聯繫人之前,我們要判斷這個 Contact 是不是已經存在了,如果存在那麼就不能添加了。

不過在一個支持多業務的系統裡面,可能每個業務的衝突檢查都不一樣,這是一個典型的可以擴展的場景。那麼在 SOFA 框架中,我們可以這樣去做。

1. 定義擴展點

public interface ContactConflictRuleExtPt extends RuleI, ExtensionPointI { /**
* 查詢聯繫人衝突
*
* @param contact 衝突條件,不同業務會有不同的判斷規則
* @return 衝突結果
*/
public boolean queryContactConflict(ContactE contact);

}

2. 實現業務的擴展實現

@Extension(bizCode = BizCode.ICBU)public class IcbuContactConflictRuleExt implements ContactConflictRuleExtPt { @Autowired
private RepeatCheckServiceI repeatCheckService; @Autowired
private MemberMappingQueryTunnel memberMappingQueryTunnel; private Logger logger = LoggerFactory.getLogger(getClass()); /**
* 查詢聯繫人衝突
*
* @param contact 衝突條件,不同業務會有不同的判斷規則
* @return 衝突結果
*/
@Override
public boolean queryContactConflict(ContactE contact) {

Set<String> emails = contact.getEmail(); //具體的業務邏輯

return false;
}

3. 在領域實體中調用擴展實現

@ToString@Getter@Setterpublic class CustomerE extends Entity { /**
* 公司ID
*/
private String companyId; /**
* 公司(客戶)名字
*/
private String companyName; /**
* 公司(客戶)英文名字
*/
private String companyNameEn; /**
* 給客戶添加聯繫人
* @param contact
*/

public void addContact(ContactE contact,boolean checkConflict){ // 業務檢查
if (checkConflict) {
ruleExecutor.execute(ContactConflictRuleExtPt.class, p -> p.queryContactConflict(contact));
}
contact.setCustomerId(this.getId());
contactRepository.create(contact);
}
}

在上面的代碼中,框架在 runtime 的時候之所以可以找到對應的擴展實現,主要是靠 @Extension(bizCode = BizCode.ICBU) 這個 Annotation,因為在系統啟動時,Bootstrap 會掃描所有的擴展實現並註冊並緩存到 HashMap 裡面。

3.5 面向對象

面向對象不僅是一種編程語言,更是一種思維模式。所以看到很多簡歷裡面寫「精通 Java」,沒寫「精通 OO」,也算是中肯。

因為會 Java 語言並不代表你就掌握了面向對象思維(當然,精通Java也不是件易事),要想做到精通,必須要對 OO 設計原則,模式,方法論有很深入的理解,同時要具備非常好的業務理解力和抽象能力,才能說是精通,這種思維的訓練是一個長期不斷累積的過程,我也在路上,下面是我對面向對象設計的兩點體會:

3.6 SOLID

SOLID 是單一職責原則 (SRP),開閉原則(OCP),里氏替換原則(LSP),介面隔離原則(ISP) 和依賴倒置原則 (DIP) 的縮寫,原則是要比模式(Design Pattern)更基礎更重要的指導準則,是面向對象設計的 Bible。

深入理解後,會極大的提升我們的 OOD 能力和代碼質量。比如我在開篇提到的 ServiceImpl 上帝類的例子,很明顯就是違背了單一職責,你一個類把所有事情都做了,把不是你的功能也往自己身上攬。

所以你的內聚性就會很差,內聚性差將導致代碼很難被複用,不能復用,只能複製(Repeat Yourself),其結果就是一團亂麻。

SOFA 應用架構詳解

再比如在 Java 應用中使用 logger 框架有很多選擇,什麼 log4j,logback,common logging 等,每個 logger 的 API 和用法都稍有不同,有的需要用isLoggable()來進行預判斷以便提高性能,有的則不需要。

對於要切換不同的 logger 框架的情形,就更是頭疼了,有可能要改動很多地方。產生這些不便的原因是我們直接依賴了 logger 框架,應用和框架的耦合性很高。怎麼破?

遵循下依賴倒置原則就能很容易解決,依賴倒置就是你不要直接依賴我,你和我都同時依賴一個介面(所以有時候也叫面向介面的編程),這樣我們之間就解耦了,依賴和被依賴方都可以自由改動了。

SOFA 應用架構詳解

在我們的框架設計中,這種對 SOLID 的遵循也是隨處可見,Service Facade 設計思想來自於單一職責 SRP;擴展點設計符合關閉原則 OCP;

日誌設計,以及 Repository 和 Tunnel 的交互就用到了依賴倒置 DIP 原則,這樣的點還有很多,就不一一枚舉了。

當然了,SOLID 不是 OO 的全部。抽象能力,設計模式,架構模式,UML,以及閱讀優秀框架源碼(我們的 Command 設計就是參考了 Activiti 的 Command)也都很重要。

只是 SOLID 更基礎,更重要,所以我在這裡重點拿出來講一下,希望能得到大家的重視。

3.7 領域建模

準確的說 DDD 不是一個架構,而是思想和方法論,關於如何領域建模的詳細請參看我另一篇文章領域建模。

所以在架構層面我們並沒有強制約束要使用 DDD,但對於像我們這樣的複雜業務場景,我們強烈建議使用 DDD 代替事務腳本(TS: Transaction Script)。

因為 TS 的貧血模式,裡面只有數據結構,完全沒有對象(數據 + 行為)的概念,這也是為什麼我們叫它是面向過程的原因。

然而 DDD 是面向對象的,是一種知識豐富的設計(Knowledge Rich Design),怎麼理解?就是通過領域對象(Domain Object),領域語言(Ubiquitous Language)將核心的領域概念通過代碼的形式表達出來,從而增加代碼的可理解性。

這裡的領域核心不僅僅是業務里的 「名詞」,所有的業務活動和規則如同實體一樣,都需要明確的表達出來。

例如前面典型代碼圖中所展示的,分配策略(DistributionPolicy)你把它隱藏在一堆業務邏輯中,沒有人知道它是幹什麼的,也不會把它當成一個重要的領域概念去重視。

但是你把它抽出來,凸顯出來,給它一個合理的命名叫DistributionPolicy,後面的人一看就明白了,哦,這是一個分配策略,這樣理解和使用起來就容易的多了,添加新的策略也更方便,不需要改原來的代碼了。所以說好的代碼不僅要讓程序員能讀懂,還要能讓領域專家也能讀懂。

再比如在 CRM 領域中,公海(PublicSea)和私海(PrivateSea)是非常重要領域概念,是用來做領地(Territory)劃分的,每個銷售人員只能銷售私海(自己領地)內的客戶,不能越界。

但是在我們的代碼中卻沒有這兩個實體(Entity),也沒有相應的語言和其對應,這就導致了領域專家描述的,和我們日常溝通的,以及我們模型和代碼呈現的都是相互割裂的,沒有關聯性。

這就給後面系統維護的同學造成了極大的困擾,因為所有關於公海私海的操作,都是散落著各處的 repeat itself 的邏輯代碼,導致看不懂也沒辦法維護。

採用領域建模以後,我們在系統中定義了清晰的機會(Opportunity),公海(PublicSea)和私海(PrivateSea)的 Entity,相應的行為和業務邏輯也被封裝到對應的領域實體身上,讓代碼充分展現業務語義,讓曾經散落在各處找到了業務代碼找到了屬於它們自己的家,它們應該在的地方。

相信我,這種代碼可讀性的提升,會讓後來接手系統的同學對你心懷感恩。下面就是我們重構後 Opportunity 實體的代碼,即使你對 CRM 領域不了解,是不是也很容易看懂。

public class OpportunityE extends Entity{ @Getter
@Setter
private String customerId; /**
* 機會類型
*/
@Getter
@Setter
private OpportunityType opportunityType; /**
* 機會來源
*/
@Getter
@Setter
private String origin; /**
* 是否可以撿入
* @return
*/
public boolean canPick(){ return "y".equals(canPick) && opportunityStatus == OpportunityStatus.NEW || opportunityStatus == OpportunityStatus.ACTIVE;
} /**
* 是否可以開放
* @return
*/
public boolean canOpen(){ return (opportunityStatus == OpportunityStatus.NEW || opportunityStatus == OpportunityStatus.ACTIVE)
&& CommonUtils.isNotEmpty(ownerId);
} /**
* 撿入機會到私海
* @param privateSea
*/
public void pickupTo(PrivateSeaE privateSea){
privateSea.addOpportunity(this);
} /**
* 從私海開放出去
* @param privateSea
*/
public void openFrom(PrivateSeaE privateSea){
privateSea.removeOpportunity(this);
} /**
* 機會轉移
* @param from
* @param to
*/
public void transfer(PrivateSeaE from, PrivateSeaE to){
from.removeOpportunity(this);//從一個私海移出
to.addOpportunity(this);//添加到另一個私海中
}

如果整個系統都採用 DDD,不僅代碼的可讀性和系統的可維護性會大大提升,系統之間的邊界和交互也會更加的清晰。下圖是 CRM 域的簡要領域模型,基本上可以完整的表達 CRM 領域的核心概念:

SOFA 應用架構詳解

3.8 分層設計

這一塊的設計比較直觀,整個應用層劃分為三個大的層次,分別是 App 層,Domain 層和 Infrastructure 層。

  • App 層主要負責獲取輸入,組裝 context,做輸入校驗,發送消息給領域層做業務處理,監聽確認消息,如果需要的話使用 MetaQ 進行消息通知;
  • Domain 層主要是通過領域服務(Domain Service),領域對象(Domain Object)的交互,對上層提供業務邏輯的處理,然後調用下層 Repository 做持久化處理;
  • Infrastructure 層主要包含 Repository,Config,Common 和 message,Repository 負責數據的 CRUD 操作,這裡我們借用了盒馬的數據通道(Tunnel)的概念,通過 Tunnel 的抽象概念來屏蔽具體的數據來源,來源可以是 MySQL,NoSql,Search,甚至是 HSF 等;Config 負責應用的配置;Common 是一寫工具類;負責 message 通信的也應該放在這一層。

SOFA 應用架構詳解

這裡需要注意的是從其他系統獲取的數據是有界上下文(Bounded Context)下的數據,為了彌合 Bounded Context 下的語義 Gap,通常有兩種方式,一個是用大領域(Big Domain)把兩邊的差異都合起來,另一個是增加防腐層(Anticorruption Layer)做轉換。

什麼是 Bounded Context? 簡單闡述一下,就是我們的領域概念是有作用範圍的(Context)的,例如搖頭這個動作,在中國的 Context 下表示 NO,但是在印度的 Context 下卻是 YES。

4. 規範設計

我們規範設計主要是要滿足收納原則的兩個約束:

4.1 放對位置

東西不要亂放,我們的每一個組件(Module),每一個包(Package)都有明確的職責定義和範圍,不可以放錯,例如 extension 包就只是用來放擴展實現的,不允許放其他東西,而 Interceptor 包就只是放攔截器的,validator 包就只是放校驗器的。我們的主要組件如下圖:

SOFA 應用架構詳解

組件裡面的 Package 如下圖:

SOFA 應用架構詳解

4.2 貼好標籤

東西放在合適位置後還要貼上合適的標籤,也就是要按照規範合理命名,例如我們架構裡面和數據有關的 Object,主要有 Client Object,Domain Object 和 Data Object,Client Object 是放在二方庫中和外部交互使用的 DTO,其命名必須以 CO 結尾,相應的 Data Object 主要是持久層使用的,命名必須以 DO 結尾。

這個類名應該是自明的(self-evident),也就是看到類名就知道裡面是幹了什麼事,這也就反向要求我們的類也必須是單一職責的(Single Responsibility)的,如果你做的事情不單純,自然也就很難自明了。

如果我們 Class Name 是自明的,Package Name 是自明的,Module Name 也是自明的,那麼我們整個應用系統就會很容易被理解,看起來就會很舒服,維護效率會提高很多。

除了組件和包的命名規範以外,我們對類、方法名和錯誤碼做了如下約定:

4.3 類名約定:

SOFA 應用架構詳解

4.4 方法名約定:

層次類方法名約定App層介面服務新增:save

修改:modify

查詢:get(單個)、list(多個、分頁)

統計:count

刪除:removeDomain層Domain實體盡量避免CRUD形式的命名,要體現業務語義Tunnel層Tunnel對象新增:create

修改:update

查詢: get(單個) 、list(多個)、page(分頁)

刪除:delete

統計:count

4.5 錯誤碼約定:

異常主要分為系統異常和業務異常,系統異常是指不可預期的系統錯誤,如網路連接,服務調用超時等,是可以retry的;

而業務異常是指有明確業務語義的錯誤,再細分的話,又可以分為參數異常和業務邏輯異常,參數異常是指用戶過來的請求不準確,邏輯異常是指不滿足系統約束,比如客戶已存在。業務異常是不需要 retry 的。

我們的錯誤碼主要有 3 部分組成:類型+場景+自定義標識。

錯誤類型錯誤碼約定舉例參數異常P_XX_XXP_CAMPAIGN_NameNotNull: 運營活動名不能為空業務異常B_XX_XXB_CAMPAIGN_NameAlreadyExist: 運營活動名已存在系統異常S_XX_ERRORS_DATABASE_ERROR: 資料庫錯誤

5. SOFA 應用架構

經過上面的長篇大論,我希望我把我們的架構理念闡述清楚了,最後再從整體上看下我們的架構吧。

我講這個架構命名為 SOFA,全稱是 Simple Object-oriented and Flexible Architecture,是一個輕量級的面向對象的,可擴展的應用架構,可以幫助降低複雜應用場景的系統熵值,提升系統開發和運維效率。

SOFA 應用架構詳解

目前框架也準備開源,貢獻個社區,讓更多的開發者使用,幫助解決他們各自的業務複雜度。


關於框架源碼和介紹,請移步:

http://gitlab.alibaba-inc.com/b2bcrm/sofa

5.1 整體架構

整體架構是遵循高內聚,低耦合,可擴展,易理解的知道思想,儘可能的貫徹 OO 的設計思想和原則。

我們最終形成的架構是集成了擴展點 + 元數據 + CQRS+DDD 的思想,關於元數據前面沒怎麼提到,這裡稍微說一下,對於欄位擴展,簡單一點的解決方案就是預留擴展欄位,複雜一點的就是使用元數據引擎。

使用元數據的好處是不僅能支持欄位擴展,還提供了豐富的欄位描述,等於是為以後的 SaaS 化配置提供了可能性,所以我們選擇了使用元數據引擎。

和 DDD 一樣,元數據也是可選的,如果對沒有欄位擴展的需求,就不要用。最後的整體架構圖如下:

SOFA 應用架構詳解

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 程序員小新人學習 的精彩文章:

Discuz API JSON 適用於IOS及Android移動端開發
用python實現小豬佩奇

TAG:程序員小新人學習 |