- JSON-RPC は、ネットワーク越しに関数を呼び出すための、ステートレスで軽量なプロトコルの一種です
- jsonrpcserver は、JSON-RPC 準拠のサーバ機能を実装した Python のライブラリです
なぜ、あえて JSON-RPC を使うのか
Web API は一般的に REST のスタイルで実装されることが多く、実際に Google で「django api server」で検索したら、結果のほとんどは Django REST framework を紹介するページでした。
それに対して JSON-RPC の方式は古くて人気がありません。私自身も、Zabbix API が JSON-RPC でなかったら知る機会すらなかったと思います。
にもかかわらず今回 JSON-RPC を採用した理由は、以下のような要件によるものです。
- API 呼び出しは単なるトリガーとし、サーバ内部のロジックで各種データ処理を完結させたい
- クライアントには処理内容やデータ構造を知らせたくない
- このため、呼び出し側が対象(リソース)と操作(CRUD)を指定する RESTful なスタイルが馴染まない
- とは言え安易なオレオレ方式ではなく、何らかの標準プロトコルに準拠したい
以上の理由により、今回の要件には JSON-RPC がちょうどいいと判断しましたが、特に理由がなければ一般的な REST にするべきです。
JSON-RPC とはどんな方式か
名前のとおり JSON 形式のデータを使ってネットワーク越しの関数呼び出し(RPC)を行う、ステートレスで軽量なプロトコルの一種です。
以下のわずかな文章が JSON-RPC の仕様です。
JSON-RPC 2.0 Specification
仕様としてリクエスト/レスポンス/エラーのデータ形式が規定されていますが、逆にそれ以外の縛りはほとんどありません。
関数呼び出しをネットワークに乗せるための最低限の決め事、というイメージです。
リクエストと正常終了時のレスポンス
例 1
以下のリクエストは、"subtract"
(引き算)という名前の関数に23, 42
という引数を渡して呼び出す例です。
{
"jsonrpc": "2.0",
"method": "subtract",
"params": [23, 42],
"id": 1
}
"method"
で関数名を、"params"
で引数を指定します。
"jsonrpc": "2.0"
は固定で、"id"
は対応するリクエスト/レスポンスを特定するための数値です。
{
"jsonrpc": "2.0",
"result": -19,
"id": 1
}
正常終了時は、結果の値をレスポンスの"result"
で取得することができます。
この例では23 − 42
の計算結果として-19
が返されました。
例 2
次のリクエストは"division"
(割り算)という関数に辞書形式の引数を渡して呼び出す例です。
{
"jsonrpc": "2.0",
"method": "division",
"params": {"divisor": 16, "dividend": 1080},
"id": 2
}
{
"jsonrpc": "2.0",
"result": {"quotient": 67, "remainder": 8},
"id": 2
}
それぞれの引数に名前をつけたので、順序に関係なく"dividend"
(被除数)と"divisor"
(除数)を区別して1080 ÷ 16
と計算することができました。
またこの例では戻り値も辞書形式にして、"quotient"
(商)"remainder"
(余り)という名前を付けて返しています。
エラーのレスポンス
エラーが発生した場合のレスポンスは以下の形式になり、"result"
の代わりに"error"
が返ります。
"error"
の値は以下の辞書形式で、エラーコードとエラーメッセージを返すことが規定されています。
{
"jsonrpc": "2.0",
"error": {"code": -32602, "message": "Invalid params"},
"id": 3
}
次で使用する jsonrpcserver ライブラリは自動的にこの形式で、規格どおりのエラーコードとメッセージを返してくれます。
また HTTP ステータスも同様に、エラーの場合には自動的に 400~500番台の値を返してくれます。
Django と jsonrpcserver の動作環境を Docker Compose で作る
Web フレームワークとして Django を使うことに決めていたので、Django で使える JSON-RPC サーバ用ライブラリとして jsonrpcserver を使用することにしました。
ここでは Docker Compose を使って、これらが動作する最低限の環境を作ります。
環境とバージョン
以下のソフトウェアで動作を確認しました。
- Windows 10 Home 20H2
- Docker Desktop 3.0.4
- Django 3.1.4
- jsonrpcserver 4.2.0
ファイル/ディレクトリの準備
Docker Compose の公式ドキュメントを参考にして、jsonrpcserver が動作する最低限の環境を作ります。
ここではdjango_jrpc/
をベースとして、Docker Compose に必要な定義ファイルを準備します。
またサブディレクトリcode/
を Django のプロジェクトディレクトリとして、コンテナ内から使用できるようにします。
django_jrpc/
├── code/
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
Dockerfile
Docker の定義です。Python の公式 Docker イメージを使用し、/code/
を Django のプロジェクトディレクトリにします。
FROM python:3.9
ENV PYTHONUNBUFFERED 1
WORKDIR /code
ADD requirements.txt /code/
RUN pip install -r requirements.txt
requirements.txt
Dockerfile のRUN
でインストールする pip パッケージのリストです。
今回は jsonrpcserver の動作に最低限必要な Django + jsonrpcserve と、後で使用する django-ipware だけになります。
Django==3.1.4
jsonrpcserver==4.2.0
django-ipware==3.0.2
実際の環境を作るときには、リンターやフォーマッター、DB 接続用ライブラリなどもここに追加します。
docker-compose.yml
Docker Compose の定義です。
version: "3"
services:
web:
build: .
command: python3 manage.py runserver 0.0.0.0:8000
volumes:
- ./code:/code
ports:
- 8000:8000
services:
services
の下のweb
(名前は任意)以下が今回 Django で作るサービスのコンテナ定義になります。
DBMS など別のサービスを追加する場合は、同様に新しいコンテナの定義をservices
の下に追加します。
command:
コンテナ起動時に自動実行するコマンドとして、Django の開発用サーバを起動するための runserver
を指定します。
ここで使用される/code/manage.py
というファイルは Django プロジェクトを作成すると自動的に生成されます。
volumes:
Dockerfile でコンテナ内に作るように定義した/code/
ディレクトリ (WORKDIR
) を、ホストのdjango_jrpc/code/
にマウントします。
イメージのビルドと Django プロジェクトの作成
初回は以下のコマンドを実行して「web」サービスのイメージをビルドして、一時的なコンテナの中で Django プロジェクトを作成します。
docker-compose run --rm web django-admin.py startproject rpcproject .
-
django_jrpc/
(docker-compose.yml があるディレクトリ)で実行する -
web: docker-compose.yml の
services:
で指定したサービス名 - rpcproject: 作成する Django プロジェクト名(任意)
これでdjango_jrpc/code/
の下にmanage.py
と、プロジェクト名と同名のディレクトリrpcproject/
が生成されます。
- manage.py: Django プロジェクトに対する様々な操作を行うためのコマンドラインユーティリティ
django_jrpc/
├── code/
│ ├── rpcproject/
│ │ ├── __init__.py
│ │ ├── asgi.py
│ │ ├── settings.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ └── manage.py
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
コンテナを起動して Django の開発サーバを実行する
以後は以下のコマンドでコンテナを起動できます。
docker-compose.yml のcommand
で指定したpython3 manage.py runserver
により、Django の開発用サーバも起動します。
docker-compose up -d
Web ブラウザでlocalhost:8000
にアクセスして以下の画面が表示されれば、Django プロジェクトの作成と開発用サーバの起動に成功しています。
コンテナの状態と名前を以下のように確認することができます。
docker-compose ps
Name Command State Ports
-----------------------------------------------------------------------------------
django_jrpc_web_1 python3 manage.py runserve ... Up 0.0.0.0:8000->8000/tcp
表示されたコンテナ名を使って、以下のコマンドでコンテナの中に入ることができます。
docker exec -it django_jrpc_web_1 bash
コンテナ内の/code/
ディレクトリは以下のようになりました。
/code/
├── rpcproject/
│ ├── __pycache__/
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── db.sqlite3
└── manage.py
jsonrpcserver の使い方
情報源
このライブラリを使用するうえで困った点は、情報が少ないことでした。
公式のドキュメントとサンプルは以下で全部らしく、非公式の有効な情報も見つかりませんでした。
- jsonrpcserver - Process JSON-RPC requests in Python
- GitHub - bcb/jsonrpcserver
- examples in various frameworks #Django
- guide to usage and configuration
しかしコンパクトなライブラリなので、分からない部分はデバッガでライブラリ内にステップインして読むことで解決しました。勉強にもなって一石二鳥です。
プロジェクトに追加・変更するファイル(共通)
urls.py
プロジェクト作成時に生成されたファイル urls.py に、以下のコメントを付けた 2行を追記します。
これで URL のパスがrpc/
のリクエストが、views.py のjsonrpc
関数にルーティングされます。
from django.contrib import admin
from django.urls import path
from .views import jsonrpc # views.py から jsonrpc 関数をインポートする
urlpatterns = [
path('admin/', admin.site.urls),
path('rpc/', jsonrpc), # URL のパスが 'rpc/' なら jsonrpc 関数にルーティングする
]
views.py
/code/rpcproject/
直下に以下のファイル views.py を追加し、urls.py からルーティングされたリクエストを処理するjsonrpc
関数を定義します。
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from jsonrpcserver import method, dispatch
@csrf_exempt
def jsonrpc(request):
response = dispatch(request=request.body.decode())
return JsonResponse(
response.deserialized(), status=response.http_status, safe=False
)
jsonrpc
関数はリクエストのボディを jsonrpcserver のdispatch()
に渡します。
dispatch()
内ではリクエストボディが精査されて、JSON リクエストの"method"
キーで指定された値と同じ名前の関数に処理が振り分けられます。
参考: jsonrpcserver Guide - Dispatch
例1: 引数なしの例
最初に、公式サンプルの 'ping' の動作を確認します。
以下のように、引数なしで"ping"
という関数を呼べば"pong"
という文字列が返るだけのサンプルです。
{
"jsonrpc": "2.0",
"method": "ping",
"id": 1
}
{
"jsonrpc": "2.0",
"result": "pong",
"id": 1
}
"method": "ping"
のリクエストを受け取るために、views.py に以下のping
関数を追加します。
@method
デコレータを適用して、jsonrpcserver のdispatch()
が振り分け先を見つけられるようにします。
@method
def ping():
return "pong"
クライアント側は JSON を POST で送信できればいいので、今回はシェルスクリプトでcurl
コマンドを使用します。
関数名を"method": "ping"
で指定して、JSON-RPC 形式のリクエストにして POST します。
#!/bin/bash
content_type="Content-Type:application/json-rpc"
url="http://127.0.0.1:8000/rpc/"
payload=$(cat <<__EOT__
{
"jsonrpc": "2.0",
"method": "ping",
"id": 1
}
__EOT__
)
curl -H ${content_type} -d "${payload}" ${url} 2>/dev/null | jq
スクリプトを実行して、以下のように"result": "pong"
のレスポンスが返れば成功です。
{
"jsonrpc": "2.0",
"result": "pong",
"id": 1
}
jsonrpcserver ライブラリのエラー処理 (1)
JSON-RPC 仕様に以下のエラーが定義されています。
https://www.jsonrpc.org/specification#error_object
code | message | meaning |
---|---|---|
-32700 | Parse error | 無効な JSON を受信し、JSON 文字列を解析中にエラーが発生した |
-32600 | Invalid Request | 送信された JSON が有効なリクエストオブジェクトではない |
-32601 | Method not found | メソッドが存在しないか利用できない |
-32602 | Invalid params | メソッドのパラメータが無効 |
-32603 | Internal error | JSON-RPC 内部エラー |
-32000 to -32099 | Server error | 実装定義のサーバーエラー用に予約されている |
検証のためにクライアントスクリプトの一部を改変して、"ping"
の次の,
を削除して不正な JSON にしてみます。
"method": "ping"
"id": 1
結果は期待したとおり、-32700 Parse error
になります。
views.ping
関数にはエラーを処理するコードを何も書いていませんが、ライブラリが内部で判定して適切なエラーを返してくれました。
{
"jsonrpc": "2.0",
"error": {
"code": -32700,
"message": "Invalid JSON"
},
"id": null
}
メソッド名を間違えて存在しないメソッド名にした場合も、期待したとおりに-32601 Method not found
を返してくれます。
"method": "pinggggg",
{
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found"
},
"id": 1
}
このように JSON-RPC で定義されたエラーをライブラリが自動的に処理してくれるので、API 開発者は正しい形式のリクエストを受け取った後の論理的な処理の開発に集中することができます。
例2: 引数ありの例
次は引数を受け取る関数"division"
(割り算)を作ります。
"params"
で 2つの引数"dividend"
(被除数)と"divisor"
(除数)を辞書形式で指定します。
{
"jsonrpc": "2.0",
"method": "division",
"params": {
"dividend": 1080,
"divisor": 16
},
"id": 1
}
戻り値も"quotient"
(商)"remainder"
(余り)を辞書形式で返します。
{
"jsonrpc": "2.0",
"result": {
"quotient": 67,
"remainder": 8
},
"id": 1
}
views.py に以下のdivision
関数を追加します。
@method
def division(dividend, divisor):
return {"quotient": dividend // divisor, "remainder": dividend % divisor}
クライアントのシェルスクリプトを作ります。
まず 2つの引数"dividend"
(被除数)と"divisor"
(除数)がキーワード引数として関数に渡されることを確認するために、わざと順序を逆にしました。
#!/bin/bash
content_type="Content-Type:application/json-rpc"
url="http://127.0.0.1:8000/rpc/"
payload=$(cat <<__EOT__
{
"jsonrpc": "2.0",
"method": "division",
"params": {
"divisor": 16,
"dividend": 1080
},
"id": 1
}
__EOT__
)
curl -H ${content_type} -d "${payload}" ${url} 2>/dev/null | jq
スクリプトを実行すると、期待したとおり "result": {"quotient": 67, "remainder": 8}
を含むレスポンスが返りました。
つまり引数を辞書形式で
"params": {"divisor": 16, "dividend": 1080}
と指定することは、Python のキーワード引数で
(divisor=16, dividend=1080)
と指定することと同じになり、名前が優先されて順序が無視されます。
次に引数をリスト形式で渡します。まず逆順のまま[16, 1080]
にします。
"params": [
16,
1080
],
結果は"result": {"quotient": 0, "remainder": 16}
になったので、Python の位置引数で
(16, 1080)
と指定することと同じになりました。
jsonrpcserver ライブラリのエラー処理 (2)
もう一度"params"
の値を辞書形式に戻しますが、今度はわざと引数名を間違えて違う名前にします。
"params": {
"dividendddddddd": 1080,
"divisor": 16
},
結果は以下のとおり-32602 Invalid parameters
で、不正な引数のエラーになりました。
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "Invalid parameters"
},
"id": 1
}
これもエラー処理のコードを書く必要がなく、ライブラリが内部で判定して適切なエラーを返してくれました。
例3: コンテキストありの例
次はクライアントから渡される引数ではなく、jsonrpc
関数がdispatch()
を通して各関数にデータを渡すことができる「コンテキスト」を使います。
参考: jsonrpcserver Guide - Context
あまり使い道はないかもしれませんが、今回の例ではクライアントの IP アドレスを渡すために使用します。
リクエストから IP アドレスを取得するために、requirements.txt に記述した django-ipware を使用します。
参考: Django でクライアントの IP アドレスを取得する
views.py を以下のように変更します。コメント部分が追加・変更箇所です。
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from jsonrpcserver import method, dispatch
# ライブラリを追加でインポート
import ipware
@csrf_exempt
def jsonrpc(request):
# requestオブジェクトからクライアントのIPアドレスを抽出する
ipaddress, _ = ipware.get_client_ip(request)
# jsonrpcserver.dispatch関数の引数にコンテキストを追加する
response = dispatch(request=request.body.decode(), context={'ipaddress': ipaddress})
return JsonResponse(
response.deserialized(), status=response.http_status, safe=False
)
# 引数リストにcontextを追加する
@method
def division(context, dividend, divisor):
# コンテキストからIPアドレスを取り出す
ipaddress = context['ipaddress']
# IPアドレスを戻り値に追加する
return {"quotient": dividend // divisor, "remainder": dividend % divisor, "ipaddress": ipaddress}
例2 と同じ call_division.sh スクリプトを使ってdivision
メソッドを呼び出すと、IP アドレスが戻り値に追加されました。
{
"jsonrpc": "2.0",
"result": {
"quotient": 67,
"remainder": 8,
"ipaddress": "172.21.0.1"
},
"id": 1
}
jsonrpcserver を使ってみて
以上のように、jsonrpcserver はリクエストの精査や各種エラー処理、指定された名前の関数への振り分けなどを自動的に処理してくれるので、開発者が API の中身の開発に集中できてコードの可読性も向上する、大変ありがたいライブラリです。
最初は情報が少ないことが難点でしたが、使い方が分かってしまえば簡単に使えるようにできています。
参考
- JSON-RPC 2.0 仕様
- jsonrpcserver 公式ドキュメントとサンプル
- Docker Compose
- Zabbix API ドキュメント
- その他