【開源】OSharp框架解說系列(5.1):EntityFramework數據層設計


OSharp是什么?

  OSharp是個快速開發框架,但不是一個大而全的包羅萬象的框架,嚴格的說,OSharp中什么都沒有實現。與其他大而全的框架最大的不同點,就是OSharp只做抽象封裝,不做實現。依賴注入、ORM、對象映射、日志、緩存等等功能,都只定義了一套最基礎最通用的抽象封裝,提供了一套統一的API、約定與規則,並定義了部分執行流程,主要是讓項目在一定的規范下進行開發。所有的功能實現端,都是通過現有的成熟的第三方組件來實現的,除了EntityFramework之外,所有的第三方實現都可以輕松的替換成另一種第三方實現,OSharp框架正是要起隔離作用,保證這種變更不會對業務代碼造成影響,使用統一的API來進行業務實現,解除與第三方實現的耦合,保持業務代碼的規范與穩定。

本文已同步到系列目錄:OSharp快速開發框架解說系列

前言

  數據層設計真是一個百說不厭的話題,大系統說並發量,說高性能;小系統追求開發效率,易維護性各有各的追求。

  OSharp 開發框架的定位是中小系統, 數據層的開發效率與易用性的權重就比較高了,所以,使用ORM當然是首選。在 .net 環境下,有眾多的閉源的開源的優秀的ORM組件,從各方便對比來看,EntityFramework 是不二之選。一提起 EntityFramework,不少同學又要蠢蠢欲動來吐槽其性能了。其實,經過幾個版本的更新換代,現在的穩定版 EntityFramework 6 已經相當好用了,nuget 上截止到目前 “8,830,918 total downloads” 已經足夠能說明問題了,EntityFramework 在整個 .net 世界是相當受歡迎的。不過,不管哪個技術平台,能不能用好一個技術與技術水平有很大的關系,如果沒追求,隨處的 select * from xxx,傳說中高性能的 ado.net 也不會高到哪去。

為什么強依賴於EntityFramework

  在本系列開篇《總體設計》對 OSharp 開發框架的數據存儲組件的介紹時,就強調了 OSharp 將“強依賴”於 EntityFramework 這個數據訪問組件。當時就有人提出了“強依賴Entity framework是個坑吧”的疑問。這里簡要解釋一下為什么 OSharp 不打算做成兼容各種數據訪問方案(EF,NH,ado.net 等等)。我們來看看統一各個ORM需要付出的代價:

  • 需要統一標准,業務層與數據層之間的數據交互載體只能是POCO數據實體,如果個別ORM需要對實體有特殊要求(如NH要求實體屬性為virtual),需要在ORM實現內部進行適配轉換。
  • 數據交互只能是實體,這就導致了參數數據查詢的所有 sql 語句都是“select * from ...”這種方式,這將給系統性能帶來傷害。
  • 要兼容各個ORM,數據層只能提供一些能普遍適應的 API,放棄各種 ORM 的優點與特色(如EF的linq查詢)

  基於上面的一些理由,我們發現要兼容各種數據訪問方案,需要付出的代碼是很大的,很不划算。因此,OSharp 將專注於一款 ORM,從各方面比較,EntityFramework 是一個好選擇,理由如下:

  • EntityFramework 是微軟大力發展的一個開源項目,EF 6 在 codeplex.com 開源,EF 7 在 github.com 隨 ASP.NET 5 開源。
  • EntityFramework 能輕松支持各大主流數據庫,只要引入相應數據庫的 DataProvider 即可,能無差異的操作各大主流數據庫。
  • EntityFramework 支持 linq to entities 語句查詢,強類型支持,高效實現查詢需求。
  • EntityFramework 全面封裝了數據庫細節,使用了大量的“約定勝於配置”的思想,使開發者不必直接對關系存儲架構編程,減少代碼量,減輕維護工作,並使項目可維護性更高。

為什么要封裝EntityFramework

  在計划使用EntityFramework來在項目中實現數據存儲時,遇到的第一個問題就是:怎樣來使用EntityFramework?要不要對EntityFramework進行二次封裝? 

  反對對 EntityFramework 進行封裝的同學,通常會有如下理由:

  • EntityFramework 提供的API已經足夠簡單了,已經有了非常良好的易用性,沒有再封裝的必要。
  • EntityFramework 內部已經實現了一個 UnitOfWork + Repository 的封裝,沒有必要再包裝一次。
  • EntityFramework 提供的API非常靈活且很有特色,封裝有可能會喪失其靈活性

  我認為,如果是小項目,且所有開發成員都能很好的使用 EntityFramework,在業務層中直接使用,將 EntityFramework 的靈活性發揮出來,也是非常好的。但是,直接使用 EntityFramework 的話,也有不少弊端,對於 OSharp 這樣一個開發框架而言,封裝就顯得非常有必要了,原因如下:

  • 不是所有的開發人員都對 EntityFramework 足夠熟悉,封裝之后能將 EntityFramework 的細節及較敏感的 API 進行包裝與隱藏,使 EntityFramework 的使用更加透明易用。
  • 統一的封裝,有利於對業務層提供統一的 API,對業務層的代碼規范非常有利。
  • 封裝有利於業務實體與 EntityFramework 的解耦。如果不封裝,所有業務實體模型(Model)都要在上下文類中設置一個 DbSet<TEntity> 類型的實體集,將與上下文強耦合,當需求發生變化時,都要對原有代碼進行修改,很不利於維護。而封裝之后,所有的實體模型都是動態加載到上下文類中的,業務實體與 EntityFramework 能夠完全解耦,大大增強系統的可維護性。 

EntityFramework封裝的常見誤區

   在 EntityFramework 的發展過程中,很多使用者都在抱怨 EntityFramework 性能低下,其實很多時候都是因為對 EntityFramework 沒有足夠的了解,走進了誤區所致。那么,EntityFramework 會有哪些誤區呢?這里我列幾個我所了解的“坑”,歡迎補充。

錯誤使用返回類型,不了解 IEnumerable<T> 與 IQueryable<T> 的區別

  在設計數據訪問層的查詢API的時候,IEnumerable<T> 和 IQueryable<T> 都可以作為集合類查詢結果的返回類型,那么,這兩者有什么區別呢?為什么誤用的時候會造成致命的性能問題呢?

  IEnumerable<T> 接口的聲明為: 

1 /// <summary>
2 /// 公開枚舉數,該枚舉數支持在指定類型的集合上進行簡單迭代。
3 /// </summary>
4 public interface IEnumerable<out T> : IEnumerable

  IQueryable<T> 接口的聲明為:

1 /// <summary>
2 /// 提供對數據類型已知的特定數據源的查詢進行計算的功能。
3 /// </summary>
4 public interface IQueryable<out T> : IEnumerable<T>, IQueryable, IEnumerable

  在進行查詢的時候,IEnumerable<T> 接口接受一個 Func<T, bool> 類型的委托參數: public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate); ,而 IQueryable<T> 接口接受一個 Expression<Func<T, bool>> 類型的表達式參數: public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate); 。

  正因為 IEnumerable<T> 接受的參數 predicate 數據類型是委托類型,所以這個參數在被調用的時候,就會立即執行查詢邏輯,然后將查詢的結果保存在內存中,后續的查詢邏輯是完全在內存中執行的。而 IQueryable<T> 接受的參數 predicate 數據類型是表達式類型,這個參數會一直往下傳遞,直到被 IQueryable 中的 IQueryableProvider 類型的 Provider 屬性解析成真正的查詢語句(如 sql 語句),才傳到數據源中進行查詢動作。

  所以,如果查詢返回的數據集合很大的時候,使用 IEnumerable<T> 作為返回類型,會將這個數據集合立即加載到內存中,比如在設計 IRepository<T> 的 API 時,設計  IEnumerable<T> GetAll(); , IEnumerable<T> GetByPredicate(Func<T, bool> predicate); 這種 API ,是非常可怕的,如果一個表中有幾十上百萬的數據,也同樣會把所有數據加載到內存中,可能直接就導致服務器宕機了。即使數據量不大,當並發量上來的時候,也同樣會造成極大的性能問題。  

過早地內存化數據

  IEnumerable<T> 類型與 IQueryable<T> 類型是支持延遲的,沒有真正使用數據之前,不管怎么調用,都不會執行查詢,數據還是在數據庫內,只有真正使用數據的時候,才會執行查詢,把數據本地化到內存中。這樣一來,什么時候執行本地化操作(ToArray(),ToList()等操作)就顯得非常重要了,如果過早的執行本地化操作,那么就容易造成加載到內存的數據集合過於龐大,記錄條數過多,造成性能問題。因此,在進行數據查詢的時候,原則上應該按需獲取數據,取出的數據集合就盡量的小,字段應盡量少,到數據真正使用的時候,才執行數據內存本地化操作。對於篩選部分字段的需求,linq to entities 的 select 查詢匿名結果的查詢方式,提供了有力的支持。

導航屬性的延遲加載與循環

  EntityFramework 實體模型的導航屬性(即與當前表有外鍵關系的關聯表)通常標記為 virtual,標記為 virtual 之后,相應屬性的數據是具有延遲加載的特性的,只有真正用到相應屬性的數據時,才會根據外鍵關系執行相應的查詢動作,加載相應的數據。延遲加載的特性,能給系統性能帶來優化,因為加載主干實體時只加載主干實體的信息,不會把關聯實體的信息都加載進來,關聯實體的數據只有用到的時候都會去加載。但也正是因為延遲加載,導航屬性的數據是用到一次就執行一次查詢動作,加載一次數據,一次還如,如果對於相同實體,需要多次用到同一個導航屬性,就會產生多次重復的查詢動作來加載導航屬性的數據,給系統帶來性能問題。例如如下的操作:

  

  正確的做法,當需要在循環中使用導航屬性時,應在循環之前加載主干實體數據時,把導航屬性的數據使用 Include 查詢一起加載:

  

數據查詢方式

  EntityFramework 給我們提供了直接使用 實體對象 的眾多查詢 API,如果我們在實現業務的時候,使用直接操作 實體對象 的方式,同樣也會給系統造成性能問題。因為 EntityFramework 每執行一次查詢,都會將實體的所有字段取出來,等同於每次都執行“ select * from xxx ” 的查詢語句。更可怕的是導航屬性的使用,因為導航屬性的數據類型通常都定義為 ICollection<T> 或者 ICollection<T> 的派生類型,ICollection<T> 類型是什么?內存集合類型!也就是說,當我們直接調用 entity.NavigationProperties 這樣一個導航屬性的時候,會將 NavigationProperty 這個關聯表的“所有數據”都加載到內存中。想想,如果這個關聯表的數據幾十上百萬的話,那將是多么可怕的性能災難!!

  那么,怎樣編寫數據查詢的代碼呢,個人認為應該分析場景:

  1. 如果查詢出來的數據需要進行更新,刪除等操作,就使用實體查詢的方式。
  2. 如果查詢出來的數據不需要再存回到數據庫中,只是為了業務判斷使用,則是按需要使用 Select 進行匿名對象查詢的方式來只查詢出必要的實體屬性。

  PS:關於 EntityFramework 數據查詢的更多內容,歡迎閱讀 架構系列的《數據查詢》篇。 

怎樣設計 EntityFramework 數據層

   前面說了那么多誤區,那怎樣來設計 EntityFramework 的數據訪問層 API呢?這個話題在前面的 架構系列 中也討論過了,但為了全面了解 OSharp 開發框架,這里不免要添點新料再炒次舊飯。

業務實體模型基類 EntityBase

   為了能在底層對所有的實體模型類進行統一管理,並規范實體類必要的屬性設定,定義了一個如下的 實體模型基類 EntityBase<TKey>。為適應不同主鍵數據類型的需求,定義了一個泛型 Id 類型,在各個實際實體模型中可以設置實體的主鍵數據類型。

 1 /// <summary>
 2 /// 可持久化到數據庫的數據模型基類
 3 /// </summary>
 4 /// <typeparam name="TKey"></typeparam>
 5 public abstract class EntityBase<TKey>
 6 {
 7     protected EntityBase()
 8     {
 9         IsDeleted = false;
10         CreatedTime = DateTime.Now;
11     }
12 
13     #region 屬性
14 
15     /// <summary>
16     /// 獲取或設置 實體唯一標識,主鍵
17     /// </summary>
18     [Key]
19     public TKey Id { get; set; }
20 
21     /// <summary>
22     /// 獲取或設置 是否刪除,邏輯上的刪除,非物品刪除
23     /// </summary>
24     public bool IsDeleted { get; set; }
25 
26     /// <summary>
27     /// 獲取或設置 創建時間
28     /// </summary>
29     public DateTime CreatedTime { get; set; }
30 
31     /// <summary>
32     /// 獲取或設置 版本控制標識,用於處理並發
33     /// </summary>
34     [ConcurrencyCheck]
35     [Timestamp]
36     public byte[] Timestamp { get; set; }
37 
38     #endregion
39 
40     #region 方法
41 
42     /// <summary>
43     /// 判斷兩個實體是否是同一數據記錄的實體
44     /// </summary>
45     /// <param name="obj">要比較的實體信息</param>
46     /// <returns></returns>
47     public override bool Equals(object obj)
48     {
49         if (obj == null)
50         {
51             return false;
52         }
53         EntityBase<TKey> entity = obj as EntityBase<TKey>;
54         if (entity == null)
55         {
56             return false;
57         }
58         return Id.Equals(entity.Id) && CreatedTime.Equals(entity.CreatedTime);
59     }
60 
61     /// <summary>
62     /// 用作特定類型的哈希函數。
63     /// </summary>
64     /// <returns>
65     /// 當前 <see cref="T:System.Object"/> 的哈希代碼。
66     /// </returns>
67     public override int GetHashCode()
68     {
69         return Id.GetHashCode() ^ CreatedTime.GetHashCode();
70     }
71 
72     #endregion
73 }

業務單元操作接口 IUnitOfWork

   相比 架構系列 的 數據存儲上下文管理,OSharp 的存儲上下文進行了簡化,OSharp 中的 IUnitOfWork 接口將直接作為 上下文類(DbContext的派生類)的一個接口而存在。其中定義了一個默認關閉的 TransactionEnabled 屬性開關對“是否開啟事務提交”進行管理。在默認的狀態下,事務操作是關閉的(與 EntityFramework 的默認開啟相反),調用 IRepository 對實體進行 增、改、刪 操作,立即向數據庫提交操作,這在進行單步操作的時候,很方便,不用每次都去調用一次 SaveChanges() 操作進行提交。在需要進行事務操作(同時提交多步操作,成功一起成功,失敗一起失敗)時,則需要將 TransactionEnabled 設置為 true,當調用 IRepository 對實體進行 增、改、刪 操作時,會申請更改,但不會向數據庫提交操作,需要在最后手動去調用 UnitOfWork.SaveChanges() 操作進行提交。

 1     /// <summary>
 2     /// 業務單元操作接口
 3     /// </summary>
 4     public interface IUnitOfWork : IDependency
 5     {
 6         #region 屬性
 7 
 8         /// <summary>
 9         /// 獲取或設置 是否開啟事務提交
10         /// </summary>
11         bool TransactionEnabled { get; set; }
12 
13         #endregion
14 
15         #region 方法
16 
17         /// <summary>
18         /// 提交當前單元操作的更改。
19         /// </summary>
20         /// <returns>操作影響的行數</returns>
21         int SaveChanges();
22 
23 #if NET45
24 
25         /// <summary>
26         /// 異步提交當前單元操作的更改。
27         /// </summary>
28         /// <returns>操作影響的行數</returns>
29         Task<int> SaveChangesAsync();
30 
31 #endif
32 
33         #endregion
34     }

實體倉儲數據操作接口 IRepository

   實體倉儲數據操作接口 IRepository 的 API,是整個數據層設計的核心。IRepository 接口被定義為“實體類型、主鍵類型”的雙泛型接口,實體類型限定為前面定義的實體基類 EntityBase<TKey> 的派生類,聲明如下:

1 interface IRepository<TEntity, TKey> : IDependency where TEntity : EntityBase<TKey>

  IRepository 中定義了兩個屬性:

  • IUnitOfWork 類型的單元操作對象 UnitOfWork,此接口主要用於業務層執行事務操作。
  • IQueryable<TEntity> 類型的 Entities,作為向業務層開放的 TEntity 實體類型的 查詢數據源。OSharp 的大部分數據查詢,均通過定義 IQueryable<T> 類型的擴展方法來完成。定義為 IQueryable<TEntity> 類型,即能保證 EntityFramework 組件原有的查詢自由度,又能阻止業務層使用 此數據源 進行 增、改、刪 等業務操作,必須調用 IRepository 中規定的 API 才能完成實體的業務操作。

  在操作 API 上,IRepository 接口主要定義了如下幾種 API:

普通 增、改、刪 業務操作 API

  普通業務操作API主要是對單個或多個實體進行的單個或批量操作API:

 1 /// <summary>
 2 /// 插入實體
 3 /// </summary>
 4 /// <param name="entity">實體對象</param>
 5 /// <returns>操作影響的行數</returns>
 6 int Insert(TEntity entity);
 7 
 8 /// <summary>
 9 /// 批量插入實體
10 /// </summary>
11 /// <param name="entities">實體對象集合</param>
12 /// <returns>操作影響的行數</returns>
13 int Insert(IEnumerable<TEntity> entities);
14 
15 /// <summary>
16 /// 更新實體對象
17 /// </summary>
18 /// <param name="entity">更新后的實體對象</param>
19 /// <returns>操作影響的行數</returns>
20 int Update(TEntity entity);
21 
22 /// <summary>
23 /// 刪除實體
24 /// </summary>
25 /// <param name="entity">實體對象</param>
26 /// <returns>操作影響的行數</returns>
27 int Delete(TEntity entity);
28 
29 /// <summary>
30 /// 刪除指定編號的實體
31 /// </summary>
32 /// <param name="key">實體編號</param>
33 /// <returns>操作影響的行數</returns>
34 int Delete(TKey key);
35 
36 /// <summary>
37 /// 刪除所有符合特定條件的實體
38 /// </summary>
39 /// <param name="predicate">查詢條件謂語表達式</param>
40 /// <returns>操作影響的行數</returns>
41 int Delete(Expression<Func<TEntity, bool>> predicate);
42 
43 /// <summary>
44 /// 批量刪除刪除實體
45 /// </summary>
46 /// <param name="entities">實體對象集合</param>
47 /// <returns>操作影響的行數</returns>
48 int Delete(IEnumerable<TEntity> entities);

針對 DTO 的 增、改、刪 業務操作API

  在業務層實現對實體的增加,更新操作的時候,如果業務層接收的是 Dto 數據,需要對 Dto 的數據進行合法性檢查,再將 Dto 通過 數據映射組件 AutoMapper 創建或更新相應類型的實體數據模型 Model,然后再按需求對 Model 的導航屬性進行更新,再提交保存。在進行刪除操作的時候,需要使用傳入的主鍵 Id 檢索相應的實體信息,並檢查刪除操作的可行性,再提交到上下文中進行刪除操作,並刪除其他相關數據。在這些針對實體的業務操作中,存在着很多相似的重復代碼,這種重復代碼的存在,會極大降低系統的可維護性。因此,在 數據倉儲操作 中設計了一組專門針對 Dto 的業務操作API,利用 無返回委托 Action<T> 與 有返回委托 Func<T, RT> 來向底層傳遞 各實體業務操作的變化點的業務邏輯,以達到對 Dto 業務重復代碼的徹底重構。

/// <summary>
/// 以DTO為載體批量插入實體
/// </summary>
/// <typeparam name="TAddDto">添加DTO類型</typeparam>
/// <param name="dtos">添加DTO信息集合</param>
/// <param name="checkAction">添加信息合法性檢查委托</param>
/// <param name="updateFunc">由DTO到實體的轉換委托</param>
/// <returns>業務操作結果</returns>
OperationResult Insert<TAddDto>(ICollection<TAddDto> dtos, Action<TAddDto> checkAction = null, Func<TAddDto, TEntity, TEntity> updateFunc = null)
    where TAddDto : IAddDto;

/// <summary>
/// 以DTO為載體批量更新實體
/// </summary>
/// <typeparam name="TEditDto">更新DTO類型</typeparam>
/// <param name="dtos">更新DTO信息集合</param>
/// <param name="checkAction">更新信息合法性檢查委托</param>
/// <param name="updateFunc">由DTO到實體的轉換委托</param>
/// <returns>業務操作結果</returns>
OperationResult Update<TEditDto>(ICollection<TEditDto> dtos, Action<TEditDto> checkAction = null, Func<TEditDto, TEntity, TEntity> updateFunc = null)
    where TEditDto : IEditDto<TKey>;

/// <summary>
/// 以標識集合批量刪除實體
/// </summary>
/// <param name="ids">標識集合</param>
/// <param name="checkAction">刪除前置檢查委托</param>
/// <param name="deleteFunc">刪除委托,用於刪除關聯信息</param>
/// <returns>業務操作結果</returns>
OperationResult Delete(ICollection<TKey> ids, Action<TEntity> checkAction = null, Func<TEntity, TEntity> deleteFunc = null);

數據查詢 API

   一個系統的數據層的所有 API 設計中,看似簡單卻又最復雜的是 數據查詢API 的設計,可能的原因如下:

  • 業務需求是未知的,不可預見的,很難設計出能滿足各種業務需求的 數據查詢API
  • 業務需求是多變的,在數據層設計 數據查詢API,很難保持穩定,往往也要跟隨業務進行變更
  • 業務需求需要的數據是千奇百怪的,數據層 設置死的API很難滿足業務的需求,給多了對系統性能造成影響,給少了又不能滿足業務需求

  基於以上理由,可見在數據層中設計 數據查詢API,是很難滿足業務層對數據的需求的。那怎么辦呢?答案就是不把數據查詢的API設置死,把數據查詢的決定權交給“真正需要數據的地方”,在哪需要數據就在哪查詢,需要哪些數據、需要數據的哪一部分,業務層自己說了算,數據層只需要提供一個用於數據查詢的“數據源”即可。到這里,IQueryable<T>的一切特性,幾乎都是為了滿足上面的需求而來的,只需在 IRepository 中對數據層開放一個“只讀的” IQueryable<TEntity> 查詢數據集即可,不同的數據查詢需求,通過設計 IQueryable<T> 的擴展方法來完成。

  上面定義的 IQueryable<T>查詢數據源,能滿足大部分數據查詢的需求,但某些 EntityFramework 的特定查詢需求,還是應該單獨定義 數據查詢API,以更好的保障不丟失 EntityFramework 的數據查詢自由度。在這里主要定義了 通過主鍵查找實體、使用 Include 包含指定導航屬性 的數據查詢API:

 1 /// <summary>
 2 /// 獲取 當前單元操作對象
 3 /// </summary>
 4 IUnitOfWork UnitOfWork { get; }
 5 
 6 /// <summary>
 7 /// 獲取 當前實體類型的查詢數據集
 8 /// </summary>
 9 IQueryable<TEntity> Entities { get; }
10 
11 /// <summary>
12 /// 實體存在性檢查
13 /// </summary>
14 /// <param name="predicate">查詢條件謂語表達式</param>
15 /// <param name="id">編輯的實體標識</param>
16 /// <returns>是否存在</returns>
17 bool ExistsCheck(Expression<Func<TEntity, bool>> predicate, TKey id = default(TKey));
18 
19 /// <summary>
20 /// 查找指定主鍵的實體
21 /// </summary>
22 /// <param name="key">實體主鍵</param>
23 /// <returns>符合主鍵的實體,不存在時返回null</returns>
24 TEntity GetByKey(TKey key);
25 
26 /// <summary>
27 /// 獲取貪婪加載導航屬性的查詢數據集
28 /// </summary>
29 /// <param name="path">屬性表達式,表示要貪婪加載的導航屬性</param>
30 /// <returns>查詢數據集</returns>
31 IQueryable<TEntity> GetInclude<TProperty>(Expression<Func<TEntity, TProperty>> path);
32 
33 /// <summary>
34 /// 獲取貪婪加載多個導航屬性的查詢數據集
35 /// </summary>
36 /// <param name="paths">要貪婪加載的導航屬性名稱數組</param>
37 /// <returns>查詢數據集</returns>
38 IQueryable<TEntity> GetIncludes(params string[] paths);

 

  至此,OSharp 的數據層定義基本完成,下篇我們將逐條講解 EntityFramework 的數據層實現。

開源說明

github.com

   OSharp項目已在github.com上開源,地址為:https://github.com/i66soft/osharp,歡迎閱讀代碼,歡迎 Fork,如果您認同 OSharp 項目的思想,歡迎參與 OSharp 項目的開發。

  在Visual Studio 2013中,可直接獲取 OSharp 的最新源代碼,獲取方式如下,地址為:https://github.com/i66soft/osharp.git

  

nuget

  OSharp的相關類庫已經發布到nuget上,歡迎試用,直接在nuget上搜索 “osharp” 關鍵字即可找到
  

系列導航

本文已同步到系列目錄:OSharp快速開發框架解說系列


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM