如何運用領域驅動設計 - 聚合

来源:https://www.cnblogs.com/uoyo/archive/2019/12/18/12061334.html
-Advertisement-
Play Games

[toc] 概述 在前幾篇的博文中,我們已經學習到瞭如何運用實體和值對象。隨著我們所在領域的不斷深入,領域模型變得逐漸清晰,我們已經建立了足夠豐富的實體和值對象。但隨著實體和值對象的數量逐漸增多,它們之間的關係也顯得越來越複雜:實體A與實體B存在一對一的關係,實體B又與實體C存在一對多的關係。就這樣 ...


目錄

概述

在前幾篇的博文中,我們已經學習到瞭如何運用實體和值對象。隨著我們所在領域的不斷深入,領域模型變得逐漸清晰,我們已經建立了足夠豐富的實體和值對象。但隨著實體和值對象的數量逐漸增多,它們之間的關係也顯得越來越複雜:實體A與實體B存在一對一的關係,實體B又與實體C存在一對多的關係。就這樣一層套一層,本來約束已經足夠好的領域對象們彷佛已經開始對我們不太友好。為了處理這一系列的問題,我們需要將一些實體和值對象劃分在一個統一的邊界內,原來存在多重關聯關係的大模型被分解為較小的領域對象群。

而這種強有力的劃分手法就是領域驅動設計戰術模式中的“聚合”。可能大家已經聽過它的一個重要部分“聚合根”,那麼我們什麼情況下考慮使用聚合根呢?聚合根又是從什麼地方來?聚合與實體之間又有什麼關係?如何確定和劃分一個合理的聚合?本文將從不同的角度來帶大家重新認識一下“聚合”這個概念,並且給出相應的代碼片段(本教程的代碼片段都使用的是C#,後期的實戰項目也是基於 DotNet Core 平臺)。

何為聚合

還是先來看看原著《領域驅動設計:軟體核心複雜性應對之道》 中對聚合的有關解釋:

在具有複雜關聯的模型中要想保證對象更改的一致性是很困難的。不僅互不關聯的對象需要遵守一些固定規則,而且緊密關聯的各組對象也要遵守一些固定規則。然而,過於謹慎的鎖定機制又會導致多個用戶之間臺無意義地互相干擾,從而使系統不可用。
首先,我們需要用一個抽象來封裝模型中的引用。AGGREGATE就是一組相關對象的集合,我們把它作為數據修改的單元。每個AGGREGATE都有一個根(root)和一個邊界(boundary).邊界定義了AGGREGATE的內部都有什麼。根則是AGGREGATE中所包含的一個特定Entity。在AGGREGATE中,根是唯一允許外部對象保持對它的引用的元素,而邊界內部的對象之間則可以互相引用。除根以外的其他Entity都有本地標識,但這些標識只有在AGGREGATE內部才需要加以區別,因為外部對象除了根Entity之外看不到其他對象。

演化案例

還記得我們在上一篇博文 如何運用領域驅動設計 - 實體 中所展開的一個關於旅行記賬的案例嗎? 在學習實體的時候,我們已經構建了一個叫做Itinerary的實體,並且賦予了它應用的行為操作。 到目前為止,我們那個案例好像還和主題離的稍微有點遠,我們雖然實現了行程這個東西,但是怎麼記賬呢?

接下來,讓我們完善這個案例,讓它更貼近於我們真實的項目需求:

當用戶創建一個行程時,則證明該旅程的賬單已經被開啟了。創建該行程的用戶被認定為管理員,他可以添加參與該行程的小伙伴。所有參與行程的小伙伴,都可以在旅行的過程中記賬(比如小伙伴C和小伙伴A吃了一頓火鍋花了300塊錢,小伙伴C則可以記入本筆開銷,而該筆開銷的參與者是小伙伴C和A),當大家旅行完成了之後就可以進行結算,講費用平攤到每個人身上,誰需要補錢,誰需要退錢等都可以被該應用計算出來。

這是簡化後的版本,為的是希望大家能大致明白我們需要做一個什麼樣的東西,並且如何用我們所學到的領域驅動設計知識來建模和編碼,為了讓大家更清晰的理解需求,我粗淺的為大家繪製了一個原型圖:

發現實體關係

根據需求描述,再結合我們已有的領域設計知識,我們馬上就能找出另外一個重要的實體對象出來。沒錯,那就是賬單。在這個案例中,我們暫定將賬單命名為記賬薄Account book)。在第二個原型圖中,我們大致能夠理解記賬薄是一個什麼東西,它記錄了行程中所有的開銷內容和開銷金額。這一行一行的開銷信息,我們將它命名為開銷項Overhead item)。這裡為了簡化起見,我們忽略了每條開銷項中的其它信息,例如參與人員,參與地點等等。

接下來,我們來分析已經發現的兩個事物:記賬薄開銷項。先來說開銷項吧,它是屬於實體還是值對象呢?結合前兩篇博文中我們說學到的內容,它需要一個ID來辨識它嗎? 也許還是有些困惑,因為好像它不像性別、姓名這一類東西具有很明顯的無ID特征。所以我們需要來識別該對象擁有的屬性:開銷內容、開銷金額、開銷時間。“在2019年10月12日,買了一個冰糕花費了3元人民幣”,在我們當前的領域,我們需要使用一個ID來區分它嗎?很顯然我們是需要的,我們不能說只要在同一時間花了同樣的錢買了同樣的東西就是一樣的東西了,比如用戶A在行程A中和用戶B在行程B中同時間同樣的錢買了同樣的東西,我們會認為是一樣的嗎?很顯然,不能。所以開銷項是一個實體。那麼記賬薄呢?很顯然,它也是一個實體,我們需要通過ID來識別到底是哪個記賬薄。

此時我們已經捕獲出了兩個實體對象:記賬薄開銷項。而且可以清楚的看到,它們之間是一個一對多的關係。然後來嘗試將它們轉換為我們熟悉的C#代碼吧:


public class AccountBook
{
    public Guid ID { get; private set; }

    public List<OverheadItem> OverheadItems {private get;private set; }

    //ctor

    // 記賬薄的行為
    // ....
}

public class OverheadItem
{
    public Guid ID { get; private set; }

    //開銷內容
    public OverheadContent Content { get;private set; }

    //開銷金額
    public OverheadMoney Money { get;private set; }

    // ctor

    // 開銷項目的行為
    // ....
}

public class Itinerary
{
    public int ID { get; set; }

    public List<Person> Participants { get;private set; }

    public List<Address> Places { get;private set; }

    public ItineraryNote  Note { get;private set; }

    public ItineraryTime TripTime { get;private set; }

    public ItineraryStatus Status { get;private set; }

    //ctor

    // 行程的行為
    // ....
}

OK,此時我們已經完成了記賬有關的模型。再來回顧我們之前的行程實體模型:“當旅程建立的時候,則證明該旅程的賬單已經被開啟了”,因此我們可以看出,旅程和賬薄是連接在一起的,一個旅程就對應著其擁有的對應賬薄,所以它們是一個一對一的關係。

到目前為止,我們擁有了三個比較明顯的實體:旅程、記賬薄、開銷項目,還有該領域中很多大大小小的值對象。旅程和記賬薄是一對一的關係,記賬薄和開銷項目是一對多的關係。多讀一下它們之間的關聯關係,唉!!!好累,那是不是再引入一個領域對象進來,就會讓它們之間的關係更複雜呢?這樣一層繞一層,就仿佛滾毛線球一樣,越理越亂了。

開始劃分邊界吧

我根據目前所涉及的領域對象,大致繪了一個領域之間的圖,當然這個圖並不是規範的,裡面缺少了很多我們已經捕獲出來的值對象等等,它只是為了幫助你大致回顧一下我們目前所Get到的領域模型結果:

域關係圖

圖中將“旅行記賬”的部分於“推薦”的部分用了方塊給隔離開來,這個結果我想大家也很容易理解,因為有關推薦的這些東西,比如推薦餐館呀,推薦花店呀對我們的旅行記賬來說並沒有太大的關係。關係域於關係域中,我們通過劃分了一個合理的邊界來隔離它們,那麼反過來思考,一個域中的各個領域對象,我們能不能通過一個什麼手段來劃分它們呢?將它們通過邊界的隔離,實現區域內的自治,這樣更方便我們來處理它們之間的邏輯關係。

假如用戶想查看當前行程的記賬薄,按照常規處理我們會怎麼辦呢? 用戶會訪問有關記賬薄的倉儲(倉儲的有關概念將在下一篇文章講解),獲取到當前記賬薄。此時,用戶獲取到了賬薄的有關信息,比如開銷項啊,總開銷金額啊等等,但是對用戶來說,它是很迷茫的,因為它僅僅獲取到了賬薄的信息,它不知道這個賬薄屬於哪次行程,所以它必須又得去獲取一下行程的信息。而這種場景往往都是一起出現的,你只要獲取賬薄你就必須要獲取行程。

可能你已經發現了,它們其實可以是一體的。就像開銷項和記賬薄是一體的一樣,行程記賬薄這兩個大實體居然也是可以是一體的。而這種關係,就是我們今天的主題——“聚合”。

我們可以將旅行行程、記賬薄、行程人員、開銷項、行程時間等一系列有關的對象都劃分在行程的邊界內,因為確確實實它們是屬於行程的,一旦脫離了行程它們好像都沒有任何意義。

選取一個聚合根

行程記賬薄是一體的,且它們是一對一的關係。如果將這個關係轉換為我們熟悉的代碼,我們需要將一個類作為另一個類的屬性,那麼在這個案例中,我們是用行程包含記賬薄,將記賬薄作為屬性呢?還是記賬薄包含行程呢? 你也許會說,它們可以相互包含。確實,現在的ORM框架可以運行你將兩者互相包含並映射到資料庫,但是在這裡我們沒有必要這麼做,因為我們已經知道,它們是一個整體,獲取一者另外一者同樣會被獲取到,不需要再次嵌套。回到剛纔那個問題,是誰在外層呢?其實答案也很清晰了,因為從該例子來說,我們更關註的是行程,所以我們很自然的就會將行程作為主要的實體對象,而在這個聚合關係中,被我們選取出來作為邊界範圍的實體就是我們所說的聚合根。

此時我們的代碼可能已經可以改變成這樣了:

public class Itinerary
{
    public int ID { get; set; }

    public List<Person> Participants { get;private set; }

    public List<Address> Places { get;private set; }

    public ItineraryNote  Note { get;private set; }

    public ItineraryTime TripTime { get;private set; }

    public ItineraryStatus Status { get;private set; }

    //將記賬薄放置在了旅行中
    public AccountBook AccountBook{get;private set;}

    //ctor

    // 行程的行為
    // ....
}

通過聚合根保護你的內部對象

當識別出了一個聚合根的時候,就要保證該聚合的內部是自治的。我們不能從外界直接訪問聚合根內部的任何領域對象,比如在上面的案例中,我們則不能直接記賬薄這個實體。如果我們確確實實需要獲取記賬薄中的有關信息,我們必須通過聚合根,也就是上面的行程來訪問。也就是說我們得從倉儲中獲取行程後再來得到記賬薄的有關信息。

此時,你可能會說,那這樣不就會很麻煩了嗎?我只要記一筆賬,但我必須要得到旅程的所有信息。這樣資料庫和應用程式不是增加了一些壓力嗎? 是的,這樣做我們會將更多的數據載入到記憶體之中來。但是這是合理的,回顧剛纔一下上面的案例,我們有什麼情況下需要只獲取賬薄,不獲取旅程信息呢? 是的,沒有,它們永遠是一起出現的。

當聚合內部的對象無法直接訪問的時候,很顯然也不能直接調用該對象所公開出來的行為了。比如記賬薄可能會擁有一個叫做“記一筆賬(RecordAnAccount)”的行為,我們通過訪問該行為操作就可以將開銷項增加到記賬薄中。但是現在我們不能直接訪問記賬薄了,我們怎麼記賬呢? 通過轉移行為給聚合根來完成,比如我們會將該行為轉移到行程中,並公佈一個叫做“記錄行程中一筆賬”的行為供客戶端調用。

public class Itinerary
{
    //Other Property....

    public List<Person> Participants { get;private set; }

    public AccountBook AccountBook{get;private set;}

    //ctor

     public void RecordAnAccountInItinerary(
         int PersonID,
         string itemName,
         double costMoney)
    {
        bool hasThisPerson = Participants.Any(Person=>Person.ID = PersonID);

        if(!hasThisPerson)
            throw new PersonNotInThisItineraryException();

        AccountBook.RecordAnAccount(itemName,costMoney);
    }
}

這樣一來,聚合根內部的所有對象都不會被外界肆意訪問,而且通過聚合根所表達出來的行為也更容易讓人能夠理解。

聚合的一些特性

到了現在,再回頭去看一下概述中原著對聚合概念的闡述。我們可以已經大致理解了什麼是聚合,聚合根又是怎麼來的:

  • 聚合是一個明確的邊界
  • 聚合的出現是為瞭解決領域模型之間的複雜關聯關係的
  • 聚合封裝了一系列的相關對象,它是這些對象的集合
  • 聚合應該有一個根,並且這個根是通過集合中的一個實體選出來的
  • 聚合外部的事務想引用聚合只能通過根的ID來訪問

再來給大家舉一個原著中的例子,加深印象:汽車修配廠的軟體可能會使用一個汽車模型。汽車是一個具有全局標識的ENTITY:我們需要將這部汽車與世界上所有其他汽車區分開(即使是一些非常相似的汽車),我們可以使用車輛識別號來進行區分,車輛識別號是為每輛新汽車分配的唯一標識符。我們可能想跟蹤4個輪胎的歷史轉數。我們可能想知道每個輪胎的里程數和磨損度。要想知道哪個輪胎在哪兒,必須將輪胎標識為Entity。輪胎被安在汽車上,也不會有人要系統中查詢特定的輪胎,然後看看這個輪胎在哪輛汽車上。人們只會在資料庫中查找汽車,然後臨時查看一下這部汽車的輪胎情況,因此,汽車聚合中的根Entity,而輪胎只是處於這個聚合的邊界之內。

作為一名普通的手機用戶,當屏幕摔碎的時候,他會選擇將整個手機送至維修中心。因為對他來說手機是一個整體。會不會有人自己把屏幕單獨送去維修中心呢?有吧,可能他是維修師傅。

通過ID引用

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }

    public ICollection<Enrollment> Enrollments { get; set; }
}

public class Enrollment
{
    public int EnrollmentID { get; set; }
    public int CourseID { get; set; }
    public int StudentID { get; set; }
    public Grade? Grade { get; set; }

    public Course Course { get; set; }
    public Student Student { get; set; }
}

該代碼摘自aspnetcore基礎教程

這樣的代碼是很常見的,許多開發人員都找到了一種自然方式在代碼中將關係建模為對象引用。特別是在使用EF Core中,我們會很自然的將不同對象之間的關係通過對象引用來表示。這是因為我們以往並沒有聚合的這一概念,所以我們要完成一個關聯的操作就需要載入所有的關聯對象然後通過遍歷一個一個的實例對象來處理。很顯然,這會造成性能上的浪費,雖然我們可以使用延遲載入的技術來處理,但是延遲載入會讓模型的處理更加複雜。

在上面的例子中,假如我們需要知道這個行程創建的管理員用戶是誰。我們會怎麼處理呢?管理員用戶被抽象為了一個單獨的聚合根User,該聚合包含了User所有的信息(身份,姓名,性別等等)。我們會在Itinerary聚合根中添加一個類型為User的屬性作為管理員嗎?不需要,在該領域中,我們為什麼要關心管理員的其他信息呢,以至於每次載入行程都還需要帶出用戶的信息。很顯然,我們在Itinerary聚合中並不會關心另外聚合的情況。所以,當一個聚合需要引用到另外一個聚合的時候,千萬不要直接使用類型的強引用方式來實現,而是通過使用引用聚合的ID來維持聚合與聚合的關係

這樣做的好處在分散式系統中更容易體現,旅程和用戶這兩者往往會被放在不同的系統中,旅程邊界中根本就找不到一個叫做User的實體,而它們之間的引用關係只能通過ID來標記。

聚合真的是不變的嗎

可能我們通過分析領域模型,已經建立了一個相對來說很好的聚合了,並且提取出了聚合根,將領域對象控制在聚合根的內部。但是?聚合根裡面的實體就永遠存在聚合根之內嗎?答案是不一定的。我們之所以將實體放置在聚合根之內是因為我們知道他與聚合根是一體的,外界訪問該實體的時候一定會攜帶上訪問聚合根實體。但是!!!!!假如我們需求的變更讓我們確確實實需要單獨訪問目前聚合根裡面的實體呢? 是的,它可能會被單獨提升為一個聚合根。而且通過ID之間的引用保持對原有聚合根之間的關聯關係。

所以考慮聚合根的重要一點是:在領域中我們是否會單獨訪問該實體

小的聚合

有時候,聚合的優勢可能會成為糖衣炮彈,它會讓你瘋狂的將大量的實體和值對象融入在其中去,最後的結果是造成聚合越來越大。這樣會造成性能的瓶頸,特別是在某個實體存在大量結果的情況下,這簡直是一個噩夢。所以在考慮聚合之前,我們要多思考,我們是否將聚合設計的過大了。

哪怕在某個領域設計出來的聚合是正確的,我們有時候也會拆分它。原因很簡單,性能問題。當聚合A中的實體EntityA存在大量數據的時候,我們訪問聚合A不得不去載入它們,這樣會讓性能造成大量損失。哪怕建模的結果是正確的,但是我們還是會考慮折中的辦法,將EntityA提升為一個單獨的聚合供外界單獨訪問。

一致性

聚合中的所有對象都應該保持一致的變更,這是毫無疑問的。因此一個聚合在持久化的時候理應在一個事務中完成。但是當一個業務用例可能會操作多個聚合的時候,修改了聚合A的同時也更改了聚合B,這是一個很常見的操作,我們也必須保證多個聚合之間的一致性。這在單體應用中很容易實現,但是在分散式系統中我們不得不考慮最終一致性。有關分散式的相關信息將在後期 《分散式中的領域驅動設計》 系列中講述。

總結

本次我們介紹了有關領域驅動設計中“聚合”的內容,我們知道了什麼是聚合根,已經聚合根與實體之間的關係,以及怎麼去考慮設計一個聚合根。在實際的項目中,其實聚合根是一個非常常見的領域對象,因為我們大量的業務邏輯和表達都是通過聚合根來完成操作的。回顧一下你現在正準備嘗試或者已經在寫的DDD項目,你使用聚合根了嗎?你又是怎麼來表達聚合根的?

下一期的文章中,是關於倉儲的,它與聚合根其實有密不可分的關係。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 語法 傳值與傳引用 Python參數傳遞採用的是“傳對象引用”的方式。這種方式相當於傳值和傳引用的一種綜合。 如果函數收到的是一個可變對象(比如字典或者列表)的引用,就能修改對象的原始值--相當於通過“傳引用”來傳遞對象。 如果函數收到的是一個不可變對象(比如數字、字元或者元組)的引用,就不能直接修 ...
  • 爬蟲與反爬 爬蟲:自動獲取網站數據的程式,關鍵是批量的獲取。 反爬蟲:使用技術手段防止爬蟲程式的方法 誤傷:反爬技術將普通用戶識別為爬蟲,從而限制其訪問,如果誤傷過高,反爬效果再好也不能使用(例如封ip,只會限制ip在某段時間內不能訪問) 成本:反爬蟲需要的人力和機器成本 攔截:成功攔截爬蟲,一般攔 ...
  • 一、操作redis redis是一個key-value存儲系統,value的類型包括string(字元串),list(鏈表),set(集合),zset(有序集合),hash(哈希類型)。為了保證效率,數據都是緩衝在記憶體中,在處理大規模數據讀寫的場景下運用比較多。 備註:預設redis有16個資料庫, ...
  • 一、前提 多台客戶端 / 伺服器 之間傳遞實體類的序列化對象 需要實現四個類,即伺服器類,線程類,客戶端類及實體類 註:實體類需實現介面:implements Serializable 二、伺服器類 伺服器類,需要實現兩個類:ServerSocket 和 Socket 。且 ServerSocket ...
  • 詳細使用教程 1、沒安裝Python的小伙伴需要先安裝一下 2、win+r輸入cmd打開命令行,輸入:pip install baidu-aip,如下安裝百度AI的模塊。 3、新建文本文檔,copy如下代碼,然後另存為py尾碼的文檔即可,小編的命名為:test.py。 from aip import ...
  • [toc] kratos微服務框架學習筆記一(kratos demo) 今年大部分時間飄過去了,沒怎麼更博和github,現在開發任務也差不多完成了,會比較輕鬆,考慮到今後發展,打算看看微服務框架。 常見微服務框架主要有這麼幾個 , a microservice toolkit from The N ...
  • 項目需要引用NPOI的Nuget包:DotNetCore.NPOI-v1.2.2 本篇文章是對WebAPI項目使用NPOI操作Excel時的幫助類:ExcelHelper的改進優化做下記錄: 備註:下麵的幫助類代碼使用的文件格式為:xlsx文件,xlsx相對xls的優缺點代碼里有註釋,推薦使用xls ...
  • 時間如流水,只能流去不流回! 點贊再看,養成習慣,這是您給我創作的動力! 本文 Dotnet9 https://dotnet9.com 已收錄,站長樂於分享dotnet相關技術,比如Winform、WPF、ASP.NET Core等,亦有C++桌面相關的Qt Quick和Qt Widgets等,只分 ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...