LoginSignup
3
3

More than 3 years have passed since last update.

Python の jsonrpcserver ライブラリを使ったら Django が JSON-RPC の API サーバになった

Last updated at Posted at 2020-04-25
  • 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という引数を渡して呼び出す例です。

例1-引き算のリクエスト
{
  "jsonrpc": "2.0",
  "method": "subtract",
  "params": [23, 42],
  "id": 1
}

"method"で関数名を、"params"で引数を指定します。
"jsonrpc": "2.0"は固定で、"id"は対応するリクエスト/レスポンスを特定するための数値です。

例1-引き算の正常レスポンス
{
  "jsonrpc": "2.0",
  "result": -19,
  "id": 1
}

正常終了時は、結果の値をレスポンスの"result"で取得することができます。
この例では23 − 42の計算結果として-19が返されました。

例 2

次のリクエストは"division"(割り算)という関数に辞書形式の引数を渡して呼び出す例です。

例2-割り算リクエスト
{
  "jsonrpc": "2.0",
  "method": "division",
  "params": {"divisor": 16, "dividend": 1080},
  "id": 2
}
例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 のプロジェクトディレクトリにします。

Dockerfile
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 だけになります。

requirements.txt
Django==3.1.4
jsonrpcserver==4.2.0
django-ipware==3.0.2

実際の環境を作るときには、リンターやフォーマッター、DB 接続用ライブラリなどもここに追加します。

docker-compose.yml

Docker Compose の定義です。

docker-compose.yml
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 プロジェクトの作成と開発用サーバの起動に成功しています。
django-install-success.png

コンテナの状態と名前を以下のように確認することができます。

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 の使い方

情報源

このライブラリを使用するうえで困った点は、情報が少ないことでした。
公式のドキュメントとサンプルは以下で全部らしく、非公式の有効な情報も見つかりませんでした。

しかしコンパクトなライブラリなので、分からない部分はデバッガでライブラリ内にステップインして読むことで解決しました。勉強にもなって一石二鳥です。

プロジェクトに追加・変更するファイル(共通)

urls.py

プロジェクト作成時に生成されたファイル urls.py に、以下のコメントを付けた 2行を追記します。
これで URL のパスがrpc/のリクエストが、views.py のjsonrpc関数にルーティングされます。

urls.py
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関数を定義します。

views.py
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"という文字列が返るだけのサンプルです。

例1-pingリクエスト
{
  "jsonrpc": "2.0",
  "method": "ping",
  "id": 1
}
例1-ping正常レスポンス(pong)
{
  "jsonrpc": "2.0",
  "result": "pong",
  "id": 1
}

"method": "ping"のリクエストを受け取るために、views.py に以下のping関数を追加します。
@methodデコレータを適用して、jsonrpcserver のdispatch()が振り分け先を見つけられるようにします。

views.py
@method
def ping():
    return "pong"

クライアント側は JSON を POST で送信できればいいので、今回はシェルスクリプトでcurlコマンドを使用します。
関数名を"method": "ping"で指定して、JSON-RPC 形式のリクエストにして POST します。

call_ping.sh
#!/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 にしてみます。

call_ping.sh
  "method": "ping"
  "id": 1

結果は期待したとおり、-32700 Parse errorになります。
views.ping関数にはエラーを処理するコードを何も書いていませんが、ライブラリが内部で判定して適切なエラーを返してくれました。

エラーレスポンス(無効なJSON)
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32700,
    "message": "Invalid JSON"
  },
  "id": null
}

メソッド名を間違えて存在しないメソッド名にした場合も、期待したとおりに-32601 Method not foundを返してくれます。

call_ping.sh
  "method": "pinggggg",
エラーレスポンス(メソッドが見つからない)
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found"
  },
  "id": 1
}

このように JSON-RPC で定義されたエラーをライブラリが自動的に処理してくれるので、API 開発者は正しい形式のリクエストを受け取った後の論理的な処理の開発に集中することができます。

例2: 引数ありの例

次は引数を受け取る関数"division"(割り算)を作ります。
"params"で 2つの引数"dividend"(被除数)と"divisor"(除数)を辞書形式で指定します。

例2-割り算リクエスト
{
  "jsonrpc": "2.0",
  "method": "division",
  "params": {
    "dividend": 1080,
    "divisor": 16
  },
  "id": 1
}

戻り値も"quotient"(商)"remainder"(余り)を辞書形式で返します。

例2-割り算の正常レスポンス
{
  "jsonrpc": "2.0",
  "result": {
    "quotient": 67,
    "remainder": 8
  },
  "id": 1
}

views.py に以下のdivision関数を追加します。

views.py
@method
def division(dividend, divisor):
    return {"quotient": dividend // divisor, "remainder": dividend % divisor}

クライアントのシェルスクリプトを作ります。
まず 2つの引数"dividend"(被除数)と"divisor"(除数)がキーワード引数として関数に渡されることを確認するために、わざと順序を逆にしました。

call_division.sh
#!/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]にします。

call_division.sh
  "params": [
    16,
    1080
  ],

結果は"result": {"quotient": 0, "remainder": 16}になったので、Python の位置引数で
(16, 1080)
と指定することと同じになりました。

jsonrpcserver ライブラリのエラー処理 (2)

もう一度"params"の値を辞書形式に戻しますが、今度はわざと引数名を間違えて違う名前にします。

call_division.sh
  "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 を以下のように変更します。コメント部分が追加・変更箇所です。

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 の中身の開発に集中できてコードの可読性も向上する、大変ありがたいライブラリです。

最初は情報が少ないことが難点でしたが、使い方が分かってしまえば簡単に使えるようにできています。

参考

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3