Chapter 04

メッセージの送受信について

takl
takl
2020.12.22に更新

とりあえず送受信

まず Language Server Protocol の実際のところを見てみましょう。とりあえず oreore.js を次のようにします。

oreore.js
const fs = require("fs");
const log = fs.openSync("/somewhere/log.txt", "w"); // ファイル名は適宜変えてください

function languageServer() {
    process.stdin.on("readable", () => {
        let chunk = process.stdin.read();
        fs.writeSync(log, chunk);
    });
}

if (process.argv.length !== 3) {
    console.log(`usage: ${process.argv[1]} [--language-server|FILE]`);
} else if (process.argv[2] == "--language-server") {
    languageServer();
} else {
    // TODO: interpret(process.argv[2]);
}

stdin から読んだデータを /somewhere/log.txt に書き込むだけのプログラムです。F5 で起動して拡張子が .ore のファイルを開くと /somewhere/log.txt ができます。中身は次のような感じになると思います。

/somewhere/log.txt
Content-Length: 4620

{"jsonrpc":"2.0","id":0,"method":"initialize","params":...

これが起動時に Language Client から Language Server に送られるメッセージです。"method":"initialize" で想像できるかもしれませんが、初期化メッセージです。これにいい感じの応答を返すと初期化ができます。

また、Language Server から Language Client へメッセージを送ることもできます。初期化前に送ることができる window/logMessage というメッセージがありますので、これを送ってみましょう。oreore.js を次のように書き換え、 oreore-mode を再起動します。

oreore.js
const fs = require("fs");

function languageServer() {
    process.stdin.on("readable", () => {
        const s = JSON.stringify({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 3, message: "Hello, World!" }});
        process.stdout.write(`Content-Length: ${s.length}\r\n\r\n${s}`);
    });
}

if (process.argv.length !== 3) {
    console.log(`usage: ${process.argv[1]} [--language-server|FILE]`);
} else if (process.argv[2] == "--language-server") {
    languageServer();
} else {
    // TODO: interpret(process.argv[2]);
}

OUTPUT のところに Hello, World! と表示されるはずです。

とりあえずまとめとしては

  • Language Server は stdin を使って Language Client からのメッセージを受け取ります
  • Language Server は stdout を使って Language Client にメッセージを送ります

という感じです[1]

JSON-RPC 2.0

もう少し細かく見ていきます。

Language Server Protocol は JSON-RPC 2.0 を使ったプロトコルです。JSON-RPC 2.0 の詳細はJSON-RPC 2.0 Specificationにあります。

ざっくり説明すると、 JSON-RPC 2.0 のメッセージには request, response, notification の3種類があり、「一方が request を投げたらもう一方は response を返す」「response は対応する request と同じ id を持つ」「一方通行のメッセージを投げたいときは notification を使う」となっています。それぞれの形式は次のようになっています。

request
{ "jsonrpc": "2.0", "id": ID, "method": METHOD, "params": PARAMS }
response
{ "jsonrpc": "2.0", "id": ID, "result": RESULT }
notification
{ "jsonrpc": "2.0", "method": METHOD, "params": PARAMS}

具体例はJSON-RPC の仕様書の Examplesを見るとよいでしょう。

Language Server Protocol のメッセージ

Language Server Protocol のメッセージですが、 JSON-RPC 2.0 のメッセージに HTTP 似のヘッダを付けた形になっています。
つまりヘッダは1行で1つのパラメタを表し、行末は \r\n で、空行がヘッダの終わりです。

ですので、ひとつのメッセージは次のような形になります。

Content-Length: XXXX
Content-Type: application/vscode-jsonrpc; charset=utf-8

{"jsonrpc":"2.0","id":"1",...

ヘッダは今のところ Content-Type と Content-Length の二つが定義されています。

メッセージを送る

Content-Type は optional なので、メッセージを送る側は

Content-Length: XXXX

{"jsonrpc":"2.0","id":"1",...

で良いです。JSON部分は UTF-8 でなければなりません。XXXX の部分は JSON 部分のバイト数です。

というわけで次のような関数を作っておくといいと思います。

oreore.js
function sendMessage(msg) {
    const s = new TextEncoder("utf-8").encode(JSON.stringify(msg));
    process.stdout.write(`Content-Length: ${s.length}\r\n\r\n`);
    process.stdout.write(s);
}

前述の window/logMessage を送る関数は次のように書けます。何かと有用なので作っておくといいと思います。

oreore.js
function logMessage(message) {
    sendMessage({ jsonrpc: "2.0", method: "window/logMessage", params: { type: 3, message } });
}

メッセージを受け取る

メッセージを受け取る方は少々面倒です。

基本的には「ヘッダを parse する(空行までを行単位で読み込む)」「Content-Length ヘッダを探し、JSON部分の文字列の長さを求める」「Content-Length 分の文字列を読み込み、 JSON.parse する」です。

しかし、process.stdin.on("readable" ... で 1 メッセージ毎に読み込んでくれる保証がないので、「buffer にデータをため込んで1メッセージ分読み込めたら先に進む」というやり方をしました(Node.js の stdin の扱いが良く分かっていないのですが、行ベースの読み込みとバイト数での読み込みを混在させる方法をご存じの方は教えてください…)。

また、メッセージを受け取るときは間違ったメッセージが飛んでくることを考慮しなければなりません。このときの振舞いはResponse Messageに記述があります。

oreore.js
function sendErrorResponse(id, code, message) {
    sendMessage({ jsonrpc: "2.0", id, error: { code, message }});
}

function sendParseErrorResponse() {
    // If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.
    // https://www.jsonrpc.org/specification#response_object
    sendErrorResponse(null, -32700, "received an invalid JSON");
}

function languageServer() {
    let buffer = Buffer.from(new Uint8Array(0));
    process.stdin.on("readable", () => {
        let chunk;
        while (chunk = process.stdin.read()) {
            buffer = Buffer.concat([buffer, chunk]);
        }

        const bufferString = buffer.toString();
        if (!bufferString.includes("\r\n\r\n")) return;

        const headerString = bufferString.split("\r\n\r\n", 1)[0];

        let contentLength = -1;
        let headerLength = headerString.length + 4;
        for (const line of headerString.split("\r\n")) {
            const [key, value] = line.split(": ");
            if (key === "Content-Length") {
                contentLength = parseInt(value, 10);
            }
        }

        if (contentLength === -1) return;
        if (buffer.length < headerLength + contentLength) return;

        try {
            const msg = JSON.parse(buffer.slice(headerLength, headerLength + contentLength));
            dispatch(msg); // 後述
        } catch (e) {
            if (e instanceof SyntaxError) {
                sendParseErrorResponse();
                return;
            } else {
                throw e;
            }
        } finally {
            buffer = buffer.slice(headerLength + contentLength);
        }
    });
}

dispach の中では request, response, notification を振り分けて関数テーブルに登録された関数を呼び出すようにします。

oreore.js
function sendInvalidRequestResponse() {
    sendErrorResponse(null, -32600, "received an invalid request");
}

function sendMethodNotFoundResponse(id, method) {
    sendErrorResponse(id, -32601, method + " is not supported");
}

const requestTable = {};
const notificationTable = {};

requestTable["initialize"] = (msg) => {
    logMessage("initialize");
    // TODO: implement
}

function dispatch(msg) {
    if ("id" in msg && "method" in msg) { // request
        if (msg.method in requestTable) {
            requestTable[msg.method](msg);
        } else {
            sendMethodNotFoundResponse(msg.id, msg.method)
        }
    } else if ("id" in msg) { // response
        // Ignore.
        // This language server doesn't send any request.
        // If this language server receives a response, that is invalid.
    } else if ("method" in msg) { // notification
        if (msg.method in notificationTable) {
            notificationTable[msg.method](msg);
        }
    } else { // error
        sendInvalidRequestResponse();
    }
}

これで実行すると oreore-mode の OUTPUT に initialize と表示されると思います。あとは requestTablenotificationTable に関数を登録していけばよいですね。

* ここまでのソースコード *

脚注
  1. メッセージの送受信方法は stdio 以外にも変更できます。LanguageClient のコンストラクタの serverOptions でこの辺の値を使います。 ↩︎