xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

DeclarativeSql rebooted - Now supports .NET Standard 2.0

数年間開発を放置した DeclarativeSql を更新して .NET Standard 2.0 に対応させました!過去バージョンとの互換は結構崩れてしまっている点は大変申し訳ないのですが、今の自分が欲しいなぁという機能をシンプルに詰め込むために致し方なし。

実は .NET Core がまだリリース前だった頃から .NET Core 対応を目論んでいて net-core というブランチでヒッソリと作業していたのですが、.NET Standard 1.0 が出たての頃と言ったら IDbConnection を廃止するだの Type / TypeInfo あたりが壊滅的に使いにくいなど超絶グダグダで、それに辟易して結局諦めてしまいました。だけど時代が進んで今は .NET Standard 2.x。フレームワーク起因で開発上困るようなことがほとんどなくなったのと、改めて .NET Core に対応したものが欲しくなったのでメンテしました。

GitHub の ReadMe に書いてあることのコピペみたいなものですが、僕は日本語が最も得意 () なので日本語でもある程度解説をば。

属性ベースのテーブルマッピング

Entity Framework などはそうですが、テーブルとマップする型に属性をアレコレ付けることで SQL をイイ感じに生成できるようにします。これが最も面倒な作業ですが、データベース内のテーブル表現をコード上で (ある程度) 再現しないと自動化できないので、こればっかりは頑張って書くしかないです。Entity Framework はテーブル情報を引っ張ってきてコードを自動生成してくれるので強いんですが、そういう自動化するコードを書くのはそこまで難しくないので、各々のプロジェクトでイイ感じに自動生成するバッチでも作ればよいと思っています。

using System;
using DeclarativeSql.Annotations;

namespace SampleApp
{
    // DB の種類ごとにテーブル名をカスタマイズできる
    [Table(DbKind.MySql, "T_Customer")]
    [Table(DbKind.SqlServer, "T_Person", Schema = "dbo")]
    public class Person
    {
        [PrimaryKey]  // 主キー
        [AutoIncrement]  // 自動採番
        public int Id { get; set; }

        [Unique(0)]  // インデックスで一意制約を指定
        public string Email { get; set; }

        // DB の種類ごとに列名も変えられる
        [Column(DbKind.MySql, "氏名")]
        [Column(DbKind.SqlServer, "名前")]
        public string Name { get; set; }

        [AllowNull]  // Nullable
        public int? Age { get; set; }

        [CreatedAt]  // 生成日時を入れるマーカー属性
        [DefaultValue(DbKind.SqlServer, "SYSDATETIME()")]  // 入れる値
        public DateTimeOffset CreatedOn { get; set; }

        [ModifiedAt]  // 更新日時を入れるマーカー属性
        [DefaultValue(DbKind.SqlServer, "SYSDATETIME()")]  // 入れる値
        public DateTimeOffset UpdatedOn { get; set; }
    }
}

特に今回最も頑張った機能が [CreatedAt] / [ModifiedAt] 属性 + [DefaultValue] 属性による挿入/更新日時の自動設定です。毎回毎回 DateTimeOffset.Now をプロパティに入れて Insert / Update するの、相当ダルくないですか?僕はとてもダルい!そして DateTimeOffset.Now は複数台のサーバーになったときにサーバーによって時間が違うので、時刻の信頼性が若干低下します。データベースの時刻を使えばより誤差を低減できると期待できます。ので、そういう信頼性高めの日時の挿入を自動でやってれる SQL を吐き出したかった...という私欲まみれなヤツです。

他にも以前から「SQL Server と MySQL で列名とかスキーマ名が違うんだけど同じものを表すテーブルを表現したい」みたいな要望がありました。複数の DB に同時に接続することを中途半端にしか (ぇ) 想定していなかったので、今回はそういうところにももうちょっと配慮して [Table] 属性と [Column] 属性を作ってみたりしました。ちゃんと応えられているのかは若干不安ですが...。

SQL の自動生成

テーブルとマッピングする型が手に入れば SQL を自動生成できます。SQL は方言が多くてなんでもかんでも対応するのは無理なので、基本どんなデータベースに対しても動くものだけカジュアルサポートしています。作れる SQL は以下の通りです。それぞれドストレートなメソッドが提供されているので簡単に分かると思います。

  • count
  • select
  • insert
  • update
  • delete
  • truncate
  • where
  • order by
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Select<Person>(x => new { x.Id, x.Name })
    .Where(x => x.Name == "xin9le")
    .OrderByDescending(x => x.Name)
    .ThenBy(x => x.CreatedOn)
    .Build()
    .Statement;

/*
select
    [Id] as Id,
    [名前] as Name
from [dbo].[T_Person]
where
    [名前] = @p1
order by
    [名前] desc,
    [CreatedOn]
*/
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Insert<Person>()
    .Build()
    .Statement;

/*
insert into [dbo].[T_Person]
(
    [Email],
    [名前],
    [Age],
    [CreatedOn],
    [UpdatedOn]
)
values
(
    @Email,
    @Name,
    @Age,
    SYSDATETIME(),
    SYSDATETIME()
)
*/
var sql
    = DbProvider.SqlServer.QueryBuilder
    .Update<Person>(x => new { x.Name, x.Age })
    .Where(x => x.Age < 35 || x.Name == "xin9le")
    .Build()
    .Statement;

/*
update [dbo].[T_Person]
set
    [名前] = @Name,
    [Age] = @Age,
    [UpdatedOn] = SYSDATETIME()
where
    [Age] < @p1 or [名前] = @p2
*/

Dapper との統合

データベースを相手にしていて最も使うのは超シンプルな CRUD です。inner join とか group by みたいな複雑な (?) ものこそ SQL を手書きすればよし!という割り切りのもと、シンプルな CRUD に対するショートハンドなメソッドを提供します。これは過去のバージョンから変わってない機能です。下記のサンプルは同期メソッドで書いてありますが、非同期メソッド版もありますのでご安心を。

//--- 全件取得
var p1 = connection.Select<Person>();

//--- 特定列だけに絞りつつ全件取得
var p2 = connection.Select<Person>(x => new { x.Id, x.Name });

//--- 'ID = 3' のレコードのみ取得
var p3 = connection.Select<Person>(x => x.Id == 3);

//--- 'ID = 3' のレコードを特定列に絞って取得
var p4 = connection.Select<Person>
(
    x => x.Id == 3,
    x => new { x.Id, x.Name }
);
//--- 1 件 insert
var p5 = connection.Insert(new Person { Name = "xin9le", Age = 30 });

//--- 複数件 Insert
var p6 = connection.InsertMulti(new []
{
    new Person { Name = "yoshiki", Age= 49, },
    new Person { Name = "suzuki",  Age= 30, },
    new Person { Name = "anders",  Age= 54, },
});

//--- バルク処理で insert
//--- 現在 SQL Server のみ対応 : そのうち他もやるかも
var p7 = connection.BulkInsert(new []
{
    new Person { Id = 1, Name = "yoshiki", Age= 49, },
    new Person { Id = 2, Name = "suzuki",  Age= 30, },
    new Person { Id = 3, Name = "anders",  Age= 54, },
});

//--- insert 後に自動採番された ID を取得
var p8 = connection.InsertAndGetId(new Person { Name = "xin9le", Age = 30 });
//--- 条件に一致する行の指定列を更新
var p9 = connection.Update
(
    new Person { Name = "test", Age = 23 },
    x => x.Age == 30,
    x => new { x.Name, x.Age }
);
//--- 全件削除
var p10 = connection.Delete<Person>();

//--- 条件に一致する行を削除
var p11 = connection.Delete<Person>(x => x.Age != 30);
//--- truncate 文による全件削除
var p12 = connection.Truncate<Person>();
//--- テーブルのレコード数を取得
var p13 = connection.Count<Person>();

//--- 条件に一致するレコード数を取得
var p14 = connection.Count<Person>(x => x.Name == "xin9le");

高可用性を持つデータベース接続

サービスの規模が相当大きくなってくるとデータベースへの負荷が上がってレスポンスが悪化したりします。そういうときは DB Server のインスタンスサイズを上げて金で解決するのが最も簡単な手法ですが、稀にそれで追いつかなくなって「読み込みは Read Replica からにしたい」みたいなケースも出てくるかと思います。そう言ったときに、先手を打って Master / Slave が別になることを想定したコードを書いておくと良いでしょう。

Master / Slave がインフラによって同期されていることが前提ですが、HighAvailabilityConnection はその一助になるのではないかと思います。

public class FooConnection : HighAvailabilityConnection
{
    public FooConnection()
        : base("ConnectionString-ToMasterServer", "ConnectionString-ToSlaveServer")
    {}

    protected override IDbConnection CreateConnection(string connectionString, AvailabilityTarget target)
        => new SqlConnection(connectionString);
}
using (var connection = new FooConnection())
{
    //--- Slave DB から読み込み
    var p = connection.Slave.Select<Person>();

    //--- Master DB に書き込み
    connection.Master.Insert(new Person { Name = "xin9le" });
}

Master / Slave の同期がどのくらいの期間で行われるかはインフラ/クラウド環境次第なので、自身のサービスで担保したい高可用性のレベルにマッチする場合にお使いください。

まとめ

ちょっとは戦える...と思う。難しいクエリをしたいときは生 SQL で良いけど、簡単な SQL まで手書きするのはイヤ!という怠惰な自分のための一品です。そして僕は Entity Framework と戦いたいわけでは決してないのです。