音楽サーバ"Mopidy"のフロントエンドを作る:03 EF-Coreのコードファーストでテーブルを作る
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第3回です。
今回は、Entity Framework Coreを使ったデータベース作りをなぞっていきます。
Entity Framework Coreとは?
Entity Framework Coreは、Microsoft製のORラッパーです。
よくEF-Coreなどと省略されてます。
Microsoft SQL Serverは元より、MySQL、Oracle、SQLiteなど、基本的な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というメニューがあったのを思い出します。
ためしにアルバムを見てみると...
お。URLの構造がAPIそのまんまっぽいですね。
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型は、
の三つが入っている、と。
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"が入っています。
実行してみると...
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(); } }
SaveChanges
とSaveChangesAsync
をoverrideしてあるのは、マルチスレッドでSQLiteへの並行書き込みをさせないためです。
書き込み前にlockして、書き込みが終わるまでlockを保持するようにします。
最近のC#ではTask.Runメソッドとasync/awaitのおかげで、随分とお手軽にマルチスレッドコードが書けるようになりました。
async/awaitのアプローチは、その後Javascriptにも採用されましたね。
お手軽に並行処理が走る分、並行処理に耐えられない箇所の対策が必要になります。
SQLiteは複数接続で使うRDBではないため、並行で書き込むと落ちてしまうんですね。
AspCoreでDBマイグレーションする
コードが書けたら、データベースとテーブルを実際に生成してみます。
私はVisual Studioにどっぷり依存していますので、ここではVisual Studioのパッケージマネージャーコンソール
を使っています。
まず、C#がデータベース/テーブルをメンテするコード、いわゆるマイグレーションを生成します。
PM > add-migration CreateAlbums
成功すると、生成されたマイグレーションコードが出てきます。
続いて、マイグレーションコードを実行し、データベース/テーブルを生成します。
PM > update-database
成功すると、データベースファイルが作られます。
SQLite用の管理ツール、PupSQLite
でファイルを開いてみると。
お~、テーブル上に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; } }
- 一旦現在のレコードを全消しした上で、
- 取得した
Ref
型からAlbum
オブジェクトを生成し、 - Dbcに渡し、
- 全部保存する
という流れですね。
前回作ったコントローラではパラメータごとの分岐が面倒だったので、ここでは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文が走ります。
PupSQLite
で中身をみてみると...
データ入ってますね!
これで、AspCoreのDB操作の土台が出来ました!