Micro-ORMとC#(とDapperカスタマイズ)

C#に続き、ASP.NET Advent Calendar 2012です。前日は84zumeさんのWebFormっぽいコントロールベスト3でした。私はC#ではMemcachedTranscoder - C#のMemcached用シリアライザライブラリを書きまして、ああ!これこそむしろASP.NETじゃねえか!と悶絶したりなどして、日付逆にすれば良かったよー、困ったよー。しかもあんまし手持ちの札にASP.NETネタがない!というわけで、ASP.NETなのかビミョーですが押し通せば大丈夫だろう、ということでMicro-ORMについて。

Micro-ORM?

最近タイムリーなことに、またORM論争が起こっていて。で、O/R Mapperですが、私としては割と否定派だったりして。C#にはLINQ(to SQL/Entities)があります!はい、色々な言語のORMを見ても、LINQ(to SQL/Entities)の完成度はかなり高いほうに入ると思われます。それもこれもC#の言語機能(Expression Tree, 匿名型, その他その他)のお陰です。言語は実現できる機能にあんま関係ないとかいう人が割とたまにじゃばにいますが、んなことは、ないでしょ。

で、ORMと一口に言うとややこしいので、分解しよう、分解。一つはクエリビルダ。SQL文を組み立てるところです。ORMといったら、まず浮かぶのはここでしょう、そして実際、ここの部分の色々のもやもやを振り払うために、世の中のORMは色々腐心しているのではかと思います。

残りは、クエリを発行してDBに投げつける実行部分。コネクション作ってコマンド作ってパラメータ作って、とかがお仕事。最後に、結果セットをマッピングするところ。この2つは地味ですね、ORMという時に、特に意識されることはないでしょう。

で、Micro-ORMはクエリビルダはないです。あるのは実行とマッピングだけです。生SQL書いてオブジェクトにマッピングされたのが返ってくる。つまり、ORMと言ったときにまず浮かべる部分が欠けてます。だからORMって、RelationalとはMappingしてないんならもうDataMapperとかTableMapperとか言ったほうがいいのでは、感もありますが、つまるところそういうわけでMicro-ORMはORMじゃないですね。

ORM or その他、といった時に、ORM(DataSet, NHibernate, LINQ to SQL, Entity Framework)を使わない、となると、その次が生ADO.NETに吹っ飛ぶんですよね、選択肢。それ、えっ?って。生ADO.NETとか人間が直に触るものじゃあない、けど、まあ昔からちょっとしたお手製ヘルパぐらいは存在していたけれど、それだけというのもなんだかなー。という隙間に登場したのがMicro-ORMです。

Not ORM

つまりORMじゃあない。LINQという素敵な完成系があるのに、違うのを選びたくなる。何故?LINQという素敵なもので夢を見させてくれた、それでなお、ダメかもね、という結論に至ってしまう。じゃあもうORMって無理じゃない?

SQLは全然肯定できません。30年前のしょっぱい構文、の上にダラダラ足されていく独自拡張。じゃあ標準万歳かといえば、全然そんなことはないのでにっちもさっちもいかずだし、そもそもその標準の時点で相当しょっぱいっつーの。でも、それでも、ORMにまつわる面倒ごとであったり制限を押しのけてまで欲しいかい?と言われると、いらない。になる。

結局、データベースはデータベースであり、オブジェクトはオブジェクトであり。

EF CodeFirstって凄く滑稽。オブジェクトをそのまんまDBに投げ込むのなんて幻想で。だからデータベースを意識させて、クラスじゃないクラスを作る。リレーションを手でコードで張っていく、そんな、おかしいよ!まともなクラスじゃないクラスを手で書かされるぐらいなら、SQL Server Management Studioでペトペト作って、DBからクラス生成するほうがずっといい(勿論EFはそれできます)。

オブジェクト入れたいならさ、Redisとかも検討できる、そっちのほうがずっと素直に入る。勿論、データベースをやめよう、じゃないよ。ただ、データベースはデータベースである、というだけなんだ。

SQLだってすごく進化しているのに(書きやすさは置いておいてね)、ORMの抽象はそれらに完璧に対応できない。だって、データベース毎に、違うんだものね、同じ機能なかったりするものね。RDBMSは同じだ、というのが、まず、違うんじゃないかな、って。

良い面がいっぱいあるのは分かるよ!where句を文字列で捏ね捏ねするよりもオブジェクト合成したいし、LINQのタイプセーフなところは凄く魅力的なんだ!それでもね、厄介な挙動と複雑な学習コスト、パフォーマンスの問題、その他諸々。それらとは付き合わない、という選択もね、あっていいよね。

Dapper

具体例としてDapperを扱います。もっともポピュラーだから。速いしね。で、チマッとした具体例は、出してもつまらないので省略。それは↑の公式サイトで見ればいいでしょ。

拡張しよう

基本的にマッピングはプロパティ名とDBのカラム名が一致してないとダメです。ダメ絶対。しかし、世の中往々にして一致してるとは限らないケースが少なくもない。例えばDBのカラム名はsnake_caseでつけられていたりね。勿論、その場合C#のプロパティ名もsnake_caseにすりゃあいんですが、きんもーっ。嫌なんだよね、それ。

というわけでDapperには救済策が用意されていて、マッピングルールを型毎に設定することが可能です。この辺はリリース時にはなかったんですが後から追加されてます。そしてドキュメントが一向に更新されないため、何が追加されてるのとか、はためにはさっぱり分かりません。何気に初期リリースから地味に随分と機能が強化されていたりなかったりするんですんが、この辺は定期的にSourceとTest見れってとこですねー、shoganai。

方法としてはCustomPropertyTypeMapを作って、SqlMapper.SetTypeMapに渡してやればOK。CustomPropertyTypeMapではTypeとDBのカラム名が引数にくるので、そこからPropertyInfoを返してやればOK。一度定義されたマッピングファイルは初回のクエリ実行時にIL生成&キャッシュされ、二度呼ばれることはないので高速に動作します。

例えばsnake_caseをPascalCaseにマッピングさせてやるには

// こーいう関数を用意してやると
static void SetSnakeToPascal<T>()
{
    var mapper = new CustomPropertyTypeMap(typeof(T), (type, columnName) =>
    {
        //snake_caseをPascalCaseに変換
        var propName = Regex.Replace(columnName, @"^(.)|_(\w)", x => x.Groups[1].Value.ToUpper() + x.Groups[2].Value.ToUpper());
        return type.GetProperty(propName);
    });

    SqlMapper.SetTypeMap(typeof(T), mapper);
}

// こんなクラスがあるとして
public class Person
{
    // DBではid, first_name, last_name, created_at
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime CreatedAt { get; set; }
}

static void Main()
{
    // MyClassをカラム名snake_cake → プロパティ名PascalCaseにマッピングするようセット
    SetSnakeToPascal<Person>();
                
    using (var conn = new MySqlConnection("せつぞくもじれつ"))
    {
        conn.Open();
        var result = conn.Query<Person>("select * from people"); // 無事マッピングできてる
    }
}    

といった感じ。SqlMapper.SetTypeMapをどこで呼ばせるか&管理面倒くせー、という問題は無きにしも非ずですが、まあその辺はやりようは幾らでもあるので(例えばクラスに専用の属性でも貼り付けておいてApplication_Startでリフレクションで全部舐めて登録してしまうとか)、大した問題ではないでしょう。

Dapperは何もかもの面倒は見てくれません。必要なものは随時、自分で足す。作る。でも、それでいいんじゃない?どうせ、出来あいの代物が自分達の要求に100%合致するなんてことはなくて、大なり小なり、自分達で足回り部分は作るでしょう。なら、そのついでです。大したことじゃあない。むしろ余計な面倒がなくていい。

ところでちなみに何でMySqlConnectionなのかというと、手元にあるDBが諸事情でMySQLだからです。諸事情!そこはdoudemoiiとして、DapperならMySQLでも繋げやすいという利点がありますね。DB選びません。C#はSQL Server専用みたいなものでしょ?なんてことはないのです。

Query Builder for Dapper

Dapperは純粋な実行とマッピングのみとなるように作られています、というのが設計やIssueの返信などからも見て取れます。生ADO.NETの一つ上の層として存在する、混じり気なしの代物にするのが目標だ、と。つまり、Dapper自身に、ちょっとしたPKで取ってくるだけのもの、Findとかよく言われるようなヘルパメソッドが乗ったりすることはありません。が、欲しいですよね、それ、そういうの。生で使ってもいいんですが、もう一枚、ほんの少しだけ、薄いの、被せたい。

そんなわけで、そのDapperの更に一枚上にのった、ちょっとしたCRUDヘルパーがDapperExtensionsやDapper.Rainbowなのですけれど、ビミョー。しょーじきビミョー。なので、作りましょう。自分で。例えばこういうのよくなくないですか?

// これで↓のクエリに変換される
// select * from Person p where p.FirstName = @0 && p.CreatedAt <= @1
var sato = conn.Find<Person>(x => x.FirstName == "佐藤" && x.CreatedAt <= new DateTime(2012, 10, 10));

// updateもこんな感じで update .... values ... が生成、実行される
conn.Update(sato, x => x.Id == 10)

Expression Treeから、タイプセーフなクエリ生成をする。select-where程度の、PKで取ってきたり、ちょっとした条件程度のものならさくっと書ける。InsertやUpdateも、そんまんまぶん投げて条件入れるだけなので単純明快。ところで、このまま拡張していくと、事前のマッピングクラス生成が不要な即席Queryable、LINQ to DBみたいなものができなくない?たとえばconn.AsQueryable().Where().OrderBy().Select() といったように。

結論を言えば、できる。が、やらないほうがいいと思ってます。一つは、どこかでQueryableのクエリ抽象の限界に突き当たること。生SQLで書いたほうがいいのか、Queryableで頑張ればいいのか。もしくは、これはQueryableでちゃんとサポートしているのか。そういう悩み、無駄だし意味ないし。select-whereならヘルパある、それ以外は生SQL書け。それぐらい単純明快なルールが敷けたほうが、シンプルでいいんじゃないかな。どうでもいい悩みを減らすためにやっているのに、また変な悩みを増やすようじゃやってられない。

もう一つは、Queryableを重ねれば重ねるほどパフォーマンスロスが無視できなくなっていくこと。たった一つの、↑のFindみたいなExpression Treeの生成/解析なんてたかがしれていて、無視できる範囲に収まっています。あ、これはちゃんと検証して言ってますよん。遅くなるといえば遅くなってますが、Entity Frameworkのクエリ自動コンパイルは勿論、手動コンパイルよりも速いです、逐次解析であっても。

Queryableを重ねれば重ねるほど遅くなるので、手動コンパイル(&キャッシュ)させなければならなくて、しかし手動コンパイルはかなり手間で滑稽なのでやりたくない。EFの自動コンパイルは悪くない!のですが、やっぱ相応に、そこまで速くはなくて、ね……。

実際に実装すると、こんな風になります。

// Expression Treeをなめなめする下準備
public static class ExpressionHelper
{
    // Visitorで舐めてx => x.Hoge == xxという形式のExpression Treeから値と演算子のペアを取り出す
    public static PredicatePair[] GetPredicatePairs<T>(Expression<Func<T, bool>> predicate)
    {
        return PredicateExtractVisitor.VisitAndGetPairs(predicate);
    }

    class PredicateExtractVisitor : ExpressionVisitor
    {
        readonly ParameterExpression parameterExpression; // x => ...のxなのかを比較判定するため保持
        List<PredicatePair> result = new List<PredicatePair>(); // 抽出結果保持

        public static PredicatePair[] VisitAndGetPairs<T>(Expression<Func<T, bool>> predicate)
        {
            var visitor = new PredicateExtractVisitor(predicate.Parameters[0]); // x => ... の"x"
            visitor.Visit(predicate);
            return visitor.result.ToArray();
        }

        public PredicateExtractVisitor(ParameterExpression parameterExpression)
        {
            this.parameterExpression = parameterExpression;
        }

        // Visitぐるぐるの入り口
        protected override Expression VisitBinary(BinaryExpression node)
        {
            // && と || はスルー、 <, <=, >, >=, !=, == なら左右の解析
            PredicatePair pair;
            switch (node.NodeType)
            {
                case ExpressionType.AndAlso:
                    pair = null;
                    break;
                case ExpressionType.OrElse:
                    pair = null;
                    break;
                case ExpressionType.LessThan:
                    pair = ExtractBinary(node, PredicateOperator.LessThan);
                    break;
                case ExpressionType.LessThanOrEqual:
                    pair = ExtractBinary(node, PredicateOperator.LessThanOrEqual);
                    break;
                case ExpressionType.GreaterThan:
                    pair = ExtractBinary(node, PredicateOperator.GreaterThan);
                    break;
                case ExpressionType.GreaterThanOrEqual:
                    pair = ExtractBinary(node, PredicateOperator.GreaterThanOrEqual);
                    break;
                case ExpressionType.Equal:
                    pair = ExtractBinary(node, PredicateOperator.Equal);
                    break;
                case ExpressionType.NotEqual:
                    pair = ExtractBinary(node, PredicateOperator.NotEqual);
                    break;
                default:
                    throw new InvalidOperationException();
            }

            if (pair != null) result.Add(pair);

            return base.VisitBinary(node);
        }

        // 左右ノードから抽出
        PredicatePair ExtractBinary(BinaryExpression node, PredicateOperator predicateOperator)
        {
            // x.hoge == xx形式なら左がメンバ名
            var memberName = ExtractMemberName(node.Left);
            if (memberName != null)
            {
                var value = GetValue(node.Right);
                return new PredicatePair(memberName, value, predicateOperator);
            }
            // xx == x.hoge形式なら右がメンバ名
            memberName = ExtractMemberName(node.Right);
            if (memberName != null)
            {
                var value = GetValue(node.Left);
                return new PredicatePair(memberName, value, predicateOperator.Flip()); // >, >= と <, <= を統一して扱うため演算子は左右反転
            }

            throw new InvalidOperationException();
        }

        string ExtractMemberName(Expression expression)
        {
            var member = expression as MemberExpression;

            // ストレートにMemberExpressionじゃないとUnaryExpressionの可能性あり
            if (member == null)
            {
                var unary = (expression as UnaryExpression);
                if (unary != null && unary.NodeType == ExpressionType.Convert)
                {
                    member = unary.Operand as MemberExpression;
                }
            }

            // x => xのxと一致してるかチェック
            if (member != null && member.Expression == parameterExpression)
            {
                var memberName = member.Member.Name;
                return memberName;
            }

            return null;
        }

        // 式から値取り出すほげもげ色々、階層が深いと面倒なのね対応
        static object GetValue(Expression expression)
        {
            if (expression is ConstantExpression) return ((ConstantExpression)expression).Value;
            if (expression is NewExpression)
            {
                var expr = (NewExpression)expression;
                var parameters = expr.Arguments.Select(x => GetValue(x)).ToArray();
                return expr.Constructor.Invoke(parameters); // newしてるけどアクセサ生成で高速云々
            }

            var memberNames = new List<string>();
            while (!(expression is ConstantExpression))
            {
                if ((expression is UnaryExpression) && (expression.NodeType == ExpressionType.Convert))
                {
                    expression = ((UnaryExpression)expression).Operand;
                    continue;
                }

                var memberExpression = (MemberExpression)expression;
                memberNames.Add(memberExpression.Member.Name);
                expression = memberExpression.Expression;
            }

            var value = ((ConstantExpression)expression).Value;

            for (int i = memberNames.Count - 1; i >= 0; i--)
            {
                var memberName = memberNames[i];
                // とりまリフレクションだけど、ここはアクセサを生成してキャッシュして高速可しよー
                dynamic info = value.GetType().GetMember(memberName)[0];
                value = info.GetValue(value);
            }

            return value;
        }

    }
}

// ExpressionTypeだと範囲広すぎなので縮めたものを
public enum PredicateOperator
{
    Equal,
    NotEqual,
    LessThan,
    LessThanOrEqual,
    GreaterThan,
    GreaterThanOrEqual
}

// x.Hoge == 10 みたいなのの左と右のペアを保持
public class PredicatePair
{
    public PredicateOperator Operator { get; private set; }
    public string MemberName { get; private set; }
    public object Value { get; private set; }

    public PredicatePair(string name, object value, PredicateOperator predicateOperator)
    {
        this.MemberName = name;
        this.Value = value;
        this.Operator = predicateOperator;
    }
}

public static class PredicatePairsExtensions
{
    // SQL文作るー、のでValueのほうは無視気味。
    public static string ToSqlString(this PredicatePair[] pairs, string parameterPrefix)
    {
        var sb = new StringBuilder();
        var isFirst = true;
        foreach (var pair in pairs)
        {
            if (isFirst) isFirst = false;
            else sb.Append(" && "); // 今は&&連結だけ。||対応は面倒なのよ。。。

            sb.Append(pair.MemberName);
            switch (pair.Operator)
            {
                case PredicateOperator.Equal:
                    if (pair.Value == null)
                    {
                        sb.Append(" is null ");
                        continue;
                    }
                    sb.Append(" = ").Append(parameterPrefix + pair.MemberName);
                    break;
                case PredicateOperator.NotEqual:
                    if (pair.Value == null)
                    {
                        sb.Append(" is not null ");
                        continue;
                    }
                    sb.Append(" <> ").Append(parameterPrefix + pair.MemberName);
                    break;
                case PredicateOperator.LessThan:
                    if (pair.Value == null) throw new InvalidOperationException();
                    sb.Append(" < ").Append(parameterPrefix + pair.MemberName);
                    break;
                case PredicateOperator.LessThanOrEqual:
                    if (pair.Value == null) throw new InvalidOperationException();
                    sb.Append(" <= ").Append(parameterPrefix + pair.MemberName);
                    break;
                case PredicateOperator.GreaterThan:
                    if (pair.Value == null) throw new InvalidOperationException();
                    sb.Append(" > ").Append(parameterPrefix + pair.MemberName);
                    break;
                case PredicateOperator.GreaterThanOrEqual:
                    if (pair.Value == null) throw new InvalidOperationException();
                    sb.Append(" >= ").Append(parameterPrefix + pair.MemberName);
                    break;
                default:
                    throw new InvalidOperationException();
            }
        }

        return sb.ToString();
    }
}

public static class PredicateOperatorExtensions
{
    // 演算子を反転させる、 <= と >= の違いを吸収するため
    public static PredicateOperator Flip(this PredicateOperator predicateOperator)
    {
        switch (predicateOperator)
        {
            case PredicateOperator.LessThan:
                return PredicateOperator.GreaterThan;
            case PredicateOperator.LessThanOrEqual:
                return PredicateOperator.GreaterThanOrEqual;
            case PredicateOperator.GreaterThan:
                return PredicateOperator.LessThan;
            case PredicateOperator.GreaterThanOrEqual:
                return PredicateOperator.LessThanOrEqual;
            default:
                return predicateOperator;
        }
    }
}
public static T Find<T>(this IDbConnection conn, Expression<Func<T, bool>> predicate)
{
    var pairs = ExpressionHelper.GetPredicatePairs(predicate);
    // とりあえずテーブル名はクラス名で
    var className = typeof(T).Name;
    var condition = pairs.ToSqlString("@"); // とりま@に決めうってるけどDBによっては違いますなー

    var query = string.Format("select * from {0} where {1}", className, condition);

    // 匿名型でなく動的にパラメータ作る時はDynamicParameterを使う
    var parameter = new DynamicParameters();
    foreach (var pair in pairs)
    {
        parameter.Add(pair.MemberName, pair.Value);
    }

    // Dapperで実行. 勿論、FirstではないFindAllも別途用意するとヨシ。
    return conn.Query<T>(sql: query, param: parameter, buffered: false).First();
}

static void Main(string[] args)
{
    using (var conn = new MySqlConnection("せつぞくもじれつ"))
    {
        conn.Open();
        // ↓のようなクエリ文になる
        // select * from Person where FirstName = @FirstName && CreatedAt <= @CreatedAt
        var sato = conn.Find<Person>(x => x.FirstName == "佐藤" && x.CreatedAt <= new DateTime(2012, 10, 10));
    }

といった、Expression TreeベースのタイプセーフなMicro Query Builderを中心にしたMicro-ORMが、DbExecutor ver.3で、実際に作っていました。水面下で。そしてお蔵入りしました!お蔵入りした理由は色々お察し下さい。まぁまぁ悪くないセンは行ってたかなー、とは思うのでお蔵入りはMottainai感が若干あるものの、全体的には今一つだったなあ、というのが正直なところで、"今"だったら違う感じになったかな、と思っちゃったりだから、あんまし後悔はなく没でいいかな。某g社の方々へは申し訳ありません、と思ってます。

そんなわけでMicro Query Builderというコンセプトを継いで、マッピング部分はDapperを使うDapper拡張として作り直したものは、近日中にお目見え!はしません。しませんけれど(タスクが山積みすぎてヤバい)、そのうちに出したいというか、絶対に出しますので、乞うご期待。謎社の今後にも乞うご期待。

まとめ

あんましFull ORM使わなきゃー、とか悩む必要はないです。XXが便利で使いたいんだ!というなら使えばいいですし、逆にXXがあってちょっと嫌なんだよなー、というならば、使わない、が選択肢に入っていいです。.NETだって選択の自由はあるんですよ?そこ勘違いしちゃダメですよ?自由度を決めるのは、Microsoftでもコミュニティーの空気でもなく、自分達ですから。

さて、ASP.NET Advent Calendar 2012、次はMicrosoft MVP for Windows Azureの割と普通さんです。AzureとWeb Sitesについて聞けるようですよ!wktk!

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive