Try .NET Core

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

音楽サーバ"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、といったところです!