Elasticsearchのデータ操作をサーバ上で行いたい場合に、curlでアクセすれば良いが、ちょっと複雑なことをやろうとするとシェルスクリプトでは面倒だったりする。
で、こういうちょっとしたスクリプトを実装するのはSpringではやはり難しいだろうか?と調べてみたら Spring Boot CLI がちょうど良さそうだったので試してみた。
今回は jq を使うことで結局シェルスクリプトでも何とかなるレベルのものにとどまってしまったが、あれこれ試した結果をまとめておく。
いろいろ手段は用意されているようだが今回は sdkman を使ってインストールする。
sdkmanはcurlでインストール。
curl -s "https://get.sdkman.io" | bash
Spring Boot CLI は springboot
の名前で提供されている。
今回はバージョン指定で 1.3.2.RELEASE
をインストール。
sdk install springboot 1.3.2.RELEASE
これで spring
コマンドが使えるようになる。
ElasticsearchもSpring Bootで起動してみる。
以下のようなスクリプトを elasticsearch.groovy
という名前で作成する。
@Grab('spring-boot-starter-data-elasticsearch')
@RestController
class Elasticsearch {
}
Gradleファイルなどは不要。
@Grab
によって依存関係を取り込んでくれる。
これ自体はGroovy (Grape)の機能だが、通常より簡略化された記述ができる。
httpでアクセスしたいので、application.yml
を作っておく。
spring:
data:
elasticsearch:
properties:
http:
enabled: true
で、起動する。
spring run elasticsearch.groovy
http://localhost:9200/_cat/health などにアクセスすれば起動していることが確認できる。
Groovyファイルの内容について、Controller機能はいらないのだが@Component
で作るとアプリがサーバとして起動せずそのまま終了してしまうので、Webサーバとして起動するように
@RestController
を使用している。
なお、以下のようにすればjarファイルを作成することもできる。
spring jar elasticsearch.jar elasticsearch.groovy
先ほどのElasticsearchにアクセスしてクラスタのhealthを確認するスクリプトを作ってみる。
と言っても、肝心のAPIアクセス部分はcurlコマンドを叩くだけ。
GroovyのString#execute()
で簡単にできる。
@ConfigurationProperties(prefix = 'es')
class EsHealthConfigProperties {
String host = 'localhost'
int port = 9200
}
@Component
class EsHealth implements CommandLineRunner {
@Autowired
EsHealthConfigProperties esConfig
@Override
void run(String... args) {
println "curl http://${esConfig.host}:${esConfig.port}/_cluster/health?pretty=true".execute().text
}
}
起動して何かを実行して終了するだけのものなので、CommandLineRunner
を使っている。
また実行時にホスト・ポートくらいは指定できるように Spring Boot の @ConfigurationProperties
を使っている。
これによって、例えば以下のようにパラメータを変更して起動することもできる。
spring run eshealth.groovy -- --es.port=9201
実行時にバナーやSpring Bootのログを出力したくなければ、以下のようにapplication.yml
を定義しておけばいい。
spring:
main:
banner-mode: "off"
logging:
level:
org.springframework: WARN
あらかじめ登録してあるデータから、クエリで検索する例。
Elasticsearchのドキュメントからダウンロードできるデータ(accounts.json
)をサンプルとして使う。
このファイルは
{"index":{"_id":"1"}}
{"account_number":1,"balance":39225,"firstname":"Amber","lastname":"Duke","age":32,"gender":"M","address":"880 Holmes Lane","employer":"Pyrami","email":"amberduke@pyrami.com","city":"Brogan","state":"IL"}
{"index":{"_id":"6"}}
{"account_number":6,"balance":5686,"firstname":"Hattie","lastname":"Bond","age":36,"gender":"M","address":"671 Bristol Street","employer":"Netagy","email":"hattiebond@netagy.com","city":"Dante","state":"TN"}
{"index":{"_id":"13"}}
...
というように bulk API で登録できる形式になっている。
ドキュメントにある通り、
curl -XPOST 'localhost:9200/bank/account/_bulk?pretty' --data-binary "@accounts.json"
とすれば登録できる。
このデータから、balanceの値が特定の範囲にあるデータを抽出してみる。
スクリプトは以下の通り。(esexport.groovy
とする)
@Grab('groovy-all')
import groovy.json.*
@ConfigurationProperties(prefix = 'es')
class EsExportConfigProperties {
String host = 'localhost'
int port = 9200
int balanceFrom = -1
int balanceTo = -1
}
@Component
class EsExport implements CommandLineRunner {
@Autowired
EsExportConfigProperties esConfig
@Override
void run(String... args) {
def json = new JsonBuilder()
json {
query {
filtered {
filter {
bool {
must {
range {
balance {
from esConfig.balanceFrom
to esConfig.balanceTo
}
}
}
}
}
}
}
}
def command = "curl --noproxy ${esConfig.host} http://${esConfig.host}:${esConfig.port}/bank/account/_search -d ${json.toString()}"
def result = new JsonSlurper().parseText(command.execute().text)
result.hits.hits.each { hit ->
println JsonOutput.toJson([index: [_id: hit._id]])
println JsonOutput.toJson(hit._source)
}
}
}
以下のように実行すると、balanceが20000~30000のデータが抽出される。
spring run esexport.groovy -- --es.balanceFrom=20000 --es.balanceTo=30000
上記は、取得した結果の中から、実際のデータにあたる部分を取り出して accounts.json
と同様にbulk APIのインプットとして利用できるような形式に加工して出力している。
JSONの操作にはGroovyの JsonBuilder
, JsonSlurper
, JsonOutput
を使っている。これらはgroovy-all
という依存関係に含まれ、デフォルトではインポートされていないので
@Grab('groovy-all')
import groovy.json.*
と書いておく。
ここまででなんとなく動くように見えるのだが、ページの指定が正しくできていないためデフォルトの10件だけしか取得できない。
ここでは、リクエストパラメータに from
と size
を指定して、結果が0件になるまで from
をずらしながら繰り返しリクエストするようにする。
@Override
void run(String... args) {
def json = new JsonBuilder()
json {
from 0
size 100
query {
filtered {
filter {
bool {
must {
range {
balance {
from esConfig.balanceFrom
to esConfig.balanceTo
}
}
}
}
}
}
}
}
while (true) {
def command = "curl --noproxy ${esConfig.host} http://${esConfig.host}:${esConfig.port}/bank/account/_search -d ${json.toString()}"
def result = new JsonSlurper().parseText(command.execute().text)
if (result.hits.hits.size() == 0) {
break
}
result.hits.hits.each { hit ->
println JsonOutput.toJson([index: [_id: hit._id]])
println JsonOutput.toJson(hit._source)
}
json.content.from += json.content.size
}
}
こういう「ロジック」っぽいコードが増えてくると、シェルスクリプトで書くよりいいかな?と思えてくる。
ここまでくると Spring Boot CLI は関係なくなってきているがcurlを使わずにGroovyのみでHTTPアクセスもするように変更してみる。
これには、http-builder を使うのが簡単そう。
Unitテストのことを考えるとRestTemplate
などを使ったほうが良いのかもしれないが、ここではhttp-builderのRESTClient
にする。
この依存関係も Grab で取得して、importしておく。
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.7')
import groovyx.net.http.*
import static groovyx.net.http.ContentType.*
そして、curlによるHTTPアクセスの部分を以下のように書き換える。
def http = new RESTClient("http://${esConfig.host}:${esConfig.port}/")
while (true) {
def result = http.post(path: 'bank/account/_search', contentType: JSON, body: json.toString()).data
やってみたことは以上。
今回の内容は GitHub に登録してある。
冒頭に書いた通り、やはりシェルスクリプトなどで十分というケースもあるが、これが良い選択肢になる時もありそう。
メリットは以下のような点だろうか。
brew install elasticsearch
で入れようとしたらXcodeをバージョンアップしろと言われすぐには終わらなかったので、余計にSpring Boot CLIの手軽さが際立った。逆に改善できないか考えたいポイントは以下。
なお今回使ったのは 1.3.2.RELEASE なので最新版で改善されている部分もあるかもしれないが、未確認。
混ぜて管理するにはどうしたらいいか?
せっかくGradleで構成していても、sdkmanとspringコマンドを新しく入れないと使えないのは微妙...