Try .NET Core

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

音楽サーバ"Mopidy"のフロントエンドを作る:07 無限ローディングを組み込む

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第7回です。

今回は、Vueのプラグインを使った無限ローディングの実装を追っていきます。

Vue-infinite-loadingなるもの

まあ、誰か作ってるんちゃうの?的にぐぐったところ。
f:id:try_dot_net_core:20190808161330p:plain

名前もそのまんま、Vue-infinite-loadingなるライブラリが散見されますね。
これが定番なのかな?

実装例の記事も、とてもシンプルでした。
pdo99.hatenablog.com
www.kabanoki.net

公式ページはこちら。
英字と中文、とあるので、中国の方でしょうか。
Get Startedのエフェクトかっこいい!
peachscript.github.io

ということで、これを導入してみることにしました!

インストール

公式ページにあるように、npmから貰って来ます。

# npm install vue-infinite-loading

TypeScript用型定義は、パッケージに含まれていました。
いやあ、最近は便利でいい!
f:id:try_dot_net_core:20190808163404p:plain

書いてみる

その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コンポーネントが「見えるようになった」時点で実行されます。

読み込みの流れとしては、下記のようなものです。

  1. ArtistStoreから新しいArtist配列を受け取る
  2. Artist配列を結合
  3. 読み込み完了判定、続きがある場合は$stateloadedメソッドを、完了した場合は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;
    }
    // -- 中略 ---
}

ああ、やっと実装らしい実装にたどり着きました!
中身の流れとしては、以下のようなものです。

  1. ジャンルIDの配列がある場合は、ジャンルIDでアーティストを絞り込んでおく。
  2. 合計のArtist数をとっておく。
  3. アーティストを名前順に並べる。
  4. 渡し値ページ番号に該当するアーティスト配列を取ってくる。
  5. 戻り値に整形して返す。

以前の記事に挙がっていた、EF-Coreで作ったデータベースから値を取ってきています。
だんだんと繋がりが出来てきました!

UIの動きをためしてみる

このコミットを実行してみると、こんな画面が出てきます。
f:id:try_dot_net_core:20190808180445p:plain うへえ。Bootstrap臭ぇ...。
いやいや。そのうち直すんです。

そして、Artistsのリストをスクロールしていくと...
f:id:try_dot_net_core:20190808180529p:plain

(おわかり頂けただろうか...)
でん、と残りスクロール量が増えます。
f:id:try_dot_net_core:20190808180614p:plain
EF-CoreもVueの描画も結構早いので、一瞬のうちにアーティストが増えてます。
もうちょっとタメがあった方が、ありがたみがあるってもんですが。

そしてAspCoreの出力ではこんな感じで、SELECT文が実行されていることがわかります。
f:id:try_dot_net_core:20190808181240p:plain

以上、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-componentvue-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は空オブジェクト
  • $elundefined

な状態です。

$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でブレイクしてみると。
f:id:try_dot_net_core:20190807133513p:plain
基底クラス側の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月現在の今も、同じですね。
f:id:try_dot_net_core:20190807170328p:plain

どうやら、バージョン2系と3系があるようです。
違いは何なのか、調べてみると。

こんな記事が見つかりました。
minory.org

ほうほう、Bootstrapのバージョンが4になった、と。
Bootstrap4は、私はまだ触ったことがありませんでした。
調べてみると、従来のBootstrap3までとは大きく変わったようです。

AdminLTEはよく知りませんが、Bootstrapは今までも色んな案件でお世話になりました。
その新しいバージョンは、先だって試しておいて損はないでしょう。

ということで、今回はAdminLTEのバージョン3系を導入することにしました。

AdminLTEの導入

npmでadmin-lteをsearchすると、現在のバージョンが2.4だ、と出てきます。

# npm search admin-lte

f:id:try_dot_net_core:20190807171953p:plain

バージョン3系はまだプレリリース版なんですね。
どんなバージョンがあるのか、調べてみます。

# npm info admin-lte versions

f:id:try_dot_net_core:20190807172446p:plain
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

どんなブツが入ったか、というと。
f:id:try_dot_net_core:20190807173539p:plain
f:id:try_dot_net_core:20190807173552p:plain
f:id:try_dot_net_core:20190807173605p:plain

ほうほう。imgフォルダはデモ用の画像っぽいので、スルーしてよさげ。
dist/css/adminlte.cssと、dist/js/adminlte.jsを読んでおけばよい感じです。

AdminLTEの導入で間違える

VueとAdminLTEを試していたのが、この頃のコミットです。
ここで私は、愚かな間違いを犯します。

AdminLTEのプラグインフォルダを見ると。
f:id:try_dot_net_core:20190807175331p:plain
bootstrapを始め、有名な実装がたんと詰まっています。

中でも Ion.RangeSlider は、見た目もかっこいいし、音楽プレイヤーのボリューム調整パーツに使えそうです。
そしてそれは、jQueryプラグインらしい、と。

そこで、私はjQueryを単体でインストールしました。
この時点での、package.jsondependenciesはこちら。

"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');

f:id:try_dot_net_core:20190807193022p:plain
うおー。めっちゃ縮小されとる!
しかしどうやら、ジャンルの一覧が名前順に出てきてるようです。

UIの第一歩としては、上々です!

音楽サーバ"Mopidy"のフロントエンドを作る:小休止 - Visual Studioの環境づくり

"Mopidy"フロントエンド「Mopidy.Finder」が出来るまで、今回は小休止です。

近年のWeb系のみなさんには不評この上ないWindows+IDEですが、セットアップさえしてしまえば、強力な助っ人になってくれます。

WindowsVisual Studioの開発環境を整えるまでの手順を、少しずつUIを追いながら、書き綴ってみたいと思ます。

Visual Studioのインストール

まずはVisual Studio。現行最新はVisual Studio 2019です。
特にCommunity Editionは、小規模事業者や学生さん向けに無償で配布されています。
無償とはいえ、機能はほぼProfessional Editionと変わりありません。
f:id:try_dot_net_core:20190806174248p:plain
この「無償ダウンロード」リンクから、インストーラがダウンロードできます。

f:id:try_dot_net_core:20190806174453p:plain

このインストーラを起動すると、Visual Studioの機能を選択する画面が出てきます。
私の場合は、過去案件の保守の関係もあり、下記の4つをいつも入れています。

f:id:try_dot_net_core:20190806174611p:plain
f:id:try_dot_net_core:20190806174941p:plain

機能を選んで「インストール」ボタンを押すと、インストールが始まります。
f:id:try_dot_net_core:20190806175216p:plain

インストールが終わると、Visual Studioが起動します。
あらかじめ作っておいた、Microsoftアカウントを使ってサインインします。
f:id:try_dot_net_core:20190806175443p:plain

こちらが起動直後の、プレーンなVisual Studio 2019の画面です。 f:id:try_dot_net_core:20190806175558p:plain

ためしに一度、ASP.Net Coreプロジェクトを作ってみます。
「新しいプロジェクトの作成」をクリックすると、プロジェクトテンプレートの一覧が出てきます。
f:id:try_dot_net_core:20190806175642p:plain

ASP.Net Coreプロジェクトを選んで進めると、こんな画面。
f:id:try_dot_net_core:20190806175942p:plain

「作成」ボタンを押すと、さらに詳細なテンプレート選択が出てきます。
f:id:try_dot_net_core:20190806180043p:plain
おっと、ASP.Net Coreのバージョン 2.2が選べませんね。
これは後からインストールする必要があるようです。
一旦このまま、プロジェクトを作ってみます。

そして、作り立てのプロジェクトの最初の画面。
f:id:try_dot_net_core:20190806180339p:plain

最初はファイル一覧などが右側に配置されてます。
私は左側に置いておきたいので、移動させます。
f:id:try_dot_net_core:20190806180522p:plain

プロパティウインドウはデスクトップアプリしか使わないため、左帯に追いやりました。
f:id:try_dot_net_core:20190806180957p:plain

Extensionのインストール

Visual Studioでは拡張機能が色々用意されています。
このままでも十分使い物になるのですが、そこはお好みで。
Extensionのインストールは、メニュー「拡張機能」からです。
f:id:try_dot_net_core:20190806181323p:plain

「Manage Extensions」画面の左ペインで、オンラインを開きます。
まずは定番、Microsoftさん御謹製のProductivity Power Toolsです。
f:id:try_dot_net_core:20190806181551p:plain

そして、改行コードを保存時に整形してくれる「Line Endings Unifier」 f:id:try_dot_net_core:20190806181638p:plain

Markdownの編集機能も便利です。 f:id:try_dot_net_core:20190806181713p:plain

ひととおり欲しいものを「Download」ボタンで追加しておき、画面を閉じます。
そして、Visual Studio自体も一回閉じてしまいます。
すると、Extensionのインストーラが走ります。
しばらく待つと、インストール対象の確認ダイアログが出ます。
f:id:try_dot_net_core:20190806182053p:plain

そしてVisual Studioを起動すると、Extensionがインストールされた状態で開かれます。
いらないExtensionを無効化する場合は、同様にメニュー「拡張機能」から「拡張機能の管理」を開きます。

Productivity Power Toolsは沢山のExtensionのセットパッケージです。
私の場合は「Shrink Empty Lines」があまり好みではないため、無効化しています。 f:id:try_dot_net_core:20190806182500p:plain

.Net Core SDKの追加インストール

前述でプロジェクトを作ったとき、ASP.Net Core 2.2が選択できませんでした。
これは、.Net Core SDK 2.2がまだ入ってないためです。
追加でインストールしましょう。
f:id:try_dot_net_core:20190806182740p:plain
え...っと、どれだ?と迷いました。
Visual Studio用のインストールなので、左側三番目かなぁ?

選んでみると、注釈が出ていました。 f:id:try_dot_net_core:20190806182923p:plain
ほうほう、2019で使うならそのリンクなんですね。ぽちっとな。

f:id:try_dot_net_core:20190806183016p:plain
なんだか色々出てきました。
この辺がMSさんの洗練されてないところいや、素朴なところと言っておきます(?)

環境はWindows10-x64なので、SDK2.2.401のx64インストーラを選びます。
インストーラがダウンロードされました。
f:id:try_dot_net_core:20190806183343p:plain

早速インストール。
f:id:try_dot_net_core:20190806183416p:plain

インストール終了後、Visual Studioを起動。
また新しくASP.Net Coreプロジェクトを作ると、リストにASP.NET Core 2.2が出てくるようになりました。
f:id:try_dot_net_core:20190806183557p:plain

node.jsのインストール

node.js開発機能は選択しませんでしたので、現状ではまだnode.jsがインストールされていません。
コマンドプロンプトをだして... f:id:try_dot_net_core:20190806183954p:plain

npmをお試し。 f:id:try_dot_net_core:20190806184029p:plain
はい、無いですね。

公式配布ページから、node.jsをもらって来ます。
f:id:try_dot_net_core:20190806184217p:plain
最新の12.7.0 Currentを選びました。

ダウンロードしたインストーラがこちら。 f:id:try_dot_net_core:20190806184314p:plain

インストーラを起動し、デフォルト状態で次へ次へと進みます。
f:id:try_dot_net_core:20190806184443p:plain

インストール後、コマンドプロンプトを起動して、お試し。
f:id:try_dot_net_core:20190806184532p:plain
よしよし、入ってますね。

Visual Studioのプロジェクトを開いて、npmが使えるか試してみます。
f:id:try_dot_net_core:20190806184710p:plain

どれどれ。 f:id:try_dot_net_core:20190806184745p:plain
f:id:try_dot_net_core:20190806184819p:plain
おー。npm init が通りました。

さてこの調子で、webpackやらmochaやらのインストールを進めていくと。
f:id:try_dot_net_core:20190806185057p:plain
f:id:try_dot_net_core:20190806185114p:plain
あら。ダメ出しされちゃいました。
メッセージを読むと、rejected by your operationg systemとな。
こりゃ権限エラーかな、ということで、WindowsUACを無効化してみます。

コントロールパネルを出して... f:id:try_dot_net_core:20190806185347p:plain

ユーザーアカウントを開いていくと、ありますね。
ユーザーアカウント制御設定の変更です。
f:id:try_dot_net_core:20190806185505p:plain

これを、最低設定にします。
f:id:try_dot_net_core:20190806185539p:plain

UACは再起動しないと適用されませんので、再起動かけます。
f:id:try_dot_net_core:20190806185629p:plain

さて、どうよ? f:id:try_dot_net_core:20190806185654p:plain
おー。無事にnpmパッケージがインストールされました。よしよし。

TSCの設定

さてここで、tsc(Typescriptコンパイラ)が走るかどうか、試してみます。
f:id:try_dot_net_core:20190806185846p:plain
おっと、ダメですね。
tscの存在が分からないらしい。

tscはどこにあるか、というと。
Visual Studioでインストールした場合、ここに入ります。
C:\Program Files (x86)\Microsoft SDKs\TypeScript\[version]ですね。 f:id:try_dot_net_core:20190806190057p:plain

ここにパスを通してみます。 f:id:try_dot_net_core:20190806190148p:plain
f:id:try_dot_net_core:20190806190211p:plain
f:id:try_dot_net_core:20190806190358p:plain
一番最後に、TypeScriptのインストールパスを追記しました。

さて、どうよ?
f:id:try_dot_net_core:20190806190449p:plain
ありゃ? tscを、Windows Script Hostが実行しちゃってますね。
これをnode.jsが実行するように、変更します。

C:\Program Files (x86)\Microsoft SDKs\TypeScript\3.5\tsc.jsのプロパティから。
f:id:try_dot_net_core:20190806190647p:plain

実行するプログラムを変更します。
f:id:try_dot_net_core:20190806190724p:plain
f:id:try_dot_net_core:20190806190814p:plain
最初はNode.js: Server-side Javascriptの選択肢がありません。
「このPCで別のアプリを探す」から、node.exeを探してセットします。
デフォルトインストールの場合、C:\Program Files\nodejs\node.exeとなるはずです。

実行プログラムをnodeに変更し、リトライです。
f:id:try_dot_net_core:20190806191258p:plain よしよし、ちゃんと動いてますね。

Chromeのセットアップ

Visual Studio 2017からは、標準でChromeデバッガへのアタッチが可能になりました。
これまではIEかEdgeでしか出来なかったJavascriptのステップデバッグが、Chromeで出来るようになったんですね。
これを使わない手はない!ということで、Chromeを入れます。

まずはWebから、インストーラを貰ってきてセットアップ。
f:id:try_dot_net_core:20190806191954p:plain

インストール後に出来るChromeのショートカットに、デバッガポートの設定を追記します。
f:id:try_dot_net_core:20190806192047p:plain
f:id:try_dot_net_core:20190806192109p:plain
chrome.exe の後ろに、--remote-debugging-port=9222と追記します。
これを一回起動しておけば、あとはChromeが覚えていてくれるようです。

動作環境のテスト

さて、ひととおりセットアップが終わったはずです。
ここでMopidy.Finderの初期コミットを貰ってきて、デバッガの動作を試します。

1つめはフォルダ構造がよろしくないため、2つ目のコミットをダウンロードします。
f:id:try_dot_net_core:20190806192434p:plain
f:id:try_dot_net_core:20190806192533p:plain

zipを解凍してプロジェクトを開きます。
まずはnpmパッケージをインストール。
f:id:try_dot_net_core:20190806192639p:plain
f:id:try_dot_net_core:20190806192716p:plain
ん、なんか警告出てるけど。まあ一応通りました。
この警告は、lodash脆弱性付きバージョンに対するものだったと思います。

テストは通るかな?
f:id:try_dot_net_core:20190806192906p:plain
よしよし、通ってますね。

ビルドも、問題なく通ります。 f:id:try_dot_net_core:20190806193001p:plain

Visual StudioでTypeScriptコードにブレイクポイントを置き、ブラウザをChromeに。
f:id:try_dot_net_core:20190806193152p:plain

これを実行してみると。
f:id:try_dot_net_core:20190806193218p:plain
Chromeが出てきて、「デバッガで止まってるよ」と表示が出ます。

Visual Studioに戻ってみます。 f:id:try_dot_net_core:20190806193319p:plain おーし!
ブレイクポイントで止まっています。
既に実行済みの、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つが用意されています。
公式ドキュメントのここですね。

  1. Transient - 使い終わったら即破棄
  2. Scoped - 1回のhttpリクエリトが終わったら破棄
  3. 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メソッドの末尾に、大量のAddTransientAddSingletonがありますね。

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;
    }
    // -- 中略 --
}

コンストラクタで、DbcLibraryインスタンスを貰ってますね。

このように、連鎖的にDI上のサービスを貰い受けながら実装を書いていきます。
これで、インスタンスが破棄されず宙ぶらりんになることを防ぎながら、実装を進めることが出来ます。

常駐サービスを受け取る

バックエンドで常駐しておいて欲しいサービスは、Startup.csで書きます。
リリース時点のStartup.csでの例がこちらです。

src/aspCore/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には、テーブルを定義する方法が二つあります。

  1. Entityクラス上でアノテーション(注釈構文)を書く。(=データアノテーション)
  2. 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]アノテーションを使いません。

正確には、使うことが出来ません。
なぜかというと、このテーブルではGenreIdArtistIdの二つを主キーとしたいからです。

主キーを複数もつとき、[Key]アノテーションを使うことが出来ないんですね。
そこで主キーは、後述するFluent APIで定義するようにします。
※なお、EF-Coreでは主キーの無いテーブルを扱うことが出来ません。※

ということで、ここではGenreIdArtistIdにひとまず[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‘で書きます。

aspCore/Models/Dbc.cs

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

f:id:try_dot_net_core:20190803144218j:plain 前回と違って、albumsテーブルの後にartistsテーブルの生成コードが出来てます。
よしよし。

続いて、DBの更新です。

PM > update-database

f:id:try_dot_net_core:20190803144418j:plain
正常に終わりました。

さて、テーブルは出来てますかね?
f:id:try_dot_net_core:20190803144606j:plain
おーし、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は元より、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操作の土台が出来ました!

音楽サーバ"Mopidy"のフロントエンドを作る:02 JSON-RPCのプロキシを作る

音楽サーバ"Mopidy"のフロントエンド「Mopidy.Finder」が出来るまで、第2回です。
今回は、Mopidyとの通信部分の作り込みをなぞっていきます。

MopidyのAPI

Mopidyの公式ドキュメントを当たると、操作するにあたって幾つか方法があります。

  1. HTTP server side API - 多くのMopidy Extensionが使うはずの方法です。
  2. HTTP JSON-RPC API - MopidyのhttpサーバとJSON-RPCでやり取りする方法です。
  3. WebSocket API - WebSocketを使ってJSON-RPCをやり取りする方法です。
  4. 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;
        }
    }
}
  1. 受け取ったJSONを一旦、パラメータ全部入りの”JsonRpcFullParams"型で受け取り、
  2. それが"id"付きか、"params"付きかを判定してそれぞれの型に整形し、
  3. 整形後のパラメータをサーバに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が動くかテストです。
f:id:try_dot_net_core:20190801194502p:plain
おお!なんかエラーじゃないっぽいJSON来てるやん!

Visual Studioでブレイクしてみると、応答の中に"tracks"なる配列が入っていることが分かります。
f:id:try_dot_net_core:20190801194647p:plain

ひとまず、つかみはOK、といったところです!