最近在園子里面連續看到幾篇關於ORM的文章,其中有兩個印象比較深刻<<SqliteSugar>>,另外一篇文章是<<我的開發框架之ORM框架>>, 第一個做的ORM是相當的不錯的,第二個也是相當的不錯, 至少在表面上看起來是這么一回事。至於具體的用法和實踐我沒有深入的去測試過,所以也不便發表更多的意見,不過這種造輪子的精神我個人還是比較佩服的, 雖說有時候造輪子是閑的蛋疼的事情,但是如果你沒有早過輪子你也體會不到造輪子給你帶來的感官感受。目前比較受歡迎的ORM框架肯定是微軟系的 EF莫屬, 當然之前還出現過類似此種框架的Alinq,這也是相當的不錯,不過作者大神貌似不怎么更新了。
前不久開源了自己開發的吉特倉儲管理系統【開源地址:https://github.com/hechenqingyuan/gitwms , 有興趣可以加入群 88718955,142050808 或者本人QQ 821865130 交流】,其中也用到了ORM框架, 但是這個ORM框架不是市面上所見到的ORM框架,是自己開發的一套ORM框架,因為拉不上台面所以也就沒有對外公開過,只是自己做項目的時候一直在用,經過多年的修改維護也算有所小成, 在個人開發的倉儲系統中成功應用,而且有着較高的穩定運行記錄。最近也在給小范圍內給有興趣開發倉儲系統的朋友培訓如何快速的開發倉儲系統, 今天這里總結一下培訓中所講到的ORM框架。
一. 為什么會有這個框架
當年沒有Linq to SQL , EF 等ORM框架的時候, 寫SQL也還是相當痛苦的一件事,市面上當時有MyBatis框架, 這個ORM是從java移植過來的,好不好用不過多的說, 褒貶不一。但是我個人用下來就是有一個很不爽的就是那個配置SQL,那個SQL的配置文件超級特么的多,多到節操都快碎了一地。后面上班公司又基於微軟企業庫做了一個類似的ORM框架, 這個也沒有改變其配置文件的蛋疼本質,那個誰一怒為紅顏,我特么的一怒差點撂挑子不干了。痛定思痛我決定自己來改改這個狗日的配置文件,最終有所成也就成了現在吉特倉儲系統中使用的Git.Framework.ORM 框架。
之前也相關自己要做一款非常不錯的ORM組件,要支持MS SQL,MySQL,Oracle,Sqlce,Sqlite 等,不過也是真的嘗試過,不過我還是有點異想天開,后面做到了對SQL Server,MySQL,Oracle的支持, 終究覺得做的不是那么好,所以也就堅持不下去了,做出來終究也是就是跟別人裝逼,后面放棄了對多數據庫的支持,只對SQL Server 進行了重點的支持(可能是我能力有限以及對整體結構規划的缺失,導致對其他的數據庫支持性並不是那么好),因為我只是自己用而且我的最終目的不是做一個ORM,而是針對某種業務型的項目來輔助支撐。
二. ORM中數據庫腳本配置問題

<dataCommand name="Common.GetProceParam" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT [SPECIFIC_CATALOG],[SPECIFIC_NAME],[ORDINAL_POSITION],[PARAMETER_MODE],[PARAMETER_NAME],[DATA_TYPE],[CHARACTER_MAXIMUM_LENGTH] FROM [INFORMATION_SCHEMA].[PARAMETERS] WHERE [SPECIFIC_NAME]=@SPECIFIC_NAME ]]> </commandText> <parameters> <param name="@SPECIFIC_NAME" dbType="String" direction="Input"/> </parameters> </dataCommand>
最初系統中所有的SQL語句都是通過以上方式來配置配置在配置文件中,寫了一個比較牛逼的DataCommandManager類用於來解析讀取這些SQL, 這些配置都是緩存的。並且直接轉化為了ADO.NET中的Command對象,只需要在使用的時候連接數據庫並且傳入參數執行就可以,這樣看起來並不錯。說好話這樣是挺爽的,SQL 不用硬編碼在C#代碼中了,可以隨時在配置文件中修改, 這是java中一直流傳的套路(不懂java,但是看到的現象是這樣的),項目越做越龐大問題就來了, 業務越來越復雜SQL越來越多,配置文件多到沒法維護了,一個問題調試都不知道從何處還是找起,即使在配置文件命名規范上死下功夫仍然存在大量的問題,比如配置文件name值是一樣的重復了,SQL寫重復了也沒辦法知道,除非你知道這個項目里面的所有配置節點。
<dataCommand name="Common.GetProceParam" database="GitWMS" commandType="Text">
這個配置節點中的name 是配置數據庫SQL的唯一鍵值, SQL的配置文件多達上百個,這個name重復了有時候根本不知道從何處找起,也想過使用工具來查找,但是這樣終究是效率不高的。難道我每次寫一個SQL都要查找一下name是否存在,當然很多人也會說無所謂嘛,但是我個人覺得這個不應該是工作的重點,更加應該將重心放到業務上去。
三. ORM 配置重復的SQL

<dataCommand name="Common.GetProceParam" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT [SPECIFIC_CATALOG],[SPECIFIC_NAME],[ORDINAL_POSITION],[PARAMETER_MODE],[PARAMETER_NAME],[DATA_TYPE],[CHARACTER_MAXIMUM_LENGTH] FROM [INFORMATION_SCHEMA].[PARAMETERS] WHERE [SPECIFIC_NAME]=@SPECIFIC_NAME ]]> </commandText> <parameters> <param name="@SPECIFIC_NAME" dbType="String" direction="Input"/> </parameters> </dataCommand> <dataCommand name="Sys.GetProceParam" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT [SPECIFIC_CATALOG],[SPECIFIC_NAME],[ORDINAL_POSITION],[PARAMETER_MODE],[PARAMETER_NAME],[DATA_TYPE],[CHARACTER_MAXIMUM_LENGTH] FROM [INFORMATION_SCHEMA].[PARAMETERS] WHERE [SPECIFIC_NAME]=@SPECIFIC_NAME ]]> </commandText> <parameters> <param name="@SPECIFIC_NAME" dbType="String" direction="Input"/> </parameters> </dataCommand>
上面這個這兩個SQL一模一樣,只是name的值不一樣,當時多人開發導致你根本無法知道這個SQL是不是重復了,雖然這個對程序運行沒有什么影響,但是終究是重復了。而且我幾乎不可能判斷SQL是不是有重復的。這個就存在一個相當坑爹的事情,要做這個事情還是要花費一點時間,但是沒有辦法公用,不說每個開發人員都會搞一套,但是這種重復的問題是的的確確存在的問題,我相信做MyBatis開發的肯定深有體會。

public List<ProceMetadata> GetMetadataList(string argProceName) { DataCommand command = DataCommandManager.GetDataCommand("Common.GetProceParam"); command.SetParameterValue("@SPECIFIC_NAME", argProceName); List<ProceMetadata> list = command.ExecuteEntityList<ProceMetadata>(); return list; }
以上是操作配置文件中C#代碼,其實也就比Ado.NET 方便那么一點點,如果加上配置等操作完全不會比Ado.NET簡單。上面這種還是個別案例,上面這段SQL用的人估計不會很多,如果涉及到業務問題比如獲取某個用戶的信息,這個時候技術人員溝通不暢那么就極有可能出現重復的代碼。再后來的接盤俠過來了怎么辦,我該怎么獲取用戶的信息,寫了這么多獲取用戶的信息,以前的又不敢動那我再重新寫一個吧。
四. ORM獲取用戶信息

SELECT * FROM [dbo].[T_USER] SELECT UserID,[Password] FROM [dbo].[T_USER] SELECT UserID,UserName FROM [dbo].[T_USER] SELECT UserID,UserName,Email FROM [dbo].[T_USER]
獲取用戶信息偷懶的辦法,我們在查詢字段的時候直接SELECT * FROM , * 通配符真是一個好東西,可以少些好多的代碼,用起來就是爽。如果從業務和更加嚴謹的角度來說其實我們不建議使用 * 來查詢所有表字段信息。業務環境點要求:我要查詢用戶編號和用戶名,我要查詢用戶名,用戶編號,用戶郵箱,我要查詢用戶所有的屬性數據,我要查詢用戶..... ; 這種需求再正常不過了,假設我們不允許在所有環境下查詢所有的數據庫屬性字段,根據上面的框架來開發的話,那的配置多少個SQL配置節點,我要寫多少個DataCommand,這還只是其中一個業務點,一個系統怎么可能只有一個業務點。
五. 數據字段權限如何處理

<dataCommand name="Common.GetProceParam1" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT * FROM [dbo].[T_USER] ]]> </commandText> <parameters> </parameters> </dataCommand>

<dataCommand name="Common.GetProceParam" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT UserID,UserName FROM [dbo].[T_USER] ]]> </commandText> <parameters> </parameters> </dataCommand>

<dataCommand name="Common.GetProceParam" database="GitWMS" commandType="Text"> <commandText> <![CDATA[ SELECT UserID,UserName,IDCard FROM [dbo].[T_USER] ]]> </commandText> <parameters> </parameters> </dataCommand>
在業務系統中有些是特別敏感的信息,比如ERP系統中對價格控制,客戶能夠看到什么價格,VIP看到什么樣的價格,什么級別的領導看到又是另外的價格,人就是這么的坑爹! 我相信這是一個再普通不過的業務場景了,各種價格保持到不同的數據庫列中,很多人的做法就是先將數據查詢出來,然后在用代碼將沒有權限的數據隱藏掉,這個也是我個人的一般做法,那么我們能不能在讀取數據的時候就控制呢,根據權限查詢特定的字段信息。這個肯定不用多說肯定是可以的, 根據上面的這種ORM配置來做 就是上面三種類型的配置,好坑爹啊。
======================ORM就是一個坑爹貨啊,根本達不到快速開發效果===========================
六.徹底一些 ORM
以上的事情足夠蛋疼好久的,每天要花大量的體力勞動時間來配置這些SQL語句,我能不能在不改動原有的結構基礎上讓這些操作變得更加的簡單一些。這是得讓我們好好想想的事情,ORM框架的核心點在哪里,那就是解決對象與數據庫之間的映射關系以及對象與對象之間的關聯關系。先不說DataCommand這個類,我們分析Ado.NET 操作數據庫的特點,幾個步驟就不說了。兩個核心點:(1) SQL語句並且帶有占位符(可選) (2) 設置占位符參數 , 仔細想想這兩個是有規律的,最起碼第二個規律很明顯,就是AddParamater() 我們完全可以使用參數的形式循環設置這些占位符參數。
SQL語句的規則稍微有點復雜:
(1) 增: insert into table (字段,字段) values(值,值)
(2) 刪: delete from table where 條件
(3) 改: update table set 字段=值,字段=值 where 條件
(4) 查: select *(字段) from table where 條件
解決以上ORM的問題,那么就是如何動態的去替換如上語句的關鍵字。 這里動態很重要,我們需要動態的去設置動態列以及條件,避免硬編碼的去配置SQL語句,動態也就意味着不同的條件生成的SQL是不一樣的,不需要配置文件窮舉這些SQL語句了。我想ORM框架的開發基本就是這個理吧,只要具體細節如何實現就看對語法的理解以及對框架的設計如何了。
七. 先說映射的屬性

[TableAttribute(DbName = "GitWMS", Name = "TNum", PrimaryKeyName = "ID", IsInternal = false)] public partial class TNumEntity:BaseEntity { public TNumEntity() { } [DataMapping(ColumnName = "ID", DbType = DbType.Int32,Length=4,CanNull=false,DefaultValue=null,PrimaryKey=true,AutoIncrement=true,IsMap=true)] public Int32 ID { get; set; } public TNumEntity IncludeID (bool flag) { if (flag && !this.ColumnList.Contains("ID")) { this.ColumnList.Add("ID"); } return this; } [DataMapping(ColumnName = "Num", DbType = DbType.Int32,Length=4,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public Int32 Num { get; set; } public TNumEntity IncludeNum (bool flag) { if (flag && !this.ColumnList.Contains("Num")) { this.ColumnList.Add("Num"); } return this; } [DataMapping(ColumnName = "MinNum", DbType = DbType.Int32,Length=4,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public Int32 MinNum { get; set; } public TNumEntity IncludeMinNum (bool flag) { if (flag && !this.ColumnList.Contains("MinNum")) { this.ColumnList.Add("MinNum"); } return this; } [DataMapping(ColumnName = "MaxNum", DbType = DbType.Int32,Length=4,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public Int32 MaxNum { get; set; } public TNumEntity IncludeMaxNum (bool flag) { if (flag && !this.ColumnList.Contains("MaxNum")) { this.ColumnList.Add("MaxNum"); } return this; } [DataMapping(ColumnName = "Day", DbType = DbType.String,Length=50,CanNull=true,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public string Day { get; set; } public TNumEntity IncludeDay (bool flag) { if (flag && !this.ColumnList.Contains("Day")) { this.ColumnList.Add("Day"); } return this; } [DataMapping(ColumnName = "TabName", DbType = DbType.String,Length=50,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public string TabName { get; set; } public TNumEntity IncludeTabName (bool flag) { if (flag && !this.ColumnList.Contains("TabName")) { this.ColumnList.Add("TabName"); } return this; } [DataMapping(ColumnName = "CompanyID", DbType = DbType.String,Length=50,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)] public string CompanyID { get; set; } public TNumEntity IncludeCompanyID (bool flag) { if (flag && !this.ColumnList.Contains("CompanyID")) { this.ColumnList.Add("CompanyID"); } return this; } }
老生常談的問題,實在不好意思在拿出來討論了,不說細節了可以去參考別人的ORM框架或本人的<<.NET ORM>>系列文章,在吉特倉儲管理系統中充斥着大量的這樣實體對象,應該說貫穿到了整個系統中。
[DataMapping(ColumnName = "CompanyID", DbType = DbType.String,Length=50,CanNull=false,DefaultValue=null,PrimaryKey=false,AutoIncrement=false,IsMap=true)]
ColumnName是映射的列與屬性之間的關系,這個是核心也是關鍵,還有一個就是IsMap 這個屬性 這個表示是否映射字段。為了靈活性我當然可以擴展這個實體類,但是我就是不想和數據庫字段建立關系,你想怎樣。
一個實體對象映射一張表, 代碼中我可以擴展這個實體對象屬性,這些屬性是和表之間唯一對應的,但是我是可以在查詢的時候連接查詢字段或者計算出來的字段做映射關系的,我就說了查詢是SQL中最難的。增刪改在操作的時候都必須和對應的表字段對應,而查詢偏偏不是這樣的,我查詢可以從其他表關聯查詢出來,我可以取別名,我可以查詢自定義新的列, 對象是否處理,那關鍵就是看IsMap 這個值了。
實體上有DataMapping 特性標識,說明和數據有映射關系,如果沒有則和數據庫映射操作沒有關系,數據庫無論怎么操作都不影響這些數據。 標識IsMap=true的屬性 這個是和數據庫字段強制關聯的(一般用於增刪改), 沒有這個值默認是false,這個時候查詢也會映射到此類屬性上.
八. 定義了一套語法規則

public partial interface ITNum : IDbHelper<TNumEntity> { }

public partial class TNumDataAccess : DbHelper<TNumEntity>, ITNum { public TNumDataAccess() { } }
傳說中的DbHelper, 之前還有人專門寫過一篇關於DbHelper的文章,到底要不要叫DbHelper, 我不想談論此類文章,畢竟我不是搞技術研究的料。此DbHelper 並非傳統的DbHelper類,這個是自己改良過的(並非改良,是完全不一樣,只是名字相同而已), 深受DDD領域驅動設計的毒害,每個人都這么搞我也就弄了一個似於上面的倉儲模式,我水平低你們不要笑話我,我也不知道這個DDD中的倉儲是不是正宗的,只是看起來有點像,我此等超低領悟能力的人有點理解不來。因為我看到網上的.NET 相關的文章 DDD倉儲模型都是使用EF實現的,千篇一律沒有其他之一。
在系統中我同樣會定義很多類似於上面的空的接口和空的實現實現類,基本每個表回對應一個這種模型,如果表很多該怎么辦,你懂得, 做程序的雖然不聰明但是也絕對不會傻逼到每個都手寫。

public int AddMeasure(MeasureEntity entity) { entity.IncludeAll(); int line = this.Measure.Add(entity); return line; }
在實體新增的時候,會將數據保持到實體對應的表中去,其中可能大家比較關注的是哪個IncludeAll() , 這個就是解決上面的指定指定問題的, IncludeAll() 意味着新增的時候會生成所有的字段插入到表中。
entity.Include(alias => new { alias.MeasureName, alias.MeasureNum });
這段代碼表示在表中只新增兩個字段,IncludeAll() 是新增所有的字段. 當然如果你在新增的時候數據庫設計必填而你沒有包含進去,這個時候執行SQL的時候是會報錯的。
entity.Include("MeasureName").Include("MeasureNum");
鏈式表達式,上面的這段代碼和表達式這段代碼是等價,在操作新增的時候都是包含了兩個字段。只要能夠和字段映射的上,你就可以無限次的包含字段關系。
INSERT INTO [dbo].[Product]([SnNum],[BarCode],[ProductName],[Num],[MinNum],[MaxNum],[UnitNum],[UnitName],[CateNum],[CateName],[Size],[Color],[Standard],[Pressure],[GasketFace],[SCH],[Status],[ProcessMode],[Material],[InPrice],[OutPrice],[AvgPrice],[NetWeight],[GrossWeight],[Description],[PicUrl],[IsDelete],[CreateTime],[CreateUser],[StorageNum],[DefaultLocal],[CusNum],[CusName],[SupNum],[SupName],[Display],[Remark],[CompanyID]) VALUES(@SnNum,@BarCode,@ProductName,@Num,@MinNum,@MaxNum,@UnitNum,@UnitName,@CateNum,@CateName,@Size,@Color,@Standard,@Pressure,@GasketFace,@SCH,@Status,@ProcessMode,@Material,@InPrice,@OutPrice,@AvgPrice,@NetWeight,@GrossWeight,@Description,@PicUrl,@IsDelete,@CreateTime,@CreateUser,@StorageNum,@DefaultLocal,@CusNum,@CusName,@SupNum,@SupName,@Display,@Remark,@CompanyID)
你執行的SQL語句可以是這樣,當然你也可以這樣
INSERT INTO [dbo].[Product]([SnNum],[BarCode],[ProductName],[Num]) VALUES(@SnNum,@BarCode,@ProductName,@Num)
該死的窮舉配置所有的SQL可能性,可以滾蛋了。
九. 查詢關聯的字段
你的ORM框架支持加載子類集合么,或者自動加載主表的數據么。 NO NO NO, 你特么的不要跟我提為什么不加載字表的集合,別的框架都支持加載子類集合啊?? 好煩 好煩 這種問題。 個人縮寫的這個ORM框架的確是不支持自動加載子類集合的,第一 個人能力有限, 第二 做ORM就一定要加載子項么, 我就單表操作不可以么(這里不是說只能操作一張表,是能夠連接查詢的,但是連接查詢出來的結果集也是一張表啊)。 你別傻了, 變通 變通 變通!

public override List<InventoryDetailEntity> GetOrderDetail(InventoryDetailEntity entity) { InventoryDetailEntity detail = new InventoryDetailEntity(); detail.IncludeAll(); detail.Where(a => a.OrderSnNum == entity.OrderSnNum) .And(a => a.CompanyID == this.CompanyID) ; ProductEntity product = new ProductEntity(); product.Include(a => new { Size=a.Size, CateName = a.CateName, Standard = a.Standard, Pressure = a.Pressure, GasketFace = a.GasketFace, SCH = a.SCH, ProductStatus = a.Status, ProcessMode = a.ProcessMode, Material = a.Material, GrossWeight = a.GrossWeight }); detail.Left<ProductEntity>(product, new Params<string, string>() { Item1 = "TargetNum", Item2 = "SnNum" }); List<InventoryDetailEntity> list = this.InventoryDetail.GetList(detail); return list; }
在上面的IsMap 屬性還記得么,上面說到了他就是為了解決連接查詢中字段不能匹配實體的問題(反正就是一個區分到底匹配不匹配). 這是使用這種實體模型的時候,我們要大量去擴展數據庫映射模型屬性, 有點違法了大家DDD說的數據庫模型,業務模型,數據傳輸模型等分離的原則,什么原則我是不懂了,在有些人眼里肯定是不倫不類了。
product.Include(a => new { Size=a.Size, CateName = a.CateName, Standard = a.Standard, Pressure = a.Pressure, GasketFace = a.GasketFace, SCH = a.SCH, ProductStatus = a.Status, ProcessMode = a.ProcessMode, Material = a.Material, GrossWeight = a.GrossWeight });
這個左連接查詢出來的屬性字段,肯定要在實體類InventoryDetailEntity 中有對應的屬性字段了。

public partial class InventoryDetailEntity { [DataMapping(ColumnName = "ProductName", DbType = DbType.String)] public string ProductName { get; set; } [DataMapping(ColumnName = "BarCode", DbType = DbType.String)] public string BarCode { get; set; } [DataMapping(ColumnName = "Size", DbType = DbType.String)] public string Size { get; set; } [DataMapping(ColumnName = "CateName", DbType = DbType.String)] public string CateName { get; set; } [DataMapping(ColumnName = "UnitName", DbType = DbType.String)] public string UnitName { get; set; } //擴展產品屬性 [DataMapping(ColumnName = "Standard", DbType = DbType.String)] public string Standard { get; set; } [DataMapping(ColumnName = "Pressure", DbType = DbType.String)] public string Pressure { get; set; } [DataMapping(ColumnName = "GasketFace", DbType = DbType.String)] public string GasketFace { get; set; } [DataMapping(ColumnName = "SCH", DbType = DbType.String)] public string SCH { get; set; } [DataMapping(ColumnName = "ProductStatus", DbType = DbType.Int32)] public int ProductStatus { get; set; } [DataMapping(ColumnName = "ProcessMode", DbType = DbType.Int32)] public int ProcessMode { get; set; } [DataMapping(ColumnName = "Material", DbType = DbType.String)] public string Material { get; set; } [DataMapping(ColumnName = "GrossWeight", DbType = DbType.Double)] public double GrossWeight { get; set; } }
可以和上面的那個實體對象做一下對比,DataMapping 也標識了,沒有那么多屬性其實都是默認了,IsMap其實就默認了false,說明這些屬性和表Inventory 沒有直接的對應關系(記住一個宗旨,任何操作都可以看做是一個單表操作)。
十. 如何解決上面提出的問題
如果你還不能明白如何解決上述提出的問題,比如多配置,命名重復,權限字段等問題,那我只能說你還沒有明白我寫的文章,要么就是我寫的文章太不入流了,不能讓你能夠很清楚的明白我的意思。
我本身不是想做一個ORM框架,我就是想做了一個軟件,然后逐步的提高開發效率。其他人我不知道了,自從改進到目前的這個程度,在開發效率上的確提升很多了。自我感覺是良好的。 我深知這個ORM不能跟其他的ORM相比,看別人的ORM簡直就是高大上,我做這個ORM完全是為了解決當初的開發遇到的問題以及從自己做吉特倉儲系統業務的角度來出發的,所以在很大的程度上是有局限性的。但是我相信在整個框架體系中(包括業務的設計)是可以在很大程度上彌補Git.Framework.ORM 方面的不足。
技術是為業務服務,是為了解決問題,如果單純的只是為了做框架而框架,我並不覺得這是一個好的方式,就好比DDD領域驅動設計,這種設計思想是很好的,但是請別千篇一律的的找一個使用了DDD設計思想的技術框架奉為定律, 框架本身就是要靈活多變的,不同的業務體系框架也就會不一樣,架構也會不一樣。(恕我不懂DDD的精髓在此說了大話)
以上代碼實現在吉特倉儲管理系統中都有實現, 總體來說並沒有什么技術門檻, 不要迷信這是一個非常好的框架,只是因為這個東西我就是為他而定制的,所以剛好就適應了它。如果有更多的想了解吉特倉儲管理系統,可以到github上去下載源碼: https://github.com/hechenqingyuan/gitwms 有任何關於倉庫系統業務方面的也可以和我交流相互學習,最近也弄了兩次吉特倉儲管理系統快速開發培訓(小范圍的),如果有興趣的朋友,特別是上海的朋友可以近距離的交流吉特倉儲系統以及物流方面的知識。
作者:情緣
出處:http://www.cnblogs.com/qingyuan/
關於作者:從事倉庫,生產軟件方面的開發,在項目管理以及企業經營方面尋求發展之路
版權聲明:本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文鏈接。
聯系方式: 個人QQ 821865130 ; 倉儲技術QQ群 88718955,142050808 ;
吉特倉儲管理系統 開源地址: https://github.com/hechenqingyuan/gitwms