C#でMICRO ORMapperを書きました。名前はDBSimpleです。
ORMapperとはデータベース上の情報をプログラミング言語上のデータ構造にマッピングするライブラリの事です。代表的なライブラリとして
RubyのActive Record
PHPのEloquent
JavaのHibernate, MyBatis
C#のEntity Framework, Dapper
などが挙げられます。
今回僕が作ったDBSimpleはC#のExpressionTreeという仕組みを使っていて動的にコードを生成、Compileします。StackOverFlow社製のDapperより早いです。
また、RailsのActiveRecordの様にリレーションを意識したマッピングが出来ます。(belongs_toやhas_manyが出来ます。preloadやlazyloadが出来ます。inverse_ofが出来ます。)
DBSimpleにある機能はSELECT文で取得したレコードをオブジェクトにMapするだけです。UPDATEやINSERTは出来ません。
ソースコード
https://github.com/ha2ne2/DBSimple
#Dapperの紹介
DapperはStack Over Flowが開発したMicro ORMapperです。薄くて早いライブラリです。ORMapperは簡単に言うと、SQLの発行結果をC#のオブジェクトにマッピングするライブラリです。例えばORMapperを使わずに、愚直にマッピングするとこういうコードになります
public List<OrderHeader> GetOrderHeaderList()
{
string connectionString = Util.GetConnectionString();
string selectQuery = "SELECT * FROM Sales.SalesOrderheader";
List<OrderHeader> modelList = new List<OrderHeader>();
using (var connection = new SqlConnection(connectionString))
{
// データベースと接続
connection.Open();
using (var command = new SqlCommand())
{
// コマンドの組み立て
command.Connection = connection;
command.CommandText = selectQuery;
// SQLの実行
using (SqlDataReader rdr = command.ExecuteReader())
{
while (rdr.Read())
{
// ひたすら詰め替える
OrderHeader model = new OrderHeader();
model.SalesOrderID = rdr["SalesOrderID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["SalesOrderID"]);
model.RevisionNumber = rdr["RevisionNumber"] == DBNull.Value ? default(byte) : Convert.ToByte(rdr["RevisionNumber"]);
model.OrderDate = rdr["OrderDate"] == DBNull.Value ? default(DateTime) : Convert.ToDateTime(rdr["OrderDate"]);
model.DueDate = rdr["DueDate"] == DBNull.Value ? default(DateTime) : Convert.ToDateTime(rdr["DueDate"]);
model.ShipDate = rdr["ShipDate"] == DBNull.Value ? default(DateTime) : Convert.ToDateTime(rdr["ShipDate"]);
model.Status = rdr["Status"] == DBNull.Value ? default(byte) : Convert.ToByte(rdr["Status"]);
model.OnlineOrderFlag = rdr["OnlineOrderFlag"] == DBNull.Value ? default(bool) : Convert.ToBoolean(rdr["OnlineOrderFlag"]);
model.SalesOrderNumber = rdr["SalesOrderNumber"] == DBNull.Value ? string.Empty : Convert.ToString(rdr["SalesOrderNumber"]);
model.PurchaseOrderNumber = rdr["PurchaseOrderNumber"] == DBNull.Value ? string.Empty : Convert.ToString(rdr["PurchaseOrderNumber"]);
model.AccountNumber = rdr["AccountNumber"] == DBNull.Value ? string.Empty : Convert.ToString(rdr["AccountNumber"]);
model.CustomerID = rdr["CustomerID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["CustomerID"]);
model.SalesPersonID = rdr["SalesPersonID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["SalesPersonID"]);
model.TerritoryID = rdr["TerritoryID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["TerritoryID"]);
model.BillToAddressID = rdr["BillToAddressID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["BillToAddressID"]);
model.ShipToAddressID = rdr["ShipToAddressID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["ShipToAddressID"]);
model.ShipMethodID = rdr["ShipMethodID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["ShipMethodID"]);
model.CreditCardID = rdr["CreditCardID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["CreditCardID"]);
model.CreditCardApprovalCode = rdr["CreditCardApprovalCode"] == DBNull.Value ? string.Empty : Convert.ToString(rdr["CreditCardApprovalCode"]);
model.CurrencyRateID = rdr["CurrencyRateID"] == DBNull.Value ? default(int) : Convert.ToInt32(rdr["CurrencyRateID"]);
model.SubTotal = rdr["SubTotal"] == DBNull.Value ? default(decimal) : Convert.ToDecimal(rdr["SubTotal"]);
model.TaxAmt = rdr["TaxAmt"] == DBNull.Value ? default(decimal) : Convert.ToDecimal(rdr["TaxAmt"]);
model.Freight = rdr["Freight"] == DBNull.Value ? default(decimal) : Convert.ToDecimal(rdr["Freight"]);
model.TotalDue = rdr["TotalDue"] == DBNull.Value ? default(decimal) : Convert.ToDecimal(rdr["TotalDue"]);
model.Comment = rdr["Comment"] == DBNull.Value ? string.Empty : Convert.ToString(rdr["Comment"]);
model.rowguid = rdr["rowguid"] == DBNull.Value ? default(Guid) : (Guid)rdr["rowguid"];
model.ModifiedDate = rdr["ModifiedDate"] == DBNull.Value ? default(DateTime) : Convert.ToDateTime(rdr["ModifiedDate"]);
modelList.Add(model);
}
}
}
}
return modelList;
}
こういうコードは書きたくないですよね(T_T)
Dapperを使うと下記のように書けます
↓↓↓
public List<OrderHeader> GetOrderHeaderListByDapper()
{
string connectionString = Util.GetConnectionString();
string selectQuery = "SELECT * FROM Sales.SalesOrderheader";
List<OrderHeader> modelList = new List<OrderHeader>();
using (var connection = new SqlConnection(connectionString))
{
// データベースと接続
connection.Open();
// Dapperでマッピング
return connection.Query<OrderHeader>(selectQuery).ToList();
}
}
短い!こうでなくっちゃ!
#Dapperの問題点
Dapperの(個人的な)問題点として以下の問題があります。
例えばこういうテーブルがあったとします。
CREATE TABLE [dbo].[Author](
[AuthorID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](50) NULL
) ON [PRIMARY]
AuthorIDと名前を持つ単純なテーブルです。
対してPOCOのモデルがこう定義されていたとします。
public Author
{
public int AuthorID { get; set;}
public string Namae { get; set; }
}
そしてマッピングさせます。
var authorList = connection.Query<Author>("SELECT * FROM Author");
これは例外も何も起きず普通に動きます。しかし返ってきたオブジェクトのNamaeプロパティには何も入っていません。DB上のカラム名はNameでPOCOではNamaeだからです。Dapperはプロパティ名をタイプミスしていても何もしません。ただマッピングしないだけです。警告を出すオプションもありません。GitHub上のDapperリポジトリのIssueで、そういう場合に例外を投げるオプションが欲しいという要望が出ていてそれに対して議論がかわされていましたが、思想的にそういう機能は付けたくないそうです。
option to throw when result set has unmapped columns
https://github.com/StackExchange/Dapper/issues/254
(確かにテストをしっかりしていれば問題にはなりませんが…)
#DBSimpleの紹介と速度比較
DBSimpleではPOCOに定義されたプロパティ名と同名のカラムがSQLの発行結果に全て存在しなくてはいけません。存在しなかった場合は例外を投げます。
また最初にも言いましたが、DBSimpleはRailsのActiveRecordの様にリレーションを意識したマッピングが出来ます。ActiveRecordのbelongs_toやhas_many、preloadやlazyload、inverse_ofと云った機能が実装されています。
それはどういう機能か? 実際に動かして動きを見ていきましょう。
まずは単純なマッピングによる速度比較からやっていきたいと思います。
先のDapperのコードに対して、DBSimpleで単純なマッピングする際のコードはこうなります。
↓↓↓
public List<OrderHeader> GetOrderHeaderListByDBSimple()
{
string connectionString = Util.GetConnectionString();
string selectQuery = "SELECT * FROM Sales.SalesOrderheader";
// DBSimpleでマッピング
return DBSimple.SimpleMap<OrderHeader>(connectionString, selectQuery);
}
AdventureWoksという架空の企業の基幹システムのDBをMicrosoftが公開しているので、それを使います。
そのDBの中にあるSalesOrderHeaderテーブルをオブジェクトにマッピングして行きます。テーブルには31465レコード入っていますがこれを単純に4倍して125860レコード入っている状態にします。カラム数は26です。
GitリポジトリをCloneしてVisualStudioで開くとSampleFormsがスタートアッププロジェクトになっていると思うので起動させます。起動するとこんな感じです。
まずはデータテーブル+リフレクションという手法で12万件のマッピングをしてみます。
「リフレクションで取得」ボタンを押します。
結果:
2620ms = 2.6秒かかりました。
次にDBSimpleでマッピングしてみます。
「DBSimpleで取得」ボタンを押します。
結果:
795ms = 0.795秒で取得できました!イェイ!早いぜ!
最後にDapperでマッピングします。
「Dapperで取得」ボタンを押します。
結果:
884msで取得しました。
Dapperも早いですね。
以下簡単にですが、10回試行した時の平均値です。
125,860件のDB上のレコードをC#にオブジェクトにマップするのにかかった時間。
※10回の平均値
※DBの実行プランのキャッシュやその他のキャッシュは毎回クリアしている
(/ (+ 2686 2561 2682 2726 2656 2725 2621 2787 2609 2904) 10.0)
Reflection 2695.7ms
(/ (+ 806 815 773 799 781 776 818 774 793 791) 10.0)
DBSimple 792.6ms
(/ (+ 900 852 854 836 885 852 873 850 857 820) 10.0)
Dapper 857.9ms
#DBSimple で RelationalMapping
先の章で行ったのは単純でフラットなマッピングでした。
次はDBのリレーションを意識したマッピングをやっていきます。
(RailsのActiveRecordやLaravelのEloquentみたいなやつのことです。)
テーブルの定義がこうなっていたとします。
CREATE TABLE [dbo].[Author](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](50) NULL
) ON [PRIMARY]
CREATE TABLE [dbo].[Book](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](50) NULL,
[AuthorID] [int] NULL
) ON [PRIMARY]
対してモデルはこう定義します。
public class Author : DBSimpleModel
{
[PrimaryKey]
public int ID { get; set; }
public string Name { get; set; }
[HasMany(typeof(Book), foreignKey: nameof(Book.AuthorID))]
public List<Book> BookList {
get { return Get<List<Book>>(); }
set { Set(value); }
}
}
public class Book : DBSimpleModel
{
[PrimaryKey]
public int ID { get; set; }
public string Name { get; set; }
public int AuthorID { get; set; }
[BelongsTo(typeof(Author), foreignKey: nameof(AuthorID))]
public Author Author
{
get { return Get<Author>(); }
set { Set(value); }
}
}
これで準備完了です。
下記の様にマップするとAuthorのリストが手に入ります。
string connectionString = Util.GetConnectionString();
string selectQuery = "SELECT * FROM [Author]";
List<Author> authorList = DBSimple.ORMap<Author>(connectionString , selectQuery);
これだけでAuthorにも、AuthorのプロパティであるBookListにも全て値がマッピングされます!
巷でよく聞くn+1問題に対してはORMapメソッドの第三引数で対処します。
第三番目の引数は省略可能なint型の引数で、これはプリロードする深さを指定します。
DBSimple.ORMap(connectionString , selectQuery, 1)とした場合
1階層目(Authorのプロパティ)までがプリロードされます。
2階層目以降のプロパティは遅延バインドされ、プロパティへのアクセス時にSQLが発行されます。
DBSimple.ORMap(connectionString , selectQuery, 2)とした場合
2階層目(プリロードされたプロパティのプロパティ)までがプリロードされます。
3階層目以降のプロパティは遅延バインドされ、プロパティへのアクセス時にSQLが発行されます。
第三引数が省略された場合は1が入ります。
そんな感じです!
使ってみてください。