音楽サーバ"Mopidy"のフロントエンドを作る:07 無限ローディングを組み込む
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第7回です。
今回は、Vueのプラグインを使った無限ローディングの実装を追っていきます。
Vue-infinite-loadingなるもの
まあ、誰か作ってるんちゃうの?的にぐぐったところ。
名前もそのまんま、Vue-infinite-loading
なるライブラリが散見されますね。
これが定番なのかな?
実装例の記事も、とてもシンプルでした。
pdo99.hatenablog.com
www.kabanoki.net
公式ページはこちら。
英字と中文、とあるので、中国の方でしょうか。
Get Startedのエフェクトかっこいい!
peachscript.github.io
ということで、これを導入してみることにしました!
インストール
公式ページにあるように、npmから貰って来ます。
# npm install vue-infinite-loading
TypeScript用型定義は、パッケージに含まれていました。
いやあ、最近は便利でいい!
書いてみる
そのVue-Infinite-Loading
の導入を試していたのが、この頃のコミットです。
音楽プレイヤーとしては、ジャンルはともかくアーティストやアルバムは、どれだけの数があるか分かりゃしません。
ということで、アーティストのリストに組み込んでみたのがこちら。
src/ts/Views/Finders/ArtistList.ts:
import ViewBase from '../Bases/ViewBase'; import Component from 'vue-class-component'; import ArtistStore from '../../Models/Artists/ArtistStore'; import SelectionItem from '../Shared/SelectionItem'; import Artist from 'src/ts/Models/Artists/Artist'; import Vue from 'vue'; import { default as InfiniteLoading, StateChanger } from 'vue-infinite-loading'; Vue.use(InfiniteLoading); // -- 一部省略 -- @Component({ template: `<div class="col-md-2 h-100"> <div class="card h-100"> <div class="card-header with-border bg-info"> <h3 class="card-title">Artists</h3> </div> <div class="card-body list-scrollable"> <ul class="nav nav-pills h-100 d-flex flex-column flex-nowrap"> <template v-for="entity in entities"> <selection-item ref="Items" v-bind:entity="entity" /> </template> <!-- ここに無限ローディングコンポーネント --> <infinite-loading @infinite="OnInfinite"></infinite-loading> </ul> </div> </div> </div>`, components: { 'selection-item': SelectionItem } }) export default class ArtistList extends ViewBase { private store: ArtistStore = new ArtistStore(); private page: number = 1; private genreIds: number[] = []; private entities: Artist[] = []; // ここで読み込み private async OnInfinite($state: StateChanger): Promise<boolean> { var result = await this.store.GetList(this.genreIds, this.page); if (0 < result.ResultList.length) this.entities = this.entities.concat(result.ResultList); if (this.entities.length < result.TotalLength) { $state.loaded(); this.page++; } else { $state.complete(); } return true; } }
まだ事例を読みつつトライ&エラーな状態だったため、Plugin API
で書いてます。
コンポーネントとして導入する場合は、@Component
の引数でこのようにします。
components: { 'selection-item': SelectionItem, 'infinite-loading': InfiniteLoading }
またテンプレートでは、スクロールする外枠を指定しておくと、潰しが効いて便利です。
<infinite-loading @infinite="OnInfinite" force-use-infinite-wrapper=".inner-scrollbox.artist-list" ref="InfiniteLoading" />
それから、肝心の読み込み機能。
テンプレートでハンドルしている@infinite="OnInfinite"
ですが、それはこのinfinite-loading
コンポーネントが「見えるようになった」時点で実行されます。
読み込みの流れとしては、下記のようなものです。
- ArtistStoreから新しいArtist配列を受け取る
- Artist配列を結合
- 読み込み完了判定、続きがある場合は
$state
のloaded
メソッドを、完了した場合はcomplete
メソッドを呼ぶ。
なんとも単純に出来るもんですね!
Artistを貰ってくるところ
では、ArtistStoreの中身はどんなだ、というと。
src/ts/Models/Artists/ArtistStore.ts:
import { default as StoreBase, PagenatedResult } from '../Bases/StoreBase'; import Artist from './Artist'; export default class ArtistStore extends StoreBase<Artist> { public async GetList(genreIds: number[], page: number): Promise<PagenatedResult<Artist>> { const result = await this.QueryGet('Artist/GetPagenatedList', { genreIds: genreIds, page: page }); if (!result.Succeeded) { console.error(result.Errors); throw new Error('Unexpected Error on ApiQuery'); } return result.Result as PagenatedResult<Artist>; } }
APIArtist/GetPagenatedList
に対して、GETクエリしています。
引数として、選択されたジャンルを示す「ジャンルID」の配列、そして要求する「ページ番号」を渡しています。
見た目を「無限ロード」と表現するとはいえ、一皮むけば単なるページング、というのは皆さまご承知のとおりです。
サーバサイドはこんなかんじ
GETクエリを受け取る、AspCore側のArtistコントローラがこちらです。
aspCore/Controllers/ArtistController.cs:
[Produces("application/json")] [Route("Artist")] public class ArtistController : Controller { // -- 中略 -- [HttpGet("GetPagenatedList")] public XhrResponse GetPagenatedList( [FromQuery] int[] genreIds, [FromQuery] int? page, [FromServices] ArtistStore store ) { var result = store.GetPagenatedList(genreIds, page); return XhrResponseFactory.CreateSucceeded(result); } }
まだまだ、パラメータのバケツリレー状態が続いていますね...。
こんどはAspCore上の、ArtistStoreに引数を渡しています。
ではその、AspCore上のArtistStoreがどんな実装か、というと。
aspCore/Models/Artists/ArtistStore.cs:
public class ArtistStore : PagenagedStoreBase<Artist> { // -- 中略 --- public ArtistStore([FromServices] Dbc dbc) : base(dbc) { } public PagenatedResult GetPagenatedList(int[] genreIds, int? page) { var query = this.Dbc.GetArtistQuery(); if (genreIds != null && 0 < genreIds.Length) query = query .Where(e => e.GenreArtists.Any(e2 => genreIds.Contains(e2.GenreId))); var totalLength = query.Count(); query = query.OrderBy(e => e.Name); if (page != null) { query = query .Skip(((int)page - 1) * this.PageLength) .Take(this.PageLength); } var list = query.ToArray(); var result = new PagenatedResult() { TotalLength = totalLength, ResultLength = list.Length, ResultPage = page, ResultList = list }; return result; } // -- 中略 --- }
ああ、やっと実装らしい実装にたどり着きました!
中身の流れとしては、以下のようなものです。
- ジャンルIDの配列がある場合は、ジャンルIDでアーティストを絞り込んでおく。
- 合計のArtist数をとっておく。
- アーティストを名前順に並べる。
- 渡し値ページ番号に該当するアーティスト配列を取ってくる。
- 戻り値に整形して返す。
以前の記事に挙がっていた、EF-Coreで作ったデータベースから値を取ってきています。
だんだんと繋がりが出来てきました!
UIの動きをためしてみる
このコミットを実行してみると、こんな画面が出てきます。
うへえ。Bootstrap臭ぇ...。
いやいや。そのうち直すんです。
そして、Artistsのリストをスクロールしていくと...
(おわかり頂けただろうか...)
でん、と残りスクロール量が増えます。
EF-CoreもVueの描画も結構早いので、一瞬のうちにアーティストが増えてます。
もうちょっとタメがあった方が、ありがたみがあるってもんですが。
そしてAspCoreの出力ではこんな感じで、SELECT文が実行されていることがわかります。
以上、Vue-Infinite-LoadingとAspCoreを使った無限ローディング実装、でした!
音楽サーバ"Mopidy"のフロントエンドを作る:06 Vue+AdminLTEの導入
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第6回です。
今回は、Vue.jsとAdminLTEを導入し、フロントエンドの土台を作ります。
今後しばらく、フロントエンドのお話が中心になる予定です。
Vue.jsの導入
Vue3.x系がまだリリースされていない現在、TypeScriptでVueを書くには、本体のVue.jsに加えてvue-class-component
、vue-property-decorator
を使う方法が主流かと思います。
インストールはお手軽簡単、npm install
コマンドで一発です。
# npm install vue vue-class-component vue-property-decorator
ただし、webpackを使う上では少々注意が必要です。
参照するモジュールは、ESモジュールの方を読み込む必要があります。
webpack.config.jsで、下記のようにエイリアスを書きます。
resolve: { alias: { 'vue$': 'vue/dist/vue.esm.js' } }
vue-class-componentの注意点
vue-class-component
は、クラスベースのTypeScriptに合わせた記述が出来るように、相当に工夫されています。
しかし、本来はアプローチの異なるVueの機能を後付けでTypeScriptに合わせた仕組みのため、使うにあたって少し注意が必要です。
私の場合、Vueを全く知らない状態からvue-class-component
ベースのコードを書き始めたため、下記のような問題にぶち当たりました。
1. コンストラクタで出来ることが限られる
vue-class-component
ではVueクラス継承して機能を書いていきます。
しかし、そのコンストラクタの中では、Vueの機能の一部がまだ初期化されていません。
例えば、下記のようなコンポーネントを書いてみます。
コンストラクタと、その少し後で、Vueのプロパティなどを書き出しています。
import Vue from 'vue'; import Component from 'vue-class-component'; import GenreList from './GenreList'; import { Prop } from 'vue-property-decorator' @Component({ template: `<section class="content"> <div class="row"> <genre-list ref="GenreList" /> </div> </section>`, components: { 'genre-list': GenreList } }) export default class Finder extends Vue { private get GenreList(): GenreList { return this.$refs.GenreList as GenreList; } @Prop() public testProp?: string; public testData: string = 'hello?'; public constructor() { super(); console.log('--- On Constructor ---'); this.Dump(); setTimeout(() => { console.log('--- After Constructor ---'); this.Dump(); }, 10); } private Dump(): void { console.log('this.$data:\r\n' + JSON.stringify(this.$data, this.Replacer, 4)); console.log('this.$props:\r\n' + JSON.stringify(this.$props, this.Replacer, 4)); console.log('this.$on:\r\n' + JSON.stringify(this.$on, this.Replacer, 4)); console.log('this.$emit:\r\n' + JSON.stringify(this.$emit, this.Replacer, 4)); console.log('this.$refs:\r\n' + JSON.stringify(this.$refs, this.Replacer, 4)); console.log('this.$el:\r\n' + JSON.stringify(this.$el, this.Replacer, 4)); } private Replacer(key, value): any { const valueType = typeof value; if (valueType === 'function') return 'function'; if (valueType === 'object') { let result = '{ '; for (let i in value) { result += `${i}: ${value[i]}, ` } result += '}'; return result; } return value; } }
これを実行してみると、どんな結果になるかというと:
const finder = new Finder(); finder.$mount('#root');
--- On Constructor --- this.$data: undefined this.$props: "{ testProp: undefined, }" this.$on: "function" this.$emit: "function" this.$refs: "{ }" this.$el: undefined --- After Constructor --- this.$data: "{ initialized: false, testData: hello?, }" this.$props: "{ testProp: undefined, }" this.$on: "function" this.$emit: "function" this.$refs: "{ GenreList: [object Object], }" this.$el: "{ _prevClass: content, __vue__: [object Object], title: , lang: , translate: .....
コンストラクタの中では、
$props
は取得出来ているものの、$refs
は空オブジェクト$el
はundefined
な状態です。
$refs
や$el
は、$mount
が実行されて初めて生成されるオブジェクトのため、コンストラクタ時点では扱うことが出来ない仕組みなんですね。
2. クラス変数名にプレフィクス'_'を使うことが出来ない
例えば、下記のようにクラス変数に'_message'を持つ下記のようなコード。
import Component from 'vue-class-component'; import Vue from 'vue'; @Component({ template: `<section class="content"> <div class="row"> {{ Message }} </div> </section>` }) export default class TestView extends Vue { private _message: string = 'hello?'; public get Message(): string { return this._message; } }
このコードは、テンプレート{{ Message }}
の箇所に何も表示しません。
これはVue本来の仕様通りで、公式ドキュメントで下記のように記載されています。
Vue の内部的なプロパティや API メソッドと衝突する可能性があるため、 _ または $ から始まるプロパティは Vue インスタンスにプロキシされない ことに注意してください。
基本的に使用禁止、としておいた方が無難です。
3. スーパークラスのメソッドをイベントハンドル出来ない
正確には、ハンドル自体は動くのですが、this
参照がnull
になります。
例えば、こんなスーパークラスを作ります。
import Vue from 'vue'; export default abstract class ViewBase extends Vue { public constructor(options?: any) { super(options); } public OnClicked(): void { this.InnerOnClicked(); } private InnerOnClicked(): void { console.log('hello?'); } }
そして、スーパークラスを継承したサブクラスを作り、イベントを書きます。
import ViewBase from '../Bases/ViewBase'; import Component from 'vue-class-component'; @Component({ template: `<section class="content"> <div class="row" @click="OnClicked"> <!-- clickイベントをハンドル --> {{ Message }} </div> </section>` }) export default class SubView extends ViewBase { private message: string = 'hello?'; public get Message(): string { return this.message; } }
そして、メッセージ部分をクリックすると...
[Vue warn]: Error in v-on handler: "TypeError: Cannot read property 'InnerOnClicked' of null" (found in <Root>) TypeError: Cannot read property 'InnerOnClicked' of null at ViewBase.OnClicked (ViewBase.ts:14) at invokeWithErrorHandling (vue.esm.js:1863) at HTMLDivElement.invoker (vue.esm.js:2188) at HTMLDivElement.original._wrapper (vue.esm.js:7559)
うそーん。
'InnerOnClicked' of nullて、nullの中でInnerOnClickedメソッドを探してる?
Visual Studioでブレイクしてみると。
基底クラス側のthis
が、null
になってしまっています。
そのため、this.InnerOnClicked
メソッドが見えないわけですね。
こんな場合は、サブクラス側で一旦ハンドルしてからスーパークラスを呼びます。
export default class SubView extends ViewBase { private message: string = 'hello?'; public get Message(): string { return this.message; } public OnClicked(): void { // ここでハンドル super.OnClicked(); } }
他にも、触っていて「んー?」となる箇所が所々見受けられました。
vue-class-component
を使う際は、先にある程度書き慣らしてナレッジを積み重ねた方が良いように思います。
TypeScriptネイティブになるという、Vueバージョン3が待ち遠しいですね!
そしてAdminLTE...ってなに?
次に、AdminLTEを導入するのですが...。
導入の時点では、私はAdminLTEについて、殆ど何も知りませんでした。
「どうやら管理画面がお手軽に出来るブツらしい」程度の認識です。
AdminLTE
をぐぐってみると、公式デモページが2つ並んでいました。
2019年8月現在の今も、同じですね。
どうやら、バージョン2系と3系があるようです。
違いは何なのか、調べてみると。
こんな記事が見つかりました。
minory.org
ほうほう、Bootstrapのバージョンが4になった、と。
Bootstrap4は、私はまだ触ったことがありませんでした。
調べてみると、従来のBootstrap3までとは大きく変わったようです。
AdminLTEはよく知りませんが、Bootstrapは今までも色んな案件でお世話になりました。
その新しいバージョンは、先だって試しておいて損はないでしょう。
ということで、今回はAdminLTEのバージョン3系を導入することにしました。
AdminLTEの導入
npmでadmin-lte
をsearchすると、現在のバージョンが2.4だ、と出てきます。
# npm search admin-lte
バージョン3系はまだプレリリース版なんですね。
どんなバージョンがあるのか、調べてみます。
# npm info admin-lte versions
2019年8月7日現在で調べると、3.0.0-beta.2
まで出ています。
が、私が導入した際はまだ3.0.0-beta.1
までしかありませんでした。
その3.0.0-beta.1
が入った状態を、再現してみます。
# npm install admin-lte@3.0.0-beta.1
どんなブツが入ったか、というと。
ほうほう。imgフォルダはデモ用の画像っぽいので、スルーしてよさげ。
dist/css/adminlte.css
と、dist/js/adminlte.js
を読んでおけばよい感じです。
AdminLTEの導入で間違える
VueとAdminLTEを試していたのが、この頃のコミットです。
ここで私は、愚かな間違いを犯します。
AdminLTEのプラグインフォルダを見ると。
bootstrap
を始め、有名な実装がたんと詰まっています。
中でも Ion.RangeSlider は、見た目もかっこいいし、音楽プレイヤーのボリューム調整パーツに使えそうです。
そしてそれは、jQueryプラグインらしい、と。
そこで、私はjQueryを単体でインストールしました。
この時点での、package.jsonのdependencies
はこちら。
"dependencies": { "admin-lte": "^3.0.0-beta.1", "animate.css": "^3.7.2", "axios": "^0.19.0", "es6-promise": "^4.2.8", "font-awesome": "^4.7.0", "jquery": "^3.4.1", "linq": "3.1.1", "lodash": "^4.17.11", "qs": "^6.7.0", "requirejs": "^2.3.6", "vue": "^2.6.10", "vue-class-component": "^7.1.0", "vue-property-decorator": "^8.2.1" }
これが、この後5日間に渡り私の頭を悩ませる原因となりました。
何が間違いだったか、というと。
AdminLTEをimportしたとき、それにつられてnode_modules/admin-lte/plugins/jquery/jquery.js
が、一緒に読み込まれます。
そして、別途単体でもjQueryをimportしています。
2つのjQueryが個別に存在する、という状況を作ってしまったため、「組み込んだはずのプラグインのメソッドが呼び出せない」という現象に悩まされることとなりました。
これを解決するには!
まず単体でインストールしたjQueryを削除します。
その上で、webpack.config.jsで、jQueryのエイリアスとパスを指定してやります。
webpack.config.prod.js
// -- 一部抜粋 -- resolve: { alias: { // Admin-LTE側のJQueryを読ませる。 'jquery': 'admin-lte/node_modules/jquery/dist/jquery.js', 'vue$': 'vue/dist/vue.esm.js' } }, plugins: [ new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' }) ]
こんな感じですね。
UIパーツのコードを書く
さてさて、ライブラリのインストールが終わったところで!
実際にVueとAdminLTEを使った、UIのコードを見ていきます。
まずはVueを使った基底クラス、ViewBaseを作りました。
src/ts/Views/Bases/ViewBase.ts
import Vue from 'vue'; import * as _ from 'lodash'; export default abstract class ViewBase extends Vue { private initialized: boolean = false; public constructor(options?: any) { super(options); } public async Initialize(): Promise<boolean> { if (this.initialized) return true; const promises: Promise<boolean>[] = []; _.each(this.$children, (view) => { if (view instanceof ViewBase) promises.push((view as ViewBase).Initialize()); }); await Promise.all(promises); return true; } }
コンストラクタでは何も出来ないため、初期化用のメソッドInitialize
を作ります。
サブクラスでInitialize
をoverrideしてイベント定義やらを書くつもりです。
そして、全てのViewのルートとなるRootView
を作ります。
src/ts/Views/RootView.ts
import ViewBase from './Bases/ViewBase'; import Sidebar from './Sidebars/Sidebar'; import Finder from './Finders/Finder'; export default class RootView extends ViewBase { public constructor() { super({ template: `<div class="wrapper" style="height: auto; min-height: 100%;"> <sidebar ref="Sidebar" /> <div class="content-wrapper" style="height: 100%"> <section class="content-header"> <h1 ref="ContentTitle">{{ contentTitleString }}</h1> </section> <finder ref="Finder" /> </div> </div>`, components: { 'sidebar': Sidebar, 'finder': Finder } }); } private contentTitleString: string = 'Finder'; }
ん?なんでコンストラクタ引数にテンプレート類を渡してるんだろう?
こんなことも出来るよ、というテストがしたかったのかな?
特に意味は無く、これは後にvue-class-component
ベースで書き直しています。
サイドバーはまだ見た目だけなので、割愛。
コンテンツ部にFinder
というViewを作っています。
src/ts/Views/Finders/Finder.ts
import ViewBase from '../Bases/ViewBase'; import Component from 'vue-class-component'; import GenreList from './GenreList'; @Component({ template: `<section class="content"> <div class="row"> <genre-list ref="GenreList" /> </div> </section>`, components: { 'genre-list': GenreList } }) export default class Finder extends ViewBase { private get GenreList(): GenreList { return this.$refs.GenreList as GenreList; } }
これも、ただの入れ物ですね。
入れ物の中身、GenreListはというと。
src/ts/Views/Finders/GenreList.ts
import ViewBase from '../Bases/ViewBase'; import Component from 'vue-class-component'; import GenreStore from '../../Models/Genres/GenreStore'; import SelectionItem from '../Shared/SelectionItem'; import Genre from 'src/ts/Models/Genres/Genre'; @Component({ template: `<div class="col-md-3"> <div class="card"> <div class="card-header with-border bg-green"> <h3 class="card-title">Genre</h3> <div class="card-tools"> <button type="button" class="btn btn-tool" @click="OnClickRemove" > <i class="fa fa-remove" /> </button> </div> </div> <div class="card-body"> <ul class="nav nav-pills flex-column"> <template v-for="genre in genres"> <selection-item ref="Items" v-bind:entity="genre" @click="OnClickItem" /> </template> </ul> </div> </div> </div>`, components: { 'selection-item': SelectionItem } }) export default class GenreList extends ViewBase { private genreStore: GenreStore = new GenreStore(); private genres: Genre[] = []; public async Initialize(): Promise<boolean> { await super.Initialize(); this.genres = (await this.genreStore.GetList()) .orderBy(e => e.Name) .toArray(); return true; } private OnClickRemove(): void { } private OnClickItem(): void { } }
ロジックらしいロジックが、初めて出てきました。
Viewbaseで書いておいたInitialize
メソッドを、ここでoverrideしています。
ジャンルのStoreからリストを貰っています。
GetList
の戻り値をlinq.js
でEnumerable化しているため、ここで名前順に並べ替えるメソッドを試していますね。
そして、クラス変数genres
にセットしたGenre型の配列を、v-for
でループして描画しています。
さて、どんな絵面になるでしょう...?
これを描画してみると。
const root = new RootView(); root.$mount('#root');
うおー。めっちゃ縮小されとる!
しかしどうやら、ジャンルの一覧が名前順に出てきてるようです。
UIの第一歩としては、上々です!
音楽サーバ"Mopidy"のフロントエンドを作る:小休止 - Visual Studioの環境づくり
"Mopidy"フロントエンド「Mopidy.Finder」が出来るまで、今回は小休止です。
近年のWeb系のみなさんには不評この上ないWindows+IDEですが、セットアップさえしてしまえば、強力な助っ人になってくれます。
WindowsでVisual Studioの開発環境を整えるまでの手順を、少しずつUIを追いながら、書き綴ってみたいと思ます。
Visual Studioのインストール
まずはVisual Studio。現行最新はVisual Studio 2019です。
特にCommunity Editionは、小規模事業者や学生さん向けに無償で配布されています。
無償とはいえ、機能はほぼProfessional Editionと変わりありません。
この「無償ダウンロード」リンクから、インストーラがダウンロードできます。
このインストーラを起動すると、Visual Studioの機能を選択する画面が出てきます。
私の場合は、過去案件の保守の関係もあり、下記の4つをいつも入れています。
- ASP.NETとWeb開発
- .NET デスクトップ開発
- .NETによるモバイル開発
- .NET Core クロスプラットフォームの開発
機能を選んで「インストール」ボタンを押すと、インストールが始まります。
インストールが終わると、Visual Studioが起動します。
あらかじめ作っておいた、Microsoftアカウント
を使ってサインインします。
こちらが起動直後の、プレーンなVisual Studio 2019の画面です。
ためしに一度、ASP.Net Coreプロジェクトを作ってみます。
「新しいプロジェクトの作成」をクリックすると、プロジェクトテンプレートの一覧が出てきます。
ASP.Net Coreプロジェクトを選んで進めると、こんな画面。
「作成」ボタンを押すと、さらに詳細なテンプレート選択が出てきます。
おっと、ASP.Net Coreのバージョン 2.2
が選べませんね。
これは後からインストールする必要があるようです。
一旦このまま、プロジェクトを作ってみます。
そして、作り立てのプロジェクトの最初の画面。
最初はファイル一覧などが右側に配置されてます。
私は左側に置いておきたいので、移動させます。
プロパティウインドウはデスクトップアプリしか使わないため、左帯に追いやりました。
Extensionのインストール
Visual Studioでは拡張機能が色々用意されています。
このままでも十分使い物になるのですが、そこはお好みで。
Extensionのインストールは、メニュー「拡張機能」からです。
「Manage Extensions」画面の左ペインで、オンライン
を開きます。
まずは定番、Microsoftさん御謹製のProductivity Power Toolsです。
そして、改行コードを保存時に整形してくれる「Line Endings Unifier」
Markdownの編集機能も便利です。
ひととおり欲しいものを「Download」ボタンで追加しておき、画面を閉じます。
そして、Visual Studio自体も一回閉じてしまいます。
すると、Extensionのインストーラが走ります。
しばらく待つと、インストール対象の確認ダイアログが出ます。
そしてVisual Studioを起動すると、Extensionがインストールされた状態で開かれます。
いらないExtensionを無効化する場合は、同様にメニュー「拡張機能」から「拡張機能の管理」を開きます。
Productivity Power Toolsは沢山のExtensionのセットパッケージです。
私の場合は「Shrink Empty Lines」があまり好みではないため、無効化しています。
.Net Core SDKの追加インストール
前述でプロジェクトを作ったとき、ASP.Net Core 2.2
が選択できませんでした。
これは、.Net Core SDK 2.2
がまだ入ってないためです。
追加でインストールしましょう。
え...っと、どれだ?と迷いました。
Visual Studio用のインストールなので、左側三番目かなぁ?
選んでみると、注釈が出ていました。
ほうほう、2019で使うならそのリンクなんですね。ぽちっとな。
なんだか色々出てきました。
この辺がMSさんの洗練されてないところいや、素朴なところと言っておきます(?)
環境はWindows10-x64なので、SDK2.2.401のx64インストーラを選びます。
インストーラがダウンロードされました。
早速インストール。
インストール終了後、Visual Studioを起動。
また新しくASP.Net Coreプロジェクトを作ると、リストにASP.NET Core 2.2
が出てくるようになりました。
node.jsのインストール
node.js開発機能は選択しませんでしたので、現状ではまだnode.jsがインストールされていません。
コマンドプロンプトをだして...
npmをお試し。
はい、無いですね。
公式配布ページから、node.jsをもらって来ます。
最新の12.7.0 Current
を選びました。
ダウンロードしたインストーラがこちら。
インストーラを起動し、デフォルト状態で次へ次へと進みます。
インストール後、コマンドプロンプトを起動して、お試し。
よしよし、入ってますね。
Visual Studioのプロジェクトを開いて、npmが使えるか試してみます。
どれどれ。
おー。npm init
が通りました。
さてこの調子で、webpackやらmochaやらのインストールを進めていくと。
あら。ダメ出しされちゃいました。
メッセージを読むと、rejected by your operationg system
とな。
こりゃ権限エラーかな、ということで、WindowsのUACを無効化してみます。
コントロールパネルを出して...
ユーザーアカウント
を開いていくと、ありますね。
ユーザーアカウント制御設定の変更
です。
これを、最低設定にします。
UACは再起動しないと適用されませんので、再起動かけます。
さて、どうよ?
おー。無事にnpmパッケージがインストールされました。よしよし。
TSCの設定
さてここで、tsc(Typescriptコンパイラ)が走るかどうか、試してみます。
おっと、ダメですね。
tscの存在が分からないらしい。
tscはどこにあるか、というと。
Visual Studioでインストールした場合、ここに入ります。
C:\Program Files (x86)\Microsoft SDKs\TypeScript\[version]
ですね。
ここにパスを通してみます。
一番最後に、TypeScriptのインストールパスを追記しました。
さて、どうよ?
ありゃ?
tscを、Windows Script Host
が実行しちゃってますね。
これをnode.jsが実行するように、変更します。
C:\Program Files (x86)\Microsoft SDKs\TypeScript\3.5\tsc.js
のプロパティから。
実行するプログラムを変更します。
最初はNode.js: Server-side Javascript
の選択肢がありません。
「このPCで別のアプリを探す」から、node.exeを探してセットします。
デフォルトインストールの場合、C:\Program Files\nodejs\node.exe
となるはずです。
実行プログラムをnodeに変更し、リトライです。
よしよし、ちゃんと動いてますね。
Chromeのセットアップ
Visual Studio 2017からは、標準でChromeデバッガへのアタッチが可能になりました。
これまではIEかEdgeでしか出来なかったJavascriptのステップデバッグが、Chromeで出来るようになったんですね。
これを使わない手はない!ということで、Chromeを入れます。
まずはWebから、インストーラを貰ってきてセットアップ。
インストール後に出来るChromeのショートカットに、デバッガポートの設定を追記します。
chrome.exe
の後ろに、--remote-debugging-port=9222
と追記します。
これを一回起動しておけば、あとはChromeが覚えていてくれるようです。
動作環境のテスト
さて、ひととおりセットアップが終わったはずです。
ここでMopidy.Finderの初期コミットを貰ってきて、デバッガの動作を試します。
1つめはフォルダ構造がよろしくないため、2つ目のコミットをダウンロードします。
zipを解凍してプロジェクトを開きます。
まずはnpmパッケージをインストール。
ん、なんか警告出てるけど。まあ一応通りました。
この警告は、lodash
の脆弱性付きバージョンに対するものだったと思います。
テストは通るかな?
よしよし、通ってますね。
ビルドも、問題なく通ります。
Visual StudioでTypeScriptコードにブレイクポイントを置き、ブラウザをChromeに。
これを実行してみると。
Chromeが出てきて、「デバッガで止まってるよ」と表示が出ます。
Visual Studioに戻ってみます。
おーし!
ブレイクポイントで止まっています。
既に実行済みの、song1
の生成後の値も確認できますね。
というわけで、今回は環境構築のおはなし、でした!
音楽サーバ"Mopidy"のフロントエンドを作る:05 AspCoreのDIの仕組み
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第5回です。
さて今回は、ASP.Net CoreのDI(Dependency Injection)の仕組みを追っていきます。
DI(Dependency Injection)ってなに?
日本語では「依存性の注入」などと表現されます。
...えっとナニソレ?字面だけ見てもさっぱり分からないシロモノですよね。
何がどう便利なの?という点については、下記のQiita記事がとても詳細に例を挙げて解説して頂いています。
「なぜDI(依存性注入)が必要なのか?」についてGoogleが解説しているページを翻訳した - Qiita
クラスベースのオブジェクト指向言語で、
といった目的を追い求めて出来上がった、デザインパターンの一種です。
特に真価を発揮するのは、大規模チームで沢山の機能を並行して書き進めるときです。
クラス設計者が機能のインタフェースを書いてしまえば、
- 実装者はインタフェースを元に機能実装を進める
- テスターはインタフェースを元にユニットテストを書き進める
- その機能を利用して別の機能を実装する人は、インタフェースを使って書き進める
と、それぞれの作業を止めない形で並行してタスクをこなすことが出来ます。
AspCoreのDI
主にJava界隈で発達したDIの考え方は、その後ASP.Netを.Net Core対応する際に取り込まれました。
Microsoft公式ドキュメントでも、一章を割いて詳しく書かれています。
AspCoreで書いた自作のクラスは、基本的にこのDIを経由して使うことになります。
AspCoreでは、DIに登録するクラスを"サービス"と見立て、"サービス"を保持するServiceCollection
、"サービス"の生成/破棄を受け持つServiceProvider
という形で実装されています。
...うん。頭痛い。
実は既に使ってたりして
見慣れない人にとっては、概念を把握するのはとっても面倒なDIなんです、が。
実はAspCoreでは、プロジェクトを作った最初の時点で、既にそれを使っているんです。
一番最初のコミットの、Startup.csがこちら。
これは何も手を入れていない、テンプレートそのままの状態です。
注目すべきは、ConfigureServices
メソッドです。
// This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.Configure<CookiePolicyOptions>(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); }
引数として受け取ったservices
で、AddMvc
メソッドを実行しています。
これは、「Mvc
っていうサービスをServiceCollection
に追加しますよ」という意味合いです。
メソッドの名前がConfigureService
、そして先頭のコメント:
Use this method to add services to the container.
とあるように、サービスを登録するときはここに書くわけですね。
なおコメント中のthe container
は、ServiceCollection(=services)
を示します。
DIのお仕事を受け持つオブジェクトは「DIコンテナ」と表現されます。
コメント上の記載ではcontainer
と書いておいて、servicesはDIコンテナなんだよ、と注釈しているんですね。
サービスのライフサイクル
さて、DIのハードルを下げた(?)ところで。
ひとくちにサービスと言っても、いろんなお役目があると思います。
データベースの値を取ってきて加工したり、バックエンドで延々とバッチジョブを処理したりと、目的に応じて機能を書いていきます。
DIではコード上でクラスをインスタンシエイトしません。
DI上のサービスは、DIを通してインスタンスを受け取り、DIを通して破棄されます。
そこで、この機能がいつ生成され、いつ破棄するべきなのか、予めDIに教えておきます。
その機能の生成から破棄までの流れを、「サービスのライフサイクル」と表現します。
AspCoreのサービスライフサイクル
AspCoreでは、ライフサイクルの種類として3つが用意されています。
公式ドキュメントのここですね。
- Transient - 使い終わったら即破棄
- Scoped - 1回のhttpリクエリトが終わったら破棄
- Singleton - 一度生成されたら、破棄しないで保持し続ける
このライフサイクルに合わせて、実際の機能を作っていくことになります。
データベースの操作なんかは1.のTransientで使い捨て、常駐タスクは3.のSingletonで走らせる感じですかね。
2.のScopedは...うーん。あんまり良い使い方が思いつきませんでした...。
サービスを登録する
Mopidy.Finderでサービス関係を整理し終えたのは、だいぶ後半のコミットでした。
使い捨て系のDB操作用Storeクラスはすぐに整備したのですが、バックエンドの常駐タスクをよろしくない形で書いてしまったため、このコミットで書き直しています。
サービスを登録しているのは、前述のとおり、Startup.csです。
src/aspCore/Startup.cs:
public class Startup { // -- 中略 -- // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.Configure<CookiePolicyOptions>(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); services.AddDbContext<Dbc>(options => { // フルパス指定が出来ない?要検証。 //options.UseSqlite($"Data Source=\"{Program.DbPath}\""); options.UseSqlite($"Data Source=database.db"); // MySQL接続のとき //options.UseMySQL(this.Configuration.GetConnectionString("DbConnectionMySql")); }, ServiceLifetime.Transient, // 呼び出し都度インスタンシエイトする。 ServiceLifetime.Transient ); services .AddMvc() .SetCompatibilityVersion(CompatibilityVersion.Version_2_1) .AddJsonOptions(options => { // JSON生成時、キャメル先頭を大文字で返す。 options.SerializerSettings.ContractResolver // アッパーキャメルの場合 // = new DefaultContractResolver(); // ロウアーキャメルの場合 //= new CamelCasePropertyNamesContractResolver(); = new DefaultContractResolver(); // 無限ループ検出時の動作。 // シリアライズエラー時、デフォルトでは途中状態の文字列を返してしまう。 options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore; }); services .AddTransient<AlbumStore, AlbumStore>() .AddTransient<GenreStore, GenreStore>() .AddTransient<ArtistStore, ArtistStore>() .AddTransient<ArtistAlbumStore, ArtistAlbumStore>() .AddTransient<GenreAlbumStore, GenreAlbumStore>() .AddTransient<GenreArtistStore, GenreArtistStore>() .AddTransient<AlbumTracksStore, AlbumTracksStore>() .AddTransient<TrackStore, TrackStore>() .AddTransient<SettingsStore, SettingsStore>() .AddTransient<JobStore, JobStore>() .AddSingleton<Query, Query>() .AddSingleton<Playback, Playback>() .AddSingleton<Library, Library>() .AddSingleton<Tracklist, Tracklist>() .AddSingleton<DbMaintainer, DbMaintainer>(); } // -- 中略 -- }
ConfigureServices
メソッドの末尾に、大量のAddTransient
、AddSingleton
がありますね。
AddTransient
で追加しているのは、データベースのテーブルを操作するクラスです。
対して、staticで構わないもの、またstaticでいて欲しいものをAddSingleton
で追加しています。
前々回の記事で上げた際の記述:
そして、忘れがちな最後のひと手間、AlbumStoreクラスをDIコンテナに登録します。 aspCore/Startup.csのConfigureServicesメソッド末尾に、下記を追記します。
ずいぶんさらっと流してますが、これが今回記事のキモです。
今回記事の導入では、インタフェースを定義した上で実装を作る、と書きました。
本来のDI機能を使う上では、そうすべきです。
例えば、AlbumStoreを登録する際は予めIAlbumStoreインタフェースを定義した上で
services .AddTransient<IAlbumStore, AlbumStore>();
のように、インタフェースに対する実装クラスを割り当てるのが正しいDIの使い方です。
今回は、個人的なプロジェクトなので、サボっています!
都度生成サービスを受け取る
DI上のサービスを使う際は、メソッド引数に[FromServices]
という属性を書きます。
「DIが持ってるサービスなんだよ」ということを教えてあげるんですね。
例えば、コントローラではこのように。
src/aspCore/Controllers/ArtistController.cs
[Produces("application/json")] [Route("Artist")] public class ArtistController : Controller { [HttpGet("GetPagenatedList")] public XhrResponse GetPagenatedList( [FromQuery] int[] GenreIds, [FromQuery] string FilterText, [FromQuery] int? Page, [FromServices] ArtistStore store // <- ここでインスタンスを受け取る ) { var args = new ArtistStore.PagenagedQueryArgs() { GenreIds = GenreIds, FilterText = FilterText, Page = Page }; var result = store.GetPagenatedList(args); return XhrResponseFactory.CreateSucceeded(result); } }
GetPagenatedList
メソッドの引数で、ArtistStore
のインスタンスを貰っています。
そのArtistStore
の実装の中でも、別のサービスを受け取っています。
src/aspCore/Models/Artists/ArtistStore.cs:
public class ArtistStore : PagenagedStoreBase<Artist>, IMopidyScannable { // -- 中略 -- private Library _library; public ArtistStore( [FromServices] Dbc dbc, // <- Dbcインスタンスを受け取る [FromServices] Library library // <- Libraryインスタンスを受け取る ) : base(dbc) { this._library = library; } public PagenatedResult GetPagenatedList(PagenagedQueryArgs args) { var query = this.Dbc.GetArtistQuery(); if (args.GenreIds != null && 0 < args.GenreIds.Length) query = query .Where(e => e.GenreArtists.Any(e2 => args.GenreIds.Contains(e2.GenreId))); if (!string.IsNullOrEmpty(args.FilterText)) query = query .Where(e => e.LowerName.Contains(args.FilterText.ToLower())); var totalLength = query.Count(); query = query.OrderBy(e => e.LowerName); if (args.Page != null) { query = query .Skip(((int)args.Page - 1) * this.PageLength) .Take(this.PageLength); } var array = query.ToArray(); var result = new PagenatedResult() { TotalLength = totalLength, ResultLength = array.Length, ResultPage = args.Page, ResultList = array }; return result; } // -- 中略 -- }
コンストラクタで、Dbc
とLibrary
のインスタンスを貰ってますね。
このように、連鎖的にDI上のサービスを貰い受けながら実装を書いていきます。
これで、インスタンスが破棄されず宙ぶらりんになることを防ぎながら、実装を進めることが出来ます。
常駐サービスを受け取る
バックエンドで常駐しておいて欲しいサービスは、Startup.csで書きます。
リリース時点のStartup.csでの例がこちらです。
public class Startup { // -- 中略 -- public void ConfigureServices(IServiceCollection services) { // -- 中略 -- services .AddTransient<AlbumStore, AlbumStore>() .AddTransient<GenreStore, GenreStore>() .AddTransient<ArtistStore, ArtistStore>() .AddTransient<ArtistAlbumStore, ArtistAlbumStore>() .AddTransient<GenreAlbumStore, GenreAlbumStore>() .AddTransient<GenreArtistStore, GenreArtistStore>() .AddTransient<AlbumTracksStore, AlbumTracksStore>() .AddTransient<TrackStore, TrackStore>() .AddTransient<SettingsStore, SettingsStore>() .AddTransient<JobStore, JobStore>() .AddTransient<DbEnsurer, DbEnsurer>() .AddSingleton<Query, Query>() .AddSingleton<Playback, Playback>() .AddSingleton<Library, Library>() .AddSingleton<Tracklist, Tracklist>() .AddSingleton<DbMaintainer, DbMaintainer>(); } private DbMaintainer _dbMaintainer; // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure( IApplicationBuilder app, IApplicationLifetime applicationLifetime, IHostingEnvironment env ) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) using (var dbEnsurer = serviceScope.ServiceProvider.GetService<DbEnsurer>()) { dbEnsurer.Ensure(); this._dbMaintainer = serviceScope.ServiceProvider.GetService<DbMaintainer>(); } // アプリケーション起動/終了をハンドルする。 // https://stackoverflow.com/questions/41675577/where-can-i-log-an-asp-net-core-apps-start-stop-error-events applicationLifetime.ApplicationStarted.Register(this.OnStarted); applicationLifetime.ApplicationStopping.Register(this.OnShutdown); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseStaticFiles(); app.UseCookiePolicy(); app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); } private void OnStarted() { if (!this._dbMaintainer.IsAlbumScannerRunning) this._dbMaintainer.RunAlbumScanner(); } private void OnShutdown() { if (this._dbMaintainer.IsAlbumScannerRunning || this._dbMaintainer.IsDbUpdateRunning) { this._dbMaintainer.StopAllTasks() .GetAwaiter() .GetResult(); } } }
Configure
メソッドの中で、IApplicationBuilder
を通してDbMaintainer
のインスタンスを取得、保持しておきます。
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { this._dbMaintainer = serviceScope.ServiceProvider.GetService<DbMaintainer>(); }
DbMaintainer
はSingletonで登録したので、一度生成してしまえば破棄されません。
そしてIApplicationLifetime
で、Asp.NetCoreの起動/終了をハンドルします。
applicationLifetime.ApplicationStarted.Register(this.OnStarted); applicationLifetime.ApplicationStopping.Register(this.OnShutdown);
それぞれハンドルしたメソッドで、DbMaintainer
の開始/終了を呼び出しています。
起動時に常駐し、終了時には常駐タスクを終わらせるような仕組みが出来ました。
以上、AspCoreのDIのおはなし、でした!
音楽サーバ"Mopidy"のフロントエンドを作る:04 EF-Coreでリレーションとインデックスを作る
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第4回です。
今回は、Entity Framework Coreでのリレーション、インデックス作りです。
Entity Framework Coreのテーブル定義方法
EF-Coreには、テーブルを定義する方法が二つあります。
- Entityクラス上でアノテーション(注釈構文)を書く。(=データアノテーション)
- DbContextクラス上でモデル生成をハンドルし、定義メソッドを書く。(=Fluent API)
1.のアノテーション構文は見た目にも簡単で、キー定義、カラムのnull許可設定、外部キー定義などが単純な場合はこれだけで完結します。
しかし、キーが複数あったり、インデックスを作りたい場合などは、まだアノテーションで書くことが出来ず、2.のFluent APIを使う必要が出てきます。
今回は二つを併用して、アルバム/アーティスト/ジャンルのリレーションを作り込んでいく過程を追って行きたいと思います。
アルバム/アーティスト/ジャンルの関係は?
ここで一旦、アルバム/アーティスト/ジャンルの3要素の関係を整理します。
ジャンルの中に、複数のアーティスト/アルバムがあるか?
→どちらも、当然ながら、ありますよね。
アーティストの中に、複数のジャンル/アルバムがあるか?
→ジャンルは、稀にアルバムごとでジャンルが変わるアーティストがいますね。
→多くのアーティストはアルバムを複数作るので、アルバムは当然あります。
アルバムの中に、複数のジャンル/アーティストがあるか?
→ジャンルは、コピレーション盤の場合は十分あり得ますね。
→アーティストは同じようにコンピレーション盤やトリビュート盤であり得ます。
どうやら、この3つは全て1対多の関係のようです。
テーブルの持ち方は?
ジャンル/アーティスト/アルバムの3つの要素は、それぞれテーブルを作ります。
それらには一意のIDを持たせます。
- ジャンル:genres
- アーティスト:artists
- アルバム:albums
そして、それらの関係性を示すためのリレーション用テーブルとして、以下の3つを作ることにしました。
このテーブルには、2つのIDだけを持つことにします。
- ジャンル-アーティストの所属関係:genre_artists
-> ジャンルIDとアーティストIDを持つ - ジャンル-アルバムの所属関係:genre_albums
-> ジャンルIDとアルバムIDを持つ - アーティスト-アルバムの所属関係:artist_albums
-> アーティストIDとアルバムIDを持つ
...うん、めんどくさい!
めんどくさい、けど!
他に、いい方法もないし...。
ということで、これをEF-CoreのEntityコードに落と込んでいきましょう。
ジャンル/アーティスト/アルバムのEntityを書く
この3者のリレーションが出来上がったコミットは、このあたりです。
まずジャンルEntityを、以下のように。
aspCore/Models/Genres/Genre.cs:
[Table("genres")] [JsonObject(MemberSerialization.OptIn)] public class Genre { [Key] [JsonProperty("Id")] public int Id { get; set; } [Required] [JsonProperty("Name")] public string Name { get; set; } [Required] [JsonProperty("LowerName")] public string LowerName { get; set; } [Required] [JsonProperty("Uri")] public string Uri { get; set; } [JsonProperty("GenreArtists")] public List<GenreArtist> GenreArtists { get; set; } [JsonProperty("GenreAlbums")] public List<GenreAlbum> GenreAlbums { get; set; } }
Entityクラス定義に[Table("genres")]
アノテーションを入れ、テーブルの名称を指定しています。
Id
カラムにある[Key]
アノテーションは、主キーを指定するものです。
また、ジャンルの名前と、Mopidy上のジャンルを示すURIは、前回までのRef型
で必ず取得できるため、[Required]
アノテーションを指定して必須入力とします。
LowerName
カラムは、文字列による絞り込み時に比較しやすいように、英数字を全て小文字化したものをセットする予定です。
そして、末尾に2つのList定義があります。
これは後述する、リレーション用Entityの配列です。
ジャンルに対するアーティスト/アルバムともに1対多のため、どちらもListとします。
なお、[JsonObject(MemberSerialization.OptIn)]
や[JsonProperty("変数名")]
のアノテーションは、EF-Coreとは無関係のものです。
JSON.Netでインスタンスをシリアライズする際の方法と変数名を指定しています。
次に、アーティストEntityの定義がこちら。
aspCore/Models/Artists/Artist.cs:
[Table("artists")] [JsonObject(MemberSerialization.OptIn)] public class Artist { [Key] [JsonProperty("Id")] public int Id { get; set; } [Required] [JsonProperty("Name")] public string Name { get; set; } [Required] [JsonProperty("LowerName")] public string LowerName { get; set; } [Required] [JsonProperty("Uri")] public string Uri { get; set; } [JsonProperty("ImageUri")] public string ImageUrl { get; set; } [JsonProperty("ArtistAlbums")] public List<ArtistAlbum> ArtistAlbums { get; set; } [JsonProperty("GenreArtists")] public List<GenreArtist> GenreArtists { get; set; } }
アノテーション、リストの意味合いはジャンルEntityと同様です。
Listが二つあることも同じく、アーティストに対するジャンル/アルバムのリレーションEntityが複数存在することを示しています。
アルバムEntityの定義は、前回のものからLowerNameカラム、リレーション用Listが加わりました。
aspCore/Models/Albums/Album.cs:
[Table("albums")] [JsonObject(MemberSerialization.OptIn)] public class Album { [Key] [JsonProperty("Id")] public int Id { get; set; } [Required] [JsonProperty("Name")] public string Name { get; set; } [Required] [JsonProperty("LowerName")] public string LowerName { get; set; } [Required] [JsonProperty("Uri")] public string Uri { get; set; } [JsonProperty("Year")] public int? Year { get; set; } [JsonProperty("ImageUri")] public string ImageUri { get; set; } [JsonProperty("ArtistAlbums")] public List<ArtistAlbum> ArtistAlbums { get; set; } [JsonProperty("GenreAlbums")] public List<GenreAlbum> GenreAlbums { get; set; } }
ここまではFluent APIの力を借りず、アノテーション定義だけで済んでいます。
リレーション用Entityを書く
さて次は、上の三者の関係を示すリレーションテーブルの定義です。
まずはジャンル-アーティスト間のリレーションEntity。
aspCore/Models/Relations/GenreArtist.cs:
[Table("genre_artists")] [JsonObject(MemberSerialization.OptIn)] public class GenreArtist { [Required] [JsonProperty] public int GenreId { get; set; } [Required] [JsonProperty] public int ArtistId { get; set; } [ForeignKey("GenreId")] public Genre Genre { get; set; } [ForeignKey("ArtistId")] public Artist Artist { get; set; } }
ここでは、主キーを示す[Key]
アノテーションを使いません。
正確には、使うことが出来ません。
なぜかというと、このテーブルではGenreId
とArtistId
の二つを主キーとしたいからです。
主キーを複数もつとき、[Key]
アノテーションを使うことが出来ないんですね。
そこで主キーは、後述するFluent APIで定義するようにします。
※なお、EF-Coreでは主キーの無いテーブルを扱うことが出来ません。※
ということで、ここではGenreId
とArtistId
にひとまず[Required]
アノテーションだけを付けています。
また、新しいアノテーション[ForeignKey("変数名")]
が出てきますね。
これは、外部キーとして使う変数名はコレですよ、という指定です。
[ForeignKey("GenreId")] public Genre Genre { get; set; }
とすることで、外部キーにGenreId
を使ってGenreエンティティを結合しといてね、という指示が出来るわけです。
ジャンル-アルバムのリレーションEntity、アーティスト-アルバムのリレーションEntityも、中身は似たり寄ったりです。
aspCore/Models/Relations/GenreAlbum.cs:
[Table("genre_albums")] [JsonObject(MemberSerialization.OptIn)] public class GenreAlbum { [Required] [JsonProperty] public int GenreId { get; set; } [Required] [JsonProperty] public int AlbumId { get; set; } [ForeignKey("GenreId")] public Genre Genre { get; set; } [ForeignKey("AlbumId")] public Album Album { get; set; } }
aspCore/Models/Relations/ArtistAlbum.cs:
[Table("artist_albums")] [JsonObject(MemberSerialization.OptIn)] public class ArtistAlbum { [Required] [JsonProperty] public int ArtistId { get; set; } [Required] [JsonProperty] public int AlbumId { get; set; } [ForeignKey("ArtistId")] public Artist Artist { get; set; } [ForeignKey("AlbumId")] public Album Album { get; set; } }
Entityクラスの定義は、ひとまずここまでで終わります。
しかしまだ、リレーションEntityの主キー設定とインデックス付与を、Fluent APIで書くお仕事が残っています。
Fluent APIを書く
さて、テーブル作りまでの最後のもうひとつ。Fluent APIです。
Fluent APIは、DbContextを継承したクラス‘Dbc‘で書きます。
public class Dbc: DbContext { // --- 一部割愛 --- public DbSet<Album> Albums { get; set; } public DbSet<Genre> Genres { get; set; } public DbSet<Artist> Artists { get; set; } public DbSet<ArtistAlbum> ArtistAlbums { get; set; } public DbSet<GenreAlbum> GenreAlbums { get; set; } public DbSet<GenreArtist> GenreArtists { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Album>() .HasIndex(e => e.Uri); modelBuilder.Entity<Genre>() .HasIndex(e => e.Uri); modelBuilder.Entity<Artist>() .HasIndex(e => e.Uri); modelBuilder.Entity<ArtistAlbum>() .HasKey(e => new { e.ArtistId, e.AlbumId }); modelBuilder.Entity<GenreAlbum>() .HasKey(e => new { e.GenreId, e.AlbumId }); modelBuilder.Entity<GenreArtist>() .HasKey(e => new { e.GenreId, e.ArtistId }); } }
まず先頭部分、前回はDbSet<Album>
だけでしたが、
DbSet<Genres>
DbSet<Artists>
DbSet<ArtistAlbums>
DbSet<GenreAlbums>
DbSet<GenreArtists>
とテーブルの数だけ増えています。
EF-Coreに「コレはテーブルだかんな!」と教えるためのものですね。
そして肝心のFluent API。これは、OnModelCreating
メソッドをoverrideして、引数で受け取ったmodelBuilder
インスタンスを介して書きます。
Album
, Genre
, Artist
のEntityには、Uri
カラムにインデックスが要るかも、ということで
modelBuilder.Entity<Album>() .HasIndex(e => e.Uri);
のように、HasIndexメソッドを使います。
そしてリレーションEntityの二つの主キーは下記のように、HasKeyメソッドを使います。
modelBuilder.Entity<ArtistAlbum>()
.HasKey(e => new { e.ArtistId, e.AlbumId });
これで、テーブル生成のための準備が終わりです!
テーブルを生成する
いよいよ、テーブルの生成です!
と言っても、やりかたは前回と同じです。
プロジェクト初期はテーブル構造を試行錯誤しますので、以前作ったマイグレーションとデータベースファイルは一旦削除してしまいます。
そして、パッケージマネージャーコンソール
でAdd-Migration
します。
PM> add-migration CreateTables
前回と違って、albums
テーブルの後にartists
テーブルの生成コードが出来てます。
よしよし。
続いて、DBの更新です。
PM > update-database
正常に終わりました。
さて、テーブルは出来てますかね?
おーし、Entity定義した6つ分のテーブルが出来てますね!
なお_EFMigrationsHistory
テーブルは、EF-Coreがマイグレーション履歴を管理するために生成するテーブルです。
これでデータさえ入れば、LINQ to SQLのクエリでリレーションを追って関連Entityが取得できるはず。
var withArtists = Dbc.Genres
.Include(e => e.GenreArtists)
.ThenInclude(e2 => e2.Artist)
.Where(e => e.Id == 1)
.ToArray();
こんな感じの構文ですかね?
いよいよ、第一の目的に近づいてきました!
音楽サーバ"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操作の土台が出来ました!
音楽サーバ"Mopidy"のフロントエンドを作る:02 JSON-RPCのプロキシを作る
音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第2回です。
今回は、Mopidyとの通信部分の作り込みをなぞっていきます。
MopidyのAPI
Mopidyの公式ドキュメントを当たると、操作するにあたって幾つか方法があります。
- HTTP server side API - 多くのMopidy Extensionが使うはずの方法です。
- HTTP JSON-RPC API - MopidyのhttpサーバとJSON-RPCでやり取りする方法です。
- WebSocket API - WebSocketを使ってJSON-RPCをやり取りする方法です。
- Mopidy.js JavaScript library - Javascriptで書かれたライブラリを使う方法です。
このうち、1.は実装にPythonを使うためドロップします。
2.はシンプルなJSONのやり取りで簡単そう。
3.は一度試したのですが、Mopidy側でクロスドメインの接続を受け付けて貰える方法が見つからず断念。
4.は既に実装済みのライブラリを使えて楽そうです。
2.と4.で迷ったのですが、常にフロントエンドを介して通信する必要があるのは実装が制限されて困るな、ということで今回は2.のJSON-RPCを使った実装で進めました。
それにしても、このドキュメントは凄い詳しく書いて貰ってますね。
OSSの鑑のようなドキュメントです。ありがたや!
JSON-RPCとは?
JSON-RPCは、特定フォーマットに整形したJSONを使い、httpはpostのみと実にイージーなプロトコルです。
ここに定義ドキュメントがありますが、正直読み込む必要もあまりないかもしれません。
"jsonrpc" の値にプロトコルバージョン番号を載せ、"id", "method", "params" などをくるめたオブジェクトを作ります。
{ "method": "core.playback.get_state", "jsonrpc": "2.0", "id": 1 }
これを、MopidyのAPI用URL(http:/[Mopidyのアドレス]:6680/mopidy/rpc)にpostすると
{ "result": "paused", "jsonrpc": "2.0", "id": 1 }
こんなものが返ってきます。
"id" を一意にすることで、どのクエリに対する応答なのかが分かるような仕組みです。
エラーが起こった場合は、"result" の代わりに "error" が入ります。
AspCoreでPostする
AspCore経由でJSON-RPCをやりとりする機能は、このあたりのコミットで試しています。
まずはフロントエンドから受け取るオブジェクト、Mopidyと送受信するオブジェクトの型を書いていきます。
aspCore/Models/Entities/JsonRpcEntities.cs:
[JsonObject] public abstract class JsonRpcBase { [JsonProperty("jsonrpc")] public string jsonrpc = "2.0"; } [JsonObject] public abstract class JsonRpcWithIdBase : JsonRpcBase { [JsonProperty("id")] public int id; public JsonRpcWithIdBase(int id) { this.id = id; } } public class JsonRpcFullParams : JsonRpcBase { public int? id; public string method; public object @params; public object result; public object error; } [JsonObject] public class JsonRpcRequest: JsonRpcWithIdBase { [JsonProperty("method")] public string method; public JsonRpcRequest(int id, string method): base(id) { this.method = method; } } [JsonObject] public class JsonRpcRequestWithParams : JsonRpcRequest { [JsonProperty("params")] public object @params; public JsonRpcRequestWithParams(int id, string method, object @params): base(id, method) { this.@params = @params; } } [JsonObject] public class JsonRpcNotice: JsonRpcBase { [JsonProperty("method")] public string method; public JsonRpcNotice(string method): base() { this.method = method; } } [JsonObject] public class JsonRpcNoticeWithParams : JsonRpcNotice { [JsonProperty("params")] public object @params; public JsonRpcNoticeWithParams(string method, object @params): base(method) { this.@params = @params; } } [JsonObject] public class JsonRpcSucceededResult : JsonRpcWithIdBase { [JsonProperty("result")] public object result; public JsonRpcSucceededResult(int id, object result): base(id) { this.result = result; } } [JsonObject] public class JsonRpcErrorResult : JsonRpcWithIdBase { [JsonProperty("error")] public object error; public JsonRpcErrorResult(int id, object error): base(id) { this.error = error; } }
...はぁ。既に疲れますね...。
動的型付け言語で書く場合は、こんな面倒なこと、ありませんもんね。
- 送信パラメータに"id"が無い場合は単なる通知と見なし、応答がnullになる
- メソッドに対する引数が無い場合は、"params"が無い
など、幾つかのパターンに対応するため、こまごまと型定義しています。
ときどき変数名の先頭に"@"が付いているのは、C#の予約語を回避するためのものです。
また、"[JsonObject]", "[JsonProperty("変数名")]"などのアノテーションが付いているのは、JSON.Netがシリアライズするときのガイド用の構文です。
特に何も付けなくてもよしなにやってくれるのですが、ときどき意図しない形に変数名を整形したりしちゃうので、JSON.Netを使うときは全部書くようにしています。
そして、フロントエンドからクエリを受け取るコントローラがこちら。
aspCore/Controllers/JsonRpcController.cs:
public class JsonRpcController : Controller { // GET: /<controller>/ [HttpPost()] [Produces("application/json")] public async Task<string> Index([FromBody] JsonRpcFullParams values) { // APIクエリ用パラメータセットを宣言する。 JsonRpcBase sendValues; // パラメータ有無判定 var hasParams = (values.@params != null); // id有無(=リクエストor通知)を判定 var hasId = (values.id != null); if (hasId) { // id付き=リクエスト sendValues = (hasParams) ? new JsonRpcRequestWithParams((int)values.id, values.method, values.@params) : new JsonRpcRequest((int)values.id, values.method); } else { // id無し=通知 sendValues = (hasParams) ? new JsonRpcNoticeWithParams(values.method, values.@params) : new JsonRpcNotice(values.method); } var url = "http://192.168.254.251:6680/mopidy/rpc"; HttpResponseMessage response; 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(sendValues); var content = new StringContent(sendJson, Encoding.UTF8, "application/json"); content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); response = await client.PostAsync(url, content); } catch (Exception ex) { var error = new JsonRpcErrorResult( (hasId ? (int)values.id : -1), $"Network Error: {ex.Message}" ); return JsonConvert.SerializeObject(error); } // クエリ後 if (!hasId) { // 通知の場合 // レスポンスには何も含まない。 return null; } else { // リクエストの場合 // 戻り値JSONをそのまま返す。 var resultJson = await response.Content.ReadAsStringAsync(); return resultJson; } } }
- 受け取ったJSONを一旦、パラメータ全部入りの”JsonRpcFullParams"型で受け取り、
- それが"id"付きか、"params"付きかを判定してそれぞれの型に整形し、
- 整形後のパラメータをサーバにpostしています。
戻り値はエラーでもない限り、まるっとJSONにしてフロントエンドに返します。
まだまだテスト実装なので、MopidyサーバのURIなんかベタ書きですね。
※ なおこのコードには、Linuxでは動かなくなる不具合が含まれています。※
正しくはこちらのリリース時の実装のように、HttpClientをusingでラップし、必ず破棄されるようにしてください。
TypeScriptでAPIを使ってみる
今度はフロントエンド側で、実際にMopidyのAPIを呼び出してみます。
基底クラスとしてStoreBase.tsを作り、APIコール部を書きました。
HttpクエリにはAxiosを使っています。
src/ts/Models/Stores/StoreBase.ts:
export default class StoreBase<T> { private static XhrInstance: AxiosInstance = Axios.create({ //// APIの基底URLが存在するとき baseURL: 'http://localhost:8080/JsonRpc/', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, responseType: 'json' }); private static IdCounter: number = 1; private Call(request: JsonRpcRequest): Promise<JsonRpcResult> { return new Promise<JsonRpcResult>(async (resolve: (value: JsonRpcResult) => void) => { request.jsonrpc = '2.0'; try { const result = await StoreBase.XhrInstance.post(null, request); resolve(result.data as JsonRpcResult); } catch (ex) { const error = { id: request.id, error: `Network Error: ${ex}` } as JsonRpcResult; resolve(error); } }); }; private GetRequest(method: string, params: any = null): JsonRpcRequest { const request = { method: method } as JsonRpcRequest; if (params) request.params = params; return request; } protected Query(method: string, params: any = null): Promise<JsonRpcResult> { const request = this.GetRequest(method, params); request.id = StoreBase.IdCounter; StoreBase.IdCounter++; return this.Call(request); } protected Notice(method: string, params: any = null): void { const request = this.GetRequest(method, params); this.Call(request); } }
なお、Callメソッドでtry-catchしていますが、これは無意味です。
非同期メソッドで例外を受け取るには、下記のように".catch"メソッドをチェインする必要があります。
const result = await StoreBase.XhrInstance.post(null, request) .catch(e => { // エラー処理 });
なんとも適当なコードを書いたもんです。
いや。自分専用プロジェクトだと、コードなんてこんなもんです。きっと。
そして、実際にAPIを呼んでいるのがこちら。
src/ts/Models/Stores/SongStore.ts:
export default class SongStore extends StoreBase<Song> { private static ApiMethodSearch: string = 'core.library.search'; // : IEnumerable<Song> public async GetAll() { const songs: Song[] = []; const result = await this.Query(SongStore.ApiMethodSearch); console.log('Query Result:'); console.log(result); return result; } }
もはやtry-catchすらありませんが、そんなの関係ねえのです。
とりあえず目に付いた適当なメソッドを乗せて、JSON-RPC APIが動くかテストです。
おお!なんかエラーじゃないっぽいJSON来てるやん!
Visual Studioでブレイクしてみると、応答の中に"tracks"なる配列が入っていることが分かります。
ひとまず、つかみはOK、といったところです!