Try .NET Core

.NET Coreを動かした、試した記録を書き残します。

音楽サーバ"Mopidy"のフロントエンドを作る:03 EF-Coreのコードファーストでテーブルを作る

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第3回です。
今回は、Entity Framework Coreを使ったデータベース作りをなぞっていきます。

Entity Framework Coreとは?

Entity Framework Coreは、Microsoft製のORラッパーです。
よくEF-Coreなどと省略されてます。
Microsoft SQL Serverは元より、MySQLOracleSQLiteなど、基本的なRMDBプロダクトは対応しています。

ASP.Net Coreとは直接関係が無い、独立したプロダクトなので、コマンドラインツールやデスクトップGUIアプリでも使うことが出来ます。

その一番の特徴は、テーブル定義をコードで書いた上で、コードを元にテーブルを生成する"コードファースト"という仕組みでしょう。

今回はMopidyのAPI応答や仕様ドキュメントを元にテーブル定義を書き、実際にデータベース/テーブルが生成されるまでを追っていきます。

Mopidyのデータ構造を調べる

前回MopidyのAPI呼び出しが成功しましたので、各種API仕様ドキュメントを参照しつつ、Mopidyのデータ構造を調査していきます。

やりたいことは、ジャンル/アーティスト/アルバムのリレーション作りです。
ざっとドキュメントを見ると、それぞれの一覧を取得するには LibraryControllerのメソッドが使えそうです。

前回試した"core.library.search"は、LibraryController.searchメソッドですね。
何も引数を渡さないと、トラックが100件返ってきていました。

私のMopidyに登録してある曲数は100件を遥かに超えるので、応答はリミッティングされるようです。
そもそも、各種条件で絞り込みをするためのメソッドのように見えます。

すると、browseメソッドが適当なのかな?
しかし、引数の"uri"って何を渡せば...?
ここで、現在稼働中のフロントエンド「Iris」にBrowseというメニューがあったのを思い出します。

ためしにアルバムを見てみると...
f:id:try_dot_net_core:20190802140757j:plain
お。URLの構造がAPIそのまんまっぽいですね。

URIエンコードされたものをデコードしてみると。

http://192.168.254.251:6680/iris/library/browse/local%3Adirectory%3Ftype%3Dalbum
 ↓
http://192.168.254.251:6680/iris/library/browse/local:directory?type=album

ほうほう。引数に”local:directory?type=album"と渡せばいいのかな?

また、browseメソッドの戻り値はRef型のリストだよ、と書いてあります。
Ref型は、

  • uri (string) – object URI
  • name (string) – object name
  • type (string) – object type

の三つが入っている、と。

TypeScriptでAlbumのRef型を取ってみる

そのへんを試していたのが、このあたりのコミットです。
まずはRef型エンティティを書きます。
src/ts/Models/Entities/MopidyRef.ts:

export default class MopidyRef {
    public type: string;
    public name: string;
    public uri: string;
}

続いて、アルバムクエリ。AlbumStoreを作り、クエリを書いていきます。
src/ts/Stores/AlbumStore.ts:

export default class AlbumStore extends StoreBase<Album> {

    public async Init(): Promise<boolean> {
        const entities: Album[] = [];

        const params = {
            uri: 'local:directory?type=album'
        };
        const result = await this.Query(ApiMethods.LibraryBrowse, params);
        const refs: MopidyRef[] = result.result;

        _.each(refs, (ref) => {
            entities.push(new Album(ref.name, ref.uri));
        });

        this.Entities = Libraries.Enumerable.from(entities);

        return true;
    }
}

this.Queryの第一引数"ApiMethods.LibraryBrowse"は、定数定義したメソッド文字列"core.library.browse"が入っています。
実行してみると...
f:id:try_dot_net_core:20190802143754j:plain
lengthが7667件、これは恐らく全件とれてますね!
アルバムの名前がずらり、そして画面では見切れてますが"uri"値も入っています。
テーブルの元ネタがとれましたので、これをSQLiteに保存していきます。

AspCoreでテーブルを定義する

SQLiteのテーブルを作り、データをインポートしたのが、この頃のコミットです。

EF-Coreでテーブルを作るには、まずEntityクラスを定義します。
aspCore/Models/Entities/Album.cs:

[Table("albums")]
public class Album
{
    [Key]
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Required]
    public string Uri { get; set; }

    public int? Year { get; set; }

    public string ImageUri { get; set; }
}

発売年や画像URIなど、先々取得したい値はひとまずnullableな値で定義しています。
public int? Year?マークがnullable定義ですね。

このEntityクラスを、Asp.Net CoreのMicrosoft.EntityFrameworkCore.DbContextからDbSet<TEntity>ジェネリクス型で参照させます。
今回はDbcというDbContextを継承したサブクラスを作り、そこにDbSet<Album>を定義しています。
aspCore/Models/Dbc.cs:

public class Dbc: DbContext
{
    private class LockerObject
    {
        public bool IsLocked { get; set; }
    }

    private static LockerObject Locker = new LockerObject();


    public DbSet<Album> Albums { get; set; }  // <-ここでAlbumエンティティを参照します。

    public Dbc(DbContextOptions<Dbc> options)
        : base(options)
    {
        Xb.Util.Out("Dbc.Constructor");
    }

    public override int SaveChanges()
    {
        var result = default(int);

        lock (Dbc.Locker)
        {
            Dbc.Locker.IsLocked = true;

            result = base.SaveChanges();

            Dbc.Locker.IsLocked = false;
        }

        return result;
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        var result = default(int);

        lock (Dbc.Locker)
        {
            Dbc.Locker.IsLocked = true;

            result = base.SaveChanges();

            Dbc.Locker.IsLocked = false;
        }

        return result;
    }

    public override void Dispose()
    {
        Xb.Util.Out("Dbc.Dispose");
        base.Dispose();
    }
}

SaveChangesSaveChangesAsyncをoverrideしてあるのは、マルチスレッドでSQLiteへの並行書き込みをさせないためです。
書き込み前にlockして、書き込みが終わるまでlockを保持するようにします。

最近のC#ではTask.Runメソッドとasync/awaitのおかげで、随分とお手軽にマルチスレッドコードが書けるようになりました。
async/awaitのアプローチは、その後Javascriptにも採用されましたね。

お手軽に並行処理が走る分、並行処理に耐えられない箇所の対策が必要になります。
SQLiteは複数接続で使うRDBではないため、並行で書き込むと落ちてしまうんですね。

AspCoreでDBマイグレーションする

コードが書けたら、データベースとテーブルを実際に生成してみます。

私はVisual Studioにどっぷり依存していますので、ここではVisual Studioパッケージマネージャーコンソールを使っています。
まず、C#がデータベース/テーブルをメンテするコード、いわゆるマイグレーションを生成します。
f:id:try_dot_net_core:20190802154612j:plain

PM > add-migration CreateAlbums

成功すると、生成されたマイグレーションコードが出てきます。
f:id:try_dot_net_core:20190802155101j:plain

続いて、マイグレーションコードを実行し、データベース/テーブルを生成します。
f:id:try_dot_net_core:20190802155324j:plain

PM > update-database

成功すると、データベースファイルが作られます。
f:id:try_dot_net_core:20190802155513j:plain

SQLite用の管理ツール、PupSQLiteでファイルを開いてみると。
f:id:try_dot_net_core:20190802155713j:plain お~、テーブル上にAlbumエンティティに書いたカラムが出来てますね!

AspCoreでアルバムデータをインポートする

続いて、Mopidyから取得したアルバムデータをインポートしてみます。
まずはMopidyの応答であるRef型エンティティの定義です。
aspCore/Models/Mopidy/Ref.cs:

public class Ref
{
    public string type;
    public string name;
    public string uri;
}

Ref型は値が3つしかない、単純な型ですね。

次はアルバムクエリを、AspCore側で実行するコードです。
Mopidyにクエリするための基底Storeクラスとして、MopidyStoreBaseを作ります。
ロジックは、前回書いたJsonRpcControllerとほぼ同じです。
aspCore/Models/Bases/MopidyStoreBase.cs:

public abstract class MopidyStoreBase<T> : StoreBase<T>
{
    protected MopidyStoreBase(Dbc dbc) : base(dbc)
    {
    }

    protected async Task<object> QueryMopidy(JsonRpcQuery request)
    {
        var url = "http://192.168.254.251:6680/mopidy/rpc";
        HttpResponseMessage message;
        var client = new HttpClient();
        client.DefaultRequestHeaders.Accept.Clear();
        client.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json")
        );
        client.DefaultRequestHeaders.Add("User-Agent", ".NET Foundation Repository Reporter");

        try
        {
            var sendJson = JsonConvert.SerializeObject(request);
            var content = new StringContent(sendJson, Encoding.UTF8, "application/json");
            content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
            message = await client.PostAsync(url, content);
        }
        catch (Exception ex)
        {
            throw ex;
        }

        var json = await message.Content.ReadAsStringAsync();
        var response = JsonConvert.DeserializeObject<JsonRpcParamsResponse>(json);

        if (response.error != null)
            throw new Exception($"Mopidy Query Error: {response.error}");

        return response.result;
    }
}

※こちらのコードも前回同様に、Linuxでは動かなくなる不具合が含まれています。※
正しくはこちらのリリース時の実装のように、HttpClientをusingでラップし、必ず破棄されるようにしてください。

続いていよいよ、アルバム情報を取ってくるStoreを作ります。 aspCore/Models/Albums/AlbumStore.cs:

public class AlbumStore : MopidyStoreBase<Album>
{
    private const string QueryString = "local:directory?type=album";


    public AlbumStore([FromServices] Dbc dbc) : base(dbc)
    {
    }


    public async Task<bool> Refresh()
    {
        this.Dbc.Albums.RemoveRange(this.Dbc.Albums);
        await this.Dbc.SaveChangesAsync();

        var args = new MethodArgs(QueryString);
        var request = JsonRpcFactory.CreateRequest("core.library.browse", args);

        var resultObject = await this.QueryMopidy(request);

        // 戻り値の型は、[ JObject | JArray | JValue | null ] のどれか。
        // 型が違うとパースエラーになる。
        var result = JArray.FromObject(resultObject).ToObject<List<Ref>>();

        var albums = result.Select(e => new Album()
        {
            Name = e.name,
            Uri = e.uri
        }).ToArray();

        try
        {
            this.Dbc.Albums.AddRange(albums);
        }
        catch (Exception ex)
        {
            throw;
        }


        await this.Dbc.SaveChangesAsync();

        return true;
    }
}
  1. 一旦現在のレコードを全消しした上で、
  2. 取得したRef型からAlbumオブジェクトを生成し、
  3. Dbcに渡し、
  4. 全部保存する

という流れですね。

前回作ったコントローラではパラメータごとの分岐が面倒だったので、ここではFactoryメソッドを作って整形してもらうようにしました。

そして、忘れがちな最後のひと手間、AlbumStoreクラスをDIコンテナに登録します。
aspCore/Startup.csのConfigureServicesメソッド末尾に、下記を追記します。

services
    .AddTransient<AlbumStore, AlbumStore>();

詳細は後の記事で上げる予定ですが、「生成都度破棄するAlbumStore型のクラスがあるので、DIコンテナさん生成ヨロシクね」のような意味合いになります。

ひとまずこれが動くのかどうか、HomeControllerの起動時に差し込んで試します。

aspCore/Controllers/HomeController.cs:

public class HomeController : Controller
{
    private const string IndexDevName = "index.dev.html";
    private static readonly string IndexDevPath
        = System.IO.Path.Combine(Program.DistPath, HomeController.IndexDevName);
    private static readonly byte[] IndexDevBytes
        = System.IO.File.ReadAllBytes(HomeController.IndexDevPath);

    public async Task<IActionResult> Index([FromServices] AlbumStore stroe)
    {
        await stroe.Refresh(); //<-ここでインポートします。

        return this.File(HomeController.IndexDevBytes, "text/html");
    }
}

実行してしばらくすると、AspCoreのコマンドプロンプトで大量のINSERT文が走ります。
f:id:try_dot_net_core:20190802162924j:plain

PupSQLiteで中身をみてみると... f:id:try_dot_net_core:20190802163126j:plain
データ入ってますね!

これで、AspCoreのDB操作の土台が出来ました!