LoginSignup
58
67

More than 5 years have passed since last update.

Pythonでマイクロサービス(概要編)

Posted at

1 はじめに

最近、Lightweight Language(LL)を利用したマイクロサービス(いわゆるREST API)の開発について調べる機会がありました。
LLといってもRuby,Go,Python等々いろいろな言語がありますが、機械学習やIoTの界隈でPythonを利用している知り合いが多かったため、今回はPythonを利用してマイクロサービスを実装する方法について備忘録として残したいと思います。
なお、Pythonは今回が初めてとなる初心者ですので、そのつもりでお読み頂ければと思います。

1.1 検証環境

今回の検証で利用した環境は以下の通りです。
なお、今回はPythonの仮想環境は試していません。
(Windowsだと動作しないと言われているので)

  • Windows 7 Professional SP1 (32bit)
  • Python 2.7.13
  • PostgreSQL 9.3

1.2 利用するライブラリ

今回、Pythonでマクロサービスを作るために利用したライブラリです。
どれもお手軽に利用できるシンプルなライブラリで、すべて「pip install」でインストールできます。

項番 ライブラリ 概要 サイト
1 flask 軽量mvcフレームワーク http://flask.pocoo.org/
2 peewee O/Rマッピング http://docs.peewee-orm.com/en/latest/
3 psycopg2 PostgreSQLアクセスに必要 https://pypi.python.org/pypi/psycopg2
4 cerberus 入力チェックライブラリ http://docs.python-cerberus.org/en/stable/
5 requests HTTPクライアント http://docs.python-requests.org/en/master/

2 環境構築

2.1 Pythonのインストール

インストーラに従ってインストールしました。
インストール先はデフォルトの C:\Python27 です。

2.2 パスを通す

以下の2ヶ所を環境変数のpathに追加しました。
(参考までにwindowsにおけるpathの区切りは;です)

  • C:\Python27
  • C:\Python27\Scripts

2.3 Proxy超えのための環境変数の設定

組織のネットワーク構成の関係上、インターネットアクセスにProxyが必要な場合、Proxy超えのための環境変数を設定します。

ユーザ環境変数でもシステム環境変数でも、どちらでも構いません。
pipのインストール時のみ設定するのであればsetで一時的に設定する方法をおすすめします。

set HTTPS_PROXY=userid:password@proxy.example.jp:8080
set HTTP_PROXY=userid:password@proxy.example.jp:8080

Proxyのホストやポート番号、ユーザID、パスワードは自身の環境に合わせてください。
Proxyにユーザ認証が不要な場合、@の前は要りません。

2.4 Proxyを経由させたくないURLを環境変数として設定

プライベートネットワーク上のサーバにアクセスしたい場合など、Proxyを経由させたくないURLもあるかと思います。
その場合、NO_PROXY環境変数を利用します。

set NO_PROXY=127.0.0.1;localhost;apserver

requestsを利用して自身で用意したサーバにアクセスしようとした際、
なぜかProxy経由になってアクセスできなかったため、NO_PROXY環境変数を設定することになりました。

2.5 必要なライブラリをpipでインストール

再掲になりますが、今回はPythonの仮想環境は試していません。
なのでpipで直にインストールしています。

pip install peewee
pip install psycopg2
pip install requests
pip install cerberus
pip install flask

3 コンセプトアプリケーション

今回紹介するライブラリ(flaskcerberuspeewee)の最低限の使い方を紹介するアプリケーションを実装してみます。

今回は責務に応じた機能分割やファイル分割については考慮しません。
見通しをよくするため、マイクロサービスのWebアプリのソースコード1つと、そのマイクロサービスを利用するクライアントのソースコードの1つとします。

3.1 コンセプトアプリの概要

以下に示すquestionテーブルにレコードを追加するだけのAPIを持ったマイクロサービス(というかREST API)のアプリケーションです。

create_table.sql
DROP TABLE IF EXISTS question;

CREATE TABLE question (
    question_code   VARCHAR(10)    NOT NULL,
    category        VARCHAR(10)    NOT NULL,
    message         VARCHAR(100)   NOT NULL,
    CONSTRAINT question_pk PRIMARY KEY(question_code)
);

アプリケーションの仕様や制約等は以下の通りとします。
仕様に目的が混在している気もしますが、以下が実装のポイントになります。

  • POST/questionsにリクエストを送信するとその内容をDBに登録する。
  • リクエストデータはJSON形式とする。(前提条件として今回はチェックしない)
  • リクエスト、レスポンス等のHTTP関連はflaskの機能を利用する。
  • リクエストデータはcerberusで入力チェックを行う。
    • 全ての項目(question_code、category、message)は必須とする。
    • 最大文字長はテーブルの定義と同じとする。
    • question_codeは10桁の数字(0埋め)形式とする。
  • 入力チェックでエラーとなった場合、HTTPレスポンスコードの404とする。(そういう仕様にしました!)
  • DBアクセス、トランザクション制御等のDB関連はpeeweeの機能を利用する。
  • エラーが発生した場合、HTTPレスポンスコードの500とする。
    • 今回は一意制約エラーも同じ方法でハンドリングすることとする。(動作確認で確認するポイント)

3.2 デモのマイクロサービス

3.2.1 ソースコード

demoapp.py
# -*- coding: utf-8 -*-
import os
from flask import Flask, abort, request, make_response, jsonify
import peewee
from playhouse.pool import PooledPostgresqlExtDatabase
import cerberus

# peewee
db = PooledPostgresqlExtDatabase(
    database = os.getenv("APP_DB_DATABASE", "demodb"),
    host = os.getenv("APP_DB_HOST", "localhost"),
    port = os.getenv("APP_DB_PORT", 5432),
    user = os.getenv("APP_DB_USER", "postgres"),
    password = os.getenv("APP_DB_PASSWORD", "postgres"),
    max_connections = os.getenv("APP_DB_CONNECTIONS", 4),
    stale_timeout = os.getenv("APP_DB_STALE_TIMEOUT", 300),
    register_hstore = False)

class BaseModel(peewee.Model):
    class Meta:
        database = db

# model
class Question(BaseModel):
    question_code = peewee.CharField(primary_key=True)
    category = peewee.CharField()
    message = peewee.CharField()

# validation schema for cerberus
question_schema = {
    'question_code' : {
        'type' : 'string',
        'required' : True,
        'empty' : False,
        'maxlength' : 10,
        'regex' : '^[0-9]+$'
    },
    'category' : {
        'type' : 'string',
        'required' : True,
        'empty' : False,
        'maxlength' : 10
    },
    'message' : {
        'type' : 'string',
        'required' : True,
        'empty' : False,
        'maxlength' : 100
    }
}

# flask
app = Flask(__name__)

# rest api
@app.route('/questions', methods=['POST'])
def register_question():
    # 入力チェック
    v = cerberus.Validator(question_schema)
    v.allow_unknown = True
    validate_pass = v.validate(request.json)
    if not validate_pass:
        abort(404)

    # 業務ロジックの呼び出し
    result = register(request.json)
    # 処理結果の返却(JSON形式)
    return make_response(jsonify(result))

# error handling
@app.errorhandler(404)
def not_found(error):
    return make_response(jsonify({'error' : 'Not Found'}), 404)

@app.errorhandler(500)
def server_error(error):
    return make_response(jsonify({'error' : 'ERROR'}), 500)

# 業務ロジック
@db.atomic()
def register(input):
    # create instance
    question = Question()
    question.question_code = input.get("question_code")
    question.category = input.get("category")
    question.message = input.get("message")
    # insert record using peewee api
    question.save(force_insert=True)
    result = {
        'result' : True,
        'content' : question.__dict__['_data']
    }
    return result

# main
if __name__ == "__main__":
    app.run(host=os.getenv("APP_ADDRESS", 'localhost'), \
    port=os.getenv("APP_PORT", 3000))

3.2.2 peeweeにおけるDB設定

3.2.2.1 対応するDBとコネクションプール

peeweeではSQLLite,MySQL,PostgreSQL等のDBにアクセスすることができます。
詳細については http://docs.peewee-orm.com/en/latest/peewee/database.html を参照ください。

マイクロサービスはもちろんWebアプリのため、コネクションプールの機能は必須になるかと思います。
peeweeについてももちろんコネクションプールに対応しています。
設定はDB毎に異なり、PostgreSQLの場合はPooledPostgresqlExtDatabaseクラスを利用します。

PooledPostgresqlExtDatabaseクラスを利用する場合、psycopg2がインストールされていないと実行時にエラーになります。

peeweeの機能と関係ありませんが、DBの接続情報(ホスト、ポート番号、ユーザID、パスワード等)は環境依存のため、プログラムの外部(環境変数)から設定できるようにすることを推奨します。

3.2.2.2 PostgreSQLのHSTORE機能

peeweeでPostgreSQLを利用する場合、デフォルトではPostgreSQLのHSTOREという機能を利用する前提になっています。そのため、利用するデータベースでHSTOREが有効になっていない場合、peeweeのDBアクセスでエラーになります。

対応としては、データベースにCREATE EXTENSION hstore;でHSTOREを有効にする、
もしくは、register_hstore = Falseを設定して、peeweeでHSTOREを利用しないように設定するのどちらかが必要になります。

3.2.2.3 BaseModelの定義

peeweeのO/Rマッピング機能を利用するため、peeweeのModelを継承したクラスを定義します。
今回はBaseModelというクラスにしていますが、このクラスは内部にMetaクラスを定義し、そのdatabaseフィールドに前述のDB定義オブジェクトを設定する必要があります。

3.2.3 peeweeのモデル定義

peeweeのO/Rマッピング機能は、テーブルとクラス、カラムとフィールドを関連付けます。
そしてpeeweeのモデルオブジェクトがテーブルの1レコードとマッピングされます。

peeweeのモデルは前述のBaseModelを継承したクラスとして定義します。
フィールドはテーブルのデータ型に応じたpeeweeのフィールド型を設定します。

  • CharField() : varchar型
  • IntegerField() : integer型
  • DateField() : date型
  • DateTimeField() : timestamp型 ...等々

詳細については http://docs.peewee-orm.com/en/latest/peewee/models.html#fields を参照ください。

プライマリキーのフィールドにはprimary_key=Trueを設定します。
primary_keyを設定しない場合、peeweeはidという主キーのカラムが存在する前提で動作します。そのため当然ですがidというカラムがテーブルに存在しなければエラーとなります。

フィールド名とカラム名が異なる場合、db_column=カラム名でカラムを明示的に設定します。

3.2.4 flaskによるリクエストマッピング

flaskのアプリケーションを作成するには、まずはじめに__name__を引数にしてflaskのインスタンスを生成します。
その後、route()関数デコレータを利用して、定義した関数にflaskによるリクエストマッピングを設定することで、HTTPリクエストと関数を関連付けします。

JavaにおけるSpring Framework(Spring MVC)を利用した経験のある方には、@RequestMappingアノテーションを思い浮かべると分かりやすいかと思います。
(flaskにはリクエストパラメータやヘッダにおけるマッピングの機能はありません)

  • リクエストパス

    • route()の第一引数はリクエストパスになります。
    • JavaEEのWebアプリのようにコンテキストパスは存在せず、URLのポート番号直後のパスとして認識されます。(通常、HTTPのウェルノウンポートである80は明示しません)
  • 変数埋め込みのリクエストパス

    • リクエストパスの特殊形として、リクエストパスの一部を変数として受け取ることが可能なマッピング方法があります。
    • 例えば/questions/0000000001/questions/0000000999にGETでアクセスした際、0000000001や0000000999といった値をパス変数の引数として受け取ってリクエストをマッピングしたい場合、以下のように設定することで実現できます。
@app.route('/questions/<string:question_code>', methods=['GET'])
def reference_question(question_code):
  • HTTPメソッド
    • GET、POST、PUT、DELETE等のHTTPメソッドで実行可能なメソッドを指定することができます。
    • methods=['GET','POST']のように配列で指定するため、複数のメソッドを指定することが可能です。
    • 何も指定しない場合、つまりデフォルトはGETによるアクセスのみとなります。

リクエストマッピングの詳細については http://flask.pocoo.org/docs/0.12/quickstart/#routing を参照ください。

3.2.5 flaskによるエラーハンドリング

falskにはエラーハンドリングを設定するためのerrorhandler()関数デコレータが用意されています。
引数にはハンドリング対象のHTTPステータスコードを設定します。
デモアプリでは404と500の二つをハンドリングするようにしました。

3.2.6 cerberusによる入力チェック

3.2.6.1 入力チェックルールのスキーマ定義

cerberusでは入力チェックルールのことをスキーマと呼びます。
(たぶんデータ構造を決めるものなのでDBと同じくスキーマと呼んでいるだと思います)

スキーマはPythonの辞書型で定義します。なお、Pythonの辞書型を知らなかったため、初めて見たときはJSONで記述するものと思っていました。

フィールド毎にデータ型(type)や必須有無(required)、適用する入力チェックのルール(maxlengthregex等)を記述します。ぱっと見てなんとなく意味が分かるレベルのシンプルさかと思います。

デフォルトで用意されている入力チェックのルールについては http://docs.python-cerberus.org/en/stable/validation-rules.html を参照ください。

3.2.6.2 入力チェックの実行

cerberusによる入力チェックは、まず始めにスキーマに応じたValidatorのインスタンスを生成し、そのインスタンスを利用して入力チェックを行います。
(スキーマ毎にインスタンスを生成しないで使う方法もありますが、今回は割愛します)

デモアプリではquestion_schemaというスキーマを定義したので、これを引数にValidatorインスタンスを生成しました。

入力チェックの実行は簡単で、入力チェックの対象となる辞書型のデータを引数としてvalidateメソッドを実行します。
戻り値として入力チェックでエラーがなければTrue、エラーがあればFalseが返却されます。

flaskと連携し利用する場合、request.jsonプロパティでリクエストデータに辞書型(JSON形式)でアクセスすることができるので、これをvalidateメソッドの引数に設定します。
なお、RESTアプリのため今回は利用しませんが、入力フォームのデータはrequest.formでアクセスすることができます。

validate_pass = v.validate(request.json)
validate_pass = v.validate(request.form)

flaskとは関係なく単純にインスタンスをチェックしたい場合、validateメソッドは辞書型の引数しか取れないため__dict__を利用します。

question_ng = Question()
question_ng.question_code = "abc0123456789xyz"
question_ng.category = None
question_ng.message = ""

validate_pass = v.validate(question_ng.__dict__)

cerberusのデフォルトでは、入力データにスキーマに定義されていないフィールドが存在した場合、入力チェックエラーとなります。この動作を変更するにはValidatorインスタンスのallow_unknownプロパティをTrueに設定します。(allow_unknown = True

後述する「入力チェックのエラー内容について」を見れば分かると思いますが、cerberusのValidatorインスタンスは内部に状態を保持するステートフルなものです。ステートレスではありません。

デモアプリでリクエスト毎にValidatorインスタンスを生成しているのはそのためです。
インスタンス生成処理の効率化(削減)とスレッドセーフなValidatorについては検討が必要です。

3.2.6.3 入力チェックエラーのエラー内容について

cerberusでは入力チェックエラーのエラー内容は、Validatorインスタンスのerrorsプロパティに格納されます。
インスタンスのプロパティに保持するので、もちろんですがチェックする毎に更新されます。
これでValidatorインスタンスを複数スレッドで共有できない(スレッドセーフではない)ことが分かったかと思います。

if not validate_pass:
    print v.errors
    abort(404)

errorsプロパティの内容を確認するため、試しに以下のようなエラーとなる入力データをチェックしてみます。

エラーとなる入力データ
question = {
    'question_code' : '99999999990000000',
    'category' : '',
    'message' : 'hello'
}

errorsの内容は、エラーとなったフィールドがキー、エラーメッセージの配列が値の辞書型となっています。
ですので、一つのフィールドに複数の入力チェックエラーが該当した場合、エラーメッセージが複数格納されます。

表示されるv.errorsの内容
{u'category': ['empty values not allowed'], u'question_code': ['max length is 10']}

3.2.7 peeweeにおけるトランザクション制御

peeweeにはトランザクション制御のための関数デコレータが用意されています。
関数にatomic()デコータを付けるだけで、トランザクション境界を設定することができます。
Spring Frameworkの@Transactionalを利用した宣言的トランザクションと見た目が似ているので、Springを利用したことのある方にはなじみやすかいかと思います。
(仕組みは開発言語の仕様とAOPなので異なりますが)

もちろんトランザクションのネストや、明示的にbegin、rollback、commitを制御することも可能です。
トランザクション制御の詳細については http://docs.peewee-orm.com/en/latest/peewee/transactions.html を参照ください。

3.2.8 peeweeにおけるinsert

peeweeにおけるinsertには2つの方法があります。そのため、複数メンバで開発する場合、方法を揃える必要がありそうです。

3.2.8.1 レコードの登録とインスタンス生成を同時に実行

モデルクラスのcreate()メソッドを利用し、レコードの登録とインスタンス生成を同時に実行します。
insert時に必要なデータ(Not Nullカラムの全データ)をcreateメソッドの引数として設定します。

question = Question.create(question_code = input.get("question_code"), \
    category = input.get("category"), \
    message = input.get("message"))

インスタンスとレコードをマッピングするというO/Rマッピングの機能と、感覚的になんとなく合わないのでデモアプリはこの方法を利用してません。

詳細については http://docs.peewee-orm.com/en/latest/peewee/api.html#Model.create を参照ください。

3.2.8.2 インスタンスを生成した後、任意のタイミングでレコードを登録

登録したいインスタンスを生成した後、任意のタイミングでインスタンスのsave()メソッドを実行することでレコードを登録します。
この際、引数のforce_insertプロパティにTrueを設定するのがポイントです。(force_insert=True

本来save()メソッドは更新用(updateを発行)のメソッドですが、force_insert=Trueを設定することでinsertを発行するようになります。

詳細については http://docs.peewee-orm.com/en/latest/peewee/api.html#Model.save を参照ください。

3.3 デモクライアント

3.3.1 ソースコード

democlient.py
# -*- coding: utf-8 -*-
import requests

# if proxy pass error occurred, try "set NO_PROXY=127.0.0.1;localhost" at console
def register_question():
    print("[POST] /questions")

    question = {
        'question_code' : '9999999999',
        'category' : 'demo',
        'message' : 'hello'
    }
    headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
    response = requests.post('http://localhost:3000/questions',
        json=question, headers=headers)
    print(response.status_code)
    print(response.content)

# main
if __name__ == "__main__":
    register_question()

3.3.2 requestsによるHTTPリクエストの送信

requestsにはpostgetといったHTTPメソッドに対応するメソッドが用意されています。
今回はPOSTでリクエストを送信するためpostを利用しました。

json形式でデータを送信する場合、json=questionのようにjson引数にデータを設定します。
同様にリクエスト時のHTTPヘッダを指定したい場合、headers引数に設定します。

実はjson引数を利用した場合、Content-typeapplication/jsonが自動で設定されます。
ですので、本当はデモのソースコードのように明示的に設定する必要はありません。
(HTTPヘッダを設定する方法を知りたかったので、今回は設定しました)

3.4 動作確認

3.4.1 マイクロサービスの起動

C:\tmp>python demoapp.py
 * Running on http://localhost:3000/ (Press CTRL+C to quit)

スケールアウトする際、同一コンピュータ上で複数のプロセスを起動させる場合もあるかと思います。
その際、待ち受けポートが被らないように変更する必要があります。
デモアプリでは待ち受けポートを環境変数から上書きできるようにしているので、起動時に変更することができます。

C:\tmp>set APP_PORT=3001
C:\tmp>python demoapp.py
 * Running on http://localhost:3001/ (Press CTRL+C to quit)

3.4.2 デモクライアントの起動

データベースに該当のデータが存在しない状態で、デモクライアントを実行します。
正常に実行され、登録したデータがJSONとして返却されると思います。

C:\tmp>python democlient.py
[POST] /questions
200
{
  "content": {
    "category": "demo",
    "message": "hello",
    "question_code": "9999999999"
  },
  "result": true
}

次に、この状態でもう一度デモクライアントを実行してみます。
もちろん一意制約に引っかかるため、エラーとなるはずです。
これでHTTPステータス500の{'error' : 'ERROR'}が返却されれば、設定したエラーハンドリングも動作していることになります。

C:\tmp>python democlient.py
[POST] /questions
500
{
  "error": "ERROR"
}

最後にDBから該当データを削除してもう一度実行してみます。
一意制約エラーが発生しなくなるので、正常に動作するはずです。

C:\tmp>python democlient.py
[POST] /questions
200
{
  "content": {
    "category": "demo",
    "message": "hello",
    "question_code": "9999999999"
  },
  "result": true
}

4 さいごに

flask、cerberus、peeweeを利用したPythonによるマイクロサービスの作り方について説明してみました。
Webアプリで最低限必要となるHTTPリクエストのハンドリング、入力チェック、DBアクセスの各機能を、今回紹介したライブラリを利用することで、簡単に実装できることを紹介できたかと思います。

コンセプトアプリの最初にも説明しましたが、実際のシステム開発では重要となる責務に応じた機能分割やファイル分割については考慮していません。
また、マイクロサービスを外部に公開するには必須となる認証、認可、流量制御、モニタリング、ログ取得等々についても別途検討が必要です。

5 参考情報

Markdown記法 チートシート
Markdown書き方メモ
PythonでREST APIをサクっと実装
PythonのORMのPeeweeを使ってデータベースを操作してみる

58
67
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
58
67