watson-api-client gem の背景
「Ruby からの Watson Natural Language Classifier 利用例」の記事に関連して、watson-api-client gem の背景をまとめておきます。
1.発端
まずは 「Watson の機能のひとつ“Relationship Extraction”を試しにコマンドラインから動かしてみよう」と思い立ったのが発端。
一番慣れている Ruby から REST API をたたいて応答を得るスクリプトを書いてみることにしました。
ふむふむ API のパスは https://gateway.watsonplatform.net/relationship-extraction-beta/api/v1/sire/0 で、text やら rt やらのパラメータをつけて POST すればいいのか…とドキュメントを読み取る1。
だったら、いちいち毎回こういうパスなんか書かずにクラスやメソッドにまとめてしまえば可読性が上がると思うのは誰しも同じです。そして、こういうときいつも困るのが命名規則。
ところが API のドキュメントを読むと「API の nickname は extract」とある。いったいこれは何?
もし、すべての API に nickname がついているなら、それをそのままメソッド名にすれば命名に悩まなくて済む!
でも、Web 上の API ドキュメントをひとつひとつ開いて API の nickname を確認してスクリプトをコーディングするってめんどくさいな。それに、仕様変更で名前がふらふら変わるかもしれない。
2.メタプログラミング
…と、ここで気が付いたのが、Watson API ドキュメントのからくり。
どうやら Web の Watson API ドキュメントは個別に用意されているのではなくて、Swagger というフォーマットで書かれた JSON ファイル(例えばこちら)を“神様”にして、それをインタープリトして駆動されているらしい2。
だったら「Swagger フォーマットの“神様”JSON ファイルを読んで API の nickname を確認してスクリプトをコーディングする」というメタスクリプトを書けばいいじゃないか!
…という経緯で出来上がったのが watson-api-client の当初の安定バージョンです。
ポイントとなるコードを書き出してみると、こんな感じ↓
###- 全体起動時
全体起動時には“API一覧”をもとに各機能クラスの入れ物のみ用意して、各機能クラスの定数“API”が参照された時点で初めて“神様”JSON ファイルを読みに行くようにしています。
listings = JSON.parse(open(Base + path, Options).read)
listings['apis'].each do |list|
module_eval %Q{
class #{('-'+list['path']).gsub(/[-_\/]+(.)/) {$1.upcase}} < self
Service = superclass::Services['#{list['path'].sub(/^[-_\/]+/,'').gsub(/-/, '_')}']
RawDoc = "#{Base + listings['basePath'] + list['path']}"
class << self
alias :_const_missing :const_missing
def const_missing(constant)
if constant == :API
const_set(:API, listings(JSON.parse(open(RawDoc, superclass::Options).read)))
else
_const_missing(constant)
end
end
end
pp [self, 'See ' + RawDoc, API['digest']] if '#{__FILE__}' == '#{$PROGRAM_NAME}'
end
}
end
定数“API” 参照時に呼ばれる listings メソッドは下記の通りで、“神様”JSON ファイルを読んで “nickname” などを取得しています。
def listings(apis)
methods = {}
digest = {}
apis['apis'].each do |api|
api['operations'].each do |operation|
body = nil
(operation['parameters']||[]).each do |parameter|
next unless parameter['paramType'] == 'body'
body = parameter['name']
break
end
nickname = operation['nickname'].sub(/(.)/) {$1.downcase}
methods[nickname] = {'path'=>api['path'], 'operation'=>operation, 'body'=>body}
digest[nickname] = {'method'=>operation['method'], 'path'=>api['path'], 'summary'=>operation['summary']}
end
end
{'apis'=>apis, 'methods'=>methods, 'digest'=>digest}
end
###- 機能開始時
各機能クラスのインスタンスを生成する際に定数“API”が参照されるようにし、つづいて個々の API に対応するインスタンスメソッドをメタプログラミングしています。
def initialize(options={})
self.class::API['methods'].each_pair do |method, definition|
self.class.module_eval %Q{define_method("#{method}",
Proc.new {|options={}| rest_access_#{definition['body'] ? 'with' : 'without'}_body("#{method}", options)}
)} unless respond_to?(method)
end
…
end
メタプログラミングを使ったおかげで、全体で有効行にして約100行程度のコンパクトなスクリプトになりました。
3.Watson API 日本語対応版?
事件が起こったのは約半年後。突然“API一覧”がなくなったというIssue#1 が発行されました。
ちょうど Watson API 日本語対応版のアナウンスがされた時期です。たぶん大規模な変更に伴い Swagger ドキュメントまわりも変わってしまったのでしょう。
主な変化は、
結果、“API一覧”を人間用の Webドキュメントから文字列のパターンマッチをして“神様”JSON ファイルの所在を抽出せねばならなくなりました。
・Swagger フォーマット仕様が変わった。
もともと“nickname”というのは Watson API のローカルルール。これが“operationId”に変わって、しかも存在が保証されなくなった。同時にフォーマット仕様のバージョン自体が 1.2 から 2.0 に上がった。
…などです。
現状もとどおり動くようにはなっていますが、人間用の Webドキュメントから文字列のパターンマッチをせねばならない3という問題は解決していないので、Issue#1 はオープンのままにしています。
4.Alchemy API 対応
次に起こったのは、そもそも当初の目的だった Watson の機能“Relationship Extraction”がなくなってしまったという事件。
もともとベータ版だった“Relationship Extraction”が廃止されてAlchemy API のひとつである“AlchemyLanguage”に統合されてしまったのです。
困ったことに Alchemy API は、サードパーティであった Alchemy 社を IBM 社が買収して Watson に後から統合したサービスのため、結構 Swagger ドキュメントのノリが違いました。
しかし結局塞翁が馬、「どのようなパラメータを指定してメソッドが呼ばれたかに応じて、賢く POST / GET / DELETE などを選択できるようになる」というオマケつきでこの問題は解決することができました。
5.今後
もともとは「仕様変更で名前がふらふら変わるかもしれない」という問題にメタプログラミングでロバストに対応できるのではないかという期待があったのですが、実際はそれほどでもなかったようです。
watson-api-client はボランティアで公開していて動作確認での課金発生は最小限にしているという事情があり、今後も Issue ドリブンでやっていきたいと思っています。