Functional Programming w/ C# LINQ

業務ユーザが既存 DB を読み取り専用で利用するための LINQ to Entities (Code First) コーディング・メモ

半月の間かなり苦労して Entity Framework 6 (EF6) と Entity Framework Core (EF Core) にてデータベースアクセスできるようになった。

業務ユーザとなり、開発者だった過去を忘れていたが、そもそもデータベース・アプリ開発は難しい、ということを思い出した。低級の疎通から始めて高級な要求を満たすまでに超えるべき壁がたくさんある。LINQ to Entities は QueryProvider という Runtime Compiler が C# ロジックから変換した SQL を発行するため、さらに壁が増え、難しくなる。加えて、LINQ to Entities は誕生から 10 年ほどたったものの、いまもまだ進化しており、バイブル的な参考書籍・情報がない。ネットには LINQ to SQLLINQ to Entities EntityFramework 4 / 5 / 6 / Core と進化してきた過程の情報が混在しており、初学者にはそれらの区別がつきにくくきわめてハードルが高い。また、当然ではあるが開発者観点での情報が多く、(少し技術寄りの) データ利用者が参考にするには情報があまりに断片的で、課題がなかなか解決につながらない。

そこで、業務ユーザが既存 DB を読み取り専用で利用する目的で LINQ to Entities を導入する場合にスムーズに実現できるポイントを備忘録としてここに記す。
Entity Framework 以外の動作検証環境*1Oracle XXX, Windows 7, Visual Studio 2017, C# 7.3, ターゲットは .NET Framework 4.7.1。

計画編

  1. プラットフォームの選択
    EF6 と EF Core のどちらを利用するか選択する。その前に両者ができること、できないことをよく把握する。EF6 よりも EF Core の方が全般的に簡易な設定で動作するため、既存 DB を読み取り専用で利用するには便利かもしれない。しかし、EF Core は、将来、破壊的変更がありうる実験的な実装であり、LINQ メソッドを SQL へ変換する QueryProvider の内部実装が EF6 とはかなり異なるため、ロジックに互換性がない。EF6 より EF Core の方が EntityFunctions (for EF5) / DbFunctions (for EF6) 等を使わずとも C# 記法のロジックをそのまま SQL 変換してくれる幅が広く *2、使いやすい面もあるが、一方で残念ながら現時点では GroupBy() メソッドに対応していない等の制約もある。両者の Pros / Cons を慎重に検討し、プラットフォームを選択する必要がある。
  2. Code First の採用
    LINQ to Entities の開発アプローチは Database First, Model First, Code First と 3 種類ある。既存 DB を利用するのだから Database First だろうと思いきや、前 2 者は古いアプローチであり、オススメは開発の手間が少ない Code First だ。ただし、Code First は Migration の項目で後述するようにコードで DB を作り変えることができる仕組みであり、すでに運用している DB に適用するには若干のリスクがあるため注意が必要。それがこのメモを書き留めておく理由でもある。
  3. Visual Studio ソリューションプロバイダーの「追加 > 新しい項目 > ADO.NET Entity Data Model の追加」手順は不要
    LINQ to Entities で開発する際には「Visual Studio で "ADO.NET Entity Data Model に追加" メニューを選択しろ」とネット上の諸解説では手順説明されており、このメニューで開発アプローチを選択するのだが、既定では SQL Server を対象とした設定しかなく、接続先 DB ごとのドライバー等をインストールしなければ手順が先に進まない。結論から言えば Code First を採用する場合、このメニュー選択とドライバー・インストールは不要 *3 だ。この手順を踏むことでなされるのは、DbContext を利用したテンプレート的なクラスの .cs ファイル作成と app.config / web.config の編集だけのようである。後述のコーディング手順を踏めばこの作業の内容はカバーされる。
  4. NuGet パッケージの導入
    Visual Studio の NuGet Package Manager で必要なライブラリを導入する。

    • EF6 + Oracle の場合
      • EntityFramework
      • Oracle.ManagedDatabase.EntityFramework
      • Microsoft.Extensions.Logging (ログ出力をする場合)
    • EF Core + Oracle の場合
      • Microsoft.EntityFrameworkCore.Design
      • Oracle.EntityFrameworkCore
      • Microsoft.Extensions.DependencyInjection (ログ出力をする場合)
      • Microsoft.Extensions.Logging.Debug (ログ出力をする場合)

  5. 接続先 DB の各種情報の調査
    当たり前のことであるが、データソース名 (alias ではなく、tnsping コマンドで取得できる文字列)、テーブル名、ユーザ名、パスワード、スキーマ名を事前に調べておく。特に EF6 ではスキーマ名の指定が必須になるため、正確な情報*4を入手しておく。

コーディング準備編

このブロックの手順をひととおり踏むまではテストランすらもしてはならない。ここを疎かにして走らせると、意図せぬ Create Table 文を発行してしまって焦ったり、何が悪くて問題が生じるのかわからず混乱するハメに陥る。

  1. 接続文字列を確認する枠組みの整備
    これまた当たり前のことではあるが、実際に接続に用いられた接続文字列を接続後に出力して確認する枠組みを整備しておく。LINQ to Entities では接続文字列を new DbConnection() 等でコードから与える方法と app.config / web.config 設定ファイルから与える方法が併存しており、プログラマが意図しない接続をしている可能性があるため、与えた (と思っている) 接続前の情報を信じてはならない。また、Code First ではコードが示す DB の有無や構造と実際の DB の状況が異なる場合に設定次第でプログラムが DB やテーブルを新規作成・変更しようとする可能性があるため、実際の接続先が何であるかを確実に確認することが混乱しないための初手となる。
  2. ログ出力の整備
    実際にどのような SQL が発行されているか確認できなければ、LINQ to Entities の挙動を制御する設定はできないため、ログ出力の仕組みを整備しておく。ログ出力の設定は EF Core よりも EF6 の方が簡単 (コードの記述方法は後述)。ログはアクションのたびに生成されるため、標準出力や標準エラー出力の場合は即時出力でよいが、ファイル出力の場合はログを StringBuilder にでも溜めておいてから DbContext 破棄時に FileStream で書き出すという工夫が必要。
  3. 直接発行 SQL と EntityFramework 作成 SQL を比較する枠組みの整備
    EntityFramework の設定と挙動の関係は慣れるまで把握しづらいため、同一の DB 操作について DbConnection.CreateCommand で直接発行する SQL と EntityFramework が作成し発行する SQL とを比較する枠組みを整備しておく。これにより、想定通り EntityFramework が動作しているか、想定通りに DB 接続しているか、想定通り DB が動作しているか、を切り分ける。当然、同じ接続文字列オブジェクトを使用する等、環境条件を同一に揃えて検証する。
  4. Migration は不要
    ネットで Code First を調べると Migration 手順が合わせて記事になっているが、既存 DB を読み取り専用で利用する場合には Migration は不要である。初学者にはわかりにくいが、Migration とはコード (POCO エンティティ・クラス) で記述される DB 構造と実際の DB 構造を同期させる Code First 特有の仕組みのことだ。そのため、既存 DB を読み取り専用で使う場合は Migration をしない、というより、してはならない。わかりにくいのはネット上の諸解説が「Code First は記述したコードから DB を作成することも、"既存 DB" からコードを興すことも可能」と説明していることだ。記事をよく注意深く読むと、後者の "既存 DB" とは、今回対象にしているようなすでに運用していて構造が固定化されている DB のことではなく、これから開発するために種として作成した開発初期モデルとしての DB のことを想定していると思われる。したがって、この "既存 DB" を今後 Migration してガシガシと構造変更する想定で解説されていることから、これを鵜呑みにして今回の目的のために同じ手順を踏んではならない。一番簡単なのは、むしろ、前者のように実際の DB と同じ構造の POCO エンティティ・クラスを予め作成しておき、Migration と自動追跡を無効にして接続することだ。コードと DB の同期をさせないためには、下記のように DbContext の自作派生クラス (ここでは MyDbContext とする) の static 初期化ブロックで Database.SetInitializer(null) とする*5。ここでのポイントは Database クラスの前に名前空間を指定すること。なぜなら、基底クラス DbContext にも Database という名前のプロパティがあり、コンパイラがそちらと混同し、static ブロック内ではインスタンス・メンバーへアクセスできないというエラーが発生してしまうためだ。

    ///【EF6 の場合の記述】
    ///  EF Core では他で行った読み取り専用の設定が効いたためか、この設定がなくても DB 構造更新の SQL 文が発行されることはなかった
    ///  ように思われる。System.Data.Entity は EF6 用の名前空間であるため、EF Core にそのまま適用することはできない。
    public MyDbContext : DbContext {
      static MyDbContext() {
        System.Data.Entity.Database.SetInitializer<MyDbContext>(null);
      }
    }
    


コーディング編 (EF6 版)

  1. MyDbConfiguration の記述 (EF6 のみ)
    app.config / web.config の修正
    接続文字列と同じく、
  2. MyDbContext コンストラクタの記述
  3. OnModelCreating() メソッドの記述
  4. SaveChanges() メソッドの記述

  5. DbContext.Set() メソッドの利用
    Visual Studio が作成するテンプレートでもネット上の情報でも、DbContext 派生クラス内に参照テーブルを下記のように記述せよと解説する。

    public MyDbContext : DbContext {
      public DbSet<TEntity1> TEntity1 { get; set; }
      public DbSet<TEntity2> TEntity2 { get; set; }
      // ... 以下略 ...
    }
    

    これはこれで接続・動作試験のために一度はやってみるべきだが、読み取り専用として使用するのに public set 可能であることとテーブルすべてをプロパティとして記述することが無粋で気に食わない。かといって public 以外のアクセスレベルにしたり set を落とすとコンパイルエラーや不具合になるようだ。Entity Framework が何らかのアクセスをしているからだろうとはいえ、ユーザ定義クラスのプロパティ宣言をしているだけであるため、その実体は他にあるはず。調べると DbContext 基底クラスの Set<TEntity>() メソッドや Set(Type) メソッドがそれらしい。これらの返り値が DbSet<TEntity> 型であり、それにクエリを加えると DbQuery<TEntity> 型になるようだ。いずれも IQueryable<TEntity> の実装。そこで MyDbContext クラスではプロパティ宣言する代わりにジェネリックで読み取り専用メソッドを作成し、一方で DbSet メソッド 2 つを直接操作できないように override する。

    public MyDbContext : DbContext, IDataSourceContext {
      public IQueryable<T> ReadTable<T>() where T : class, new() =>
        base.Set<T>().AsNoTracking(); // .AsNoTracking() でデータ変更の追跡を行わない = テーブル個別に ReadOnly とする
    
      public sealed override DbSet Set(Type entityType) =>
        new NotSupportedException($"{this.GetType().Name} は読み取り専用コンテクストであるため、 Set() メソッドの直接呼出しはできません。");
    
      public sealed override DbSet Set<TEntity>() =>
        new NotSupportedException($"{this.GetType().Name} は読み取り専用コンテクストであるため、 Set<TEntity>() メソッドの直接呼出しはできません。");
    }
    

    ついでに、この IQueryable<T> ReadTable<T>() where T : class, new() メソッドを含むインターフェース (ここでは IDataSourceContext とする) を定義し、MyDbContext も LINQtoCSV を通じて読み込むデータを管理する CSV のコンテクストもこのインターフェースを装備すれば、データ源泉に RDB / CSV の違いがあろうとも無差別・透過的にデータ処理することができる*6


  6. POCO エンティティ・クラスの記述

  7. POCO エンティティクラスの記述は下記のサンプルのとおり。

    public class SampleEntity : Entity {
      public DateTime Column0 { get; set; }
      public string        Column1{ get; set; }
      public int?           Column2 { get; set; }
    
      public void SetAttribute(DbModelBuilder modelBuilder, string schema) {
        var entity =
          modelBuilder.Entity<SampleEntity>()
            .HasKey( poco => poco.Column0 );
    
        if (schema != null)
          entity.ToTable(this.GetType().Name, schema);
        else
          entity.ToTable(this.GetType().Name);
      }
    
    public abstract class Entity {
      public abstract void SetAttribute(ModelBuilder modelBuilder, string schema);
    }
    

    フレームワークに依存せずにデータコンテナとして最小限の記述しか要求しない POCO スタイル、データコンテナの属性をアノテーションではなく外部から定義できるようにする Fluent API は美しい。テーブル名はエンティティ固有の情報のため POCO クラスの情報を引用し Reflection で与えたい、一方でスキーマはエンティティ依存の情報ではないため、外から Fluent API で与えたいところ。ところが、EF6 のSystem.Data.Entity.DbModelBuilder の Entity() メソッドは Type を引数で受けるオーバーロードはなく、型引数を受け入れるジェネリック版しか用意されていないため非常に扱いづらい。また ToTable() メソッドには引数を 2 つ取るもの (第 2 引数はスキーム名) と 1 つ取るものがあるが、前者の第 2 引数に何を与えたら後者と同じになるのか Microsoft Docs に記述がないために条件分岐で使い分けせざるを得ず、上記のような汚い実装になった。

  8. クエリ文字列の置換ロジックの記述

コーディング編 (EF Core 版)

  1. MyDbConfiguration の記述
    EF Core の場合は記述しなくても想定通りに動作した。
  2. MyDbContext コンストラクタの記述
  3. OnModelCreating() メソッドの記述
  4. SaveChanges() メソッドの記述
    EF Core の場合は DbContext 全体1を追跡なしに設定できるため、fail safe 目的で override しなくても問題がない。
  5. DbContext.Set() メソッドの利用
    EF Core の場合は DbContext 全体を追跡なしに設定できるため、読み取り専用のラッパーをかける必要がない。
  6. POCO エンティティ・クラスの記述
    EF6 の場合とまったく同じ。


ポイントは数多くあるが、総じていえる思想は「いきなり完成形を目指さず、実績のある最も簡単なサンプルから始めて一歩一歩進める」ということだ。データベース・アプリ開発で発生する問題は複合的であり、要因は 1 つとは限らないため、うまくいく事例と比較して問題を 1 つ 1 つ切り分けて進める必要がある。

*1:部分的に Windows 10, Visual Studio 2019, C# 8, .NET Core 2.2 でも実験してみたが、趣旨に影響はない。

*2:日付処理について、EF6 では DbFinctions の固定値しか受け入れられない日付関数を使うしかないが、EF Core では new DateTime() も .AddDays() もそのまま使える。しかも EF Core の .AddDays() は変数を受け入れられるため応用が利く。

*3:ドライバー・インストールは EDM を作成する Database First, Model First のときにのみ必要なのだろう。

*4:Oracle では一般にユーザ名と同じスキーマが使われるが、ユーザ共通のスキーマを通じてアクセスさせる DB もあり、この辺りを誤ると不毛なトラブルシューティングに労力を費やす。

*5:Database.SetInitializer() の記述により同期の方法を、既存 DB がなければ Create Table、常に Drop Table & Create Table、等に切り替えることができる。開発フェーズの進展によって切り替えられるのは便利だが、引数 1 つで Drop Table しうるのはかなりリスキーである。

*6:業務ユーザとしては、純粋に RDB のみ扱うわけではなく、CSV とハイブリッドでデータを捏ねることがある。ちなみに new() 制約は、空のデータコンテナに文字列を Parse してデータを埋め込む CSV 側から来るもの。