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.proto
、server.py
、Dockerfile
の3つのみ。 protoc-gen.sh
は mecab.proto
からスタブ等を生成するために grpc_tools.protoc
を呼び出しているだけ。 test_server.py
は pytest
から呼び出されるテスト。残りはおまけ。
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つ定義されているだけ。 Parse
は ParseRequest
を受け取って
ParseResponse
を返す。 ParseRequest
は解析したい文字列のリスト sentences
と、 sentences
のそれぞれの文字列を解析する際に使用する辞書を指定するための dictionary
を持つ。 ParseResponse
は ParseRequest
で渡した文字列のリストのそれぞれの要素に対応した解析結果 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_HOST
と MECAB_GRPC_PORT
という2つの環境変数を読み取る。それぞれは _HOST
と _PORT
という変数に(環境変数が存在しない場合はデフォルト値を伴って)格納され、 _ADDRESS
という変数に結合された状態で保持され、後で server.add_insecure_port()
に渡される。
クラス Environment
はインストールされたMeCabの環境を抽象化したモノ(のつもり)。インスタンスが作られると mecab-config
のプロセスを使って辞書周りのディレクトリを読み取って、システムに存在する辞書を認識する。また、ワーカースレッドごとにTaggerをキャッシュするために self.taggers
に local
オブジェクトを保持する。 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()
で十分)、 Word
、Sentence
、ParseResponse
を構成して 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のイメージサイズを削減する)