All Articles

MeCabのgRPCサーバを作る

MeCabの環境を整えるのが面倒なのと(必要になる度に忘れて調べ直したり)、複数の言語からMeCabを使いたい場合はサーバにした方が楽そうなので作りました。インターフェイス的にはREST APIやらJSON-RPCやら色々考えられますが、スキーマがあって長い目で見ると色々と楽だったり、パフォーマンスが出たり、gRPCの人気自体が今後伸びそうだったり、等の理由でgRPCを採用しました。

今回の成果物

https://github.com/ayatoy/mecab-grpc

プロジェクトの構造

├── .dockerignore
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── mecab.proto
├── protoc-gen.sh
├── requirements.txt
├── server.py
└── test_server.py

主要なものは mecab.protoserver.pyDockerfile の3つのみ。 protoc-gen.shmecab.proto からスタブ等を生成するために grpc_tools.protoc を呼び出しているだけ。 test_server.pypytest から呼び出されるテスト。残りはおまけ。

mecab.proto

syntax = "proto3";

package mecab;

service Parser {
    rpc Parse (ParseRequest) returns (ParseResponse) {}
}

message ParseRequest {
    repeated string sentences = 1;
    string dictionary = 2;
}

message ParseResponse {
    repeated Sentence sentences = 1;
}

message Sentence {
    repeated Word words = 1;
}

message Word {
    string surface = 1;
    repeated string feature = 2;
}

単純。 Parser という1つのサービスに Parse というRPCが1つ定義されているだけ。 ParseParseRequest を受け取って ParseResponse を返す。 ParseRequest は解析したい文字列のリスト sentences と、 sentences のそれぞれの文字列を解析する際に使用する辞書を指定するための dictionary を持つ。 ParseResponseParseRequest で渡した文字列のリストのそれぞれの要素に対応した解析結果 Sentence のリストを元の順序を保ったまま保持する。 Sentence には分解された単語 Word のリスト、 Word には surface と品詞などの情報を含む feature をそれぞれ持つ。

server.py

from concurrent import futures
import os
import os.path
import subprocess
import threading
import time

import MeCab
import grpc

from mecab_pb2 import ParseResponse, Sentence, Word
from mecab_pb2_grpc import ParserServicer, add_ParserServicer_to_server

_ONE_DAY_IN_SECONDS = 60 * 60 * 24

_HOST = os.environ.get('MECAB_GRPC_HOST', '[::]')
_PORT = os.environ.get('MECAB_GRPC_PORT', '50051')
_ADDRESS = _HOST + ':' + _PORT


class Environment:

    def __init__(self, default_dictionary='ipadic'):
        self.dictionary_directory = subprocess.check_output(['mecab-config', '--dicdir']).decode('utf-8').split('\n')[0]
        if not self.dictionary_directory:
            raise Exception('Invalid Directory: %r' % self.dictionary_directory)
        self.default_dictionary = default_dictionary
        self.dictionaries = set(os.listdir(self.dictionary_directory))
        self.taggers = threading.local()

    def get_tagger(self, dictionary):
        if dictionary not in self.dictionaries:
            dictionary = self.default_dictionary
        tagger = getattr(self.taggers, dictionary, None)
        if tagger is None:
            tagger = MeCab.Tagger('-d %s' % os.path.join(self.dictionary_directory, dictionary))
            # For bug that node.surface can not be obtained with Python3.
            tagger.parse('')
            setattr(self.taggers, dictionary, tagger)
        return tagger


class Parser(ParserServicer):

    def __init__(self, environment):
        self.environment = environment

    def Parse(self, request, context):
        sentence_texts = request.sentences
        dictionary = request.dictionary
        tagger = self.environment.get_tagger(dictionary)
        sentences = []
        for sentence_text in sentence_texts:
            words = []
            for row in tagger.parse(sentence_text).split('\n')[:-2]:
                cols = row.split('\t')
                words.append(Word(
                    surface=cols[0],
                    feature=cols[1].split(','),
                ))
            sentences.append(Sentence(words=words))
        return ParseResponse(sentences=sentences)


def serve():
    environment = Environment()
    server = grpc.server(futures.ThreadPoolExecutor())
    add_ParserServicer_to_server(Parser(environment), server)
    server.add_insecure_port(_ADDRESS)
    server.start()
    try:
        while True:
            time.sleep(_ONE_DAY_IN_SECONDS)
    except KeyboardInterrupt:
        server.stop(0)


if __name__ == '__main__':
    serve()

これも単純。ほぼチュートリアルに毛が生えた程度。まずプログラムは MECAB_GRPC_HOSTMECAB_GRPC_PORT という2つの環境変数を読み取る。それぞれは _HOST_PORT という変数に(環境変数が存在しない場合はデフォルト値を伴って)格納され、 _ADDRESS という変数に結合された状態で保持され、後で server.add_insecure_port() に渡される。

クラス Environment はインストールされたMeCabの環境を抽象化したモノ(のつもり)。インスタンスが作られると mecab-config のプロセスを使って辞書周りのディレクトリを読み取って、システムに存在する辞書を認識する。また、ワーカースレッドごとにTaggerをキャッシュするために self.taggerslocal オブジェクトを保持する。 get_tagger メソッドは辞書の名前を受け取ってそれに対応するTaggerを返す(キャッシュが存在する場合はそちらを返す)。

クラス Parser はgRPCサービスの実装(protocで作られた ParserServicer を継承)。コンストラクタから Environment のインスタンスを受け取って self.environment に保持。 Parse メソッドはRPCの本体。 ParseRequest のオブジェクトを受け取り self.environment.get_tagger() を呼び出してTaggerを取得し、 request.sentences のそれぞれの文字列を tagger.parse() に渡して出力された文字列を泥臭く分解して( tagger.parseToNode() を使うパターンもあるけど、自分の用途では今の所単語の surface と品詞が分かれば良いので tagger.parse() で十分)、 WordSentenceParseResponse を構成して return

serve() はチュートリアルとほぼ同じ。違うのは Environment のインスタンスを作って Parser に渡してるくらい。あとスレッドプールのワーカ数をPythonのデフォルト(プロセッサの数x5)にしてるとこ(これは環境変数とかでパラメータを受け取って調整できるようにしても良いと思う)。

Dockerfile

FROM python:3.5.6-alpine3.9
LABEL Name=mecab-grpc Version=1.0.0

RUN apk add --no-cache git make g++ swig

WORKDIR /
RUN git clone https://github.com/taku910/mecab.git
WORKDIR /mecab/mecab
RUN ./configure --enable-utf8-only \
    && make \
    && make check \
    && make install

WORKDIR /mecab/mecab-ipadic
RUN ./configure --with-charset=utf8 \
    && make \
    && make install

WORKDIR /mecab/mecab-jumandic
RUN ./configure --with-charset=utf8 \
    && make \
    && make install

COPY . /mecab-grpc
WORKDIR /mecab-grpc
RUN python -m pip install --upgrade pip \
    && python -m pip install -r requirements.txt \
    && sh protoc-gen.sh

CMD ["python", "server.py"]

ベースイメージは python:3.5.6-alpine3.9 。Pythonが3.5なのは、3.6と3.7だと自分の手元の環境ではgRPC周りでエラーが出たから(後で調べようの精神。動かすことを優先)。そんでapkで必要なパッケージを入れて(mecab-python3が0.996.1からswigを必要とする)、 MeCab本体と付属の辞書をインストール(mecab-ipadic-NEologdとかその他の辞書が必要な場合は、各々入れるだけで ParseRequest で指定してそのまま動くと思う)。最後にmecab-grpcに必要なものを pip install -r requirements.txt した後に sh protoc-gen.sh でスタブ諸々を生成して終わり。

イメージサイズの削減はalpineを使う以外は特にしていないのでまた今度。
(追記:Dockerのイメージサイズを削減する)

参考