マイクロアドのサーバサイドエンジニアの松宮です。今回はプログラミングのTips的な記事になります。
タイトルの通り、「SparkでGeoIP2を使うとjava.lang.NoSuchMethodErrorが発生する問題の回避方法」を説明したいと思っておりまして、というのも、SparkでGeoIP2ライブラリの依存を上手く解決できずに、結構時間を使ってしまったので、犠牲者を増やさないためにもこの場でみなさんに共有したいと思います。
事象と解決策を先に示して、詳細な原因は後述しますので、解決策だけ早く知りたい方は前半まで読んでもらえれば大丈夫です。
前提
- 言語はScala/Java
- ビルドツールはsbt or Maven
- 実行可能なjarを生成するために本記事ではsbt-assemblyを使っている
- Sparkのバージョンは最新の2.3.2 (2018年8月現在)
- GeoIP2ライブラリのバージョンは最新の2.12.0 (2018年8月現在)
事象
Spark上でMaxMindのGeoIP2ライブラリを使おうとすると、java.lang.NoSuchMethodError
が発生してしまう問題があります。
※GeoIP2ライブラリが何かというと、IPアドレスを入れると、地理情報が返ってくるライブラリのことです。
例えば下記の「IPアドレスを入力に国を判定して出力するストリーム処理」を動かそうとすると実行時に例外が発生します。
import java.net.InetAddress import com.maxmind.geoip2.DatabaseReader import org.apache.spark.SparkConf import org.apache.spark.storage.StorageLevel import org.apache.spark.streaming.{Seconds, StreamingContext} object Main { def main(args: Array[String]): Unit = { val sparkConf = new SparkConf() val ssc = new StreamingContext(sparkConf, Seconds(10)) val lines = ssc.socketTextStream("localhost", 9999, StorageLevel.MEMORY_AND_DISK_SER) val countries = lines.map { ip => val reader = new DatabaseReader.Builder( getClass.getClassLoader.getResourceAsStream("GeoIP2-City.mmdb") ).build() reader.city(InetAddress.getByName(ip)).getCountry.getName } countries.print() ssc.start() ssc.awaitTermination() } }
これをビルドして実行すると下記のエラーが発生します。
$ sbt assembly $ spark-submit --master local[*] --class Main <ビルドしたjar> ... Exception in thread "main" java.lang.NoSuchMethodError: com.fasterxml.jackson.databind.node.ArrayNode.<init>(Lcom/fasterxml/jackson/databind/node/JsonNodeFactory;Ljava/util/List;)V at com.maxmind.db.Decoder.decodeArray(Decoder.java:272) at com.maxmind.db.Decoder.decodeByType(Decoder.java:156) at com.maxmind.db.Decoder.decode(Decoder.java:147) at com.maxmind.db.Decoder.decodeMap(Decoder.java:281) at com.maxmind.db.Decoder.decodeByType(Decoder.java:154) at com.maxmind.db.Decoder.decode(Decoder.java:147) at com.maxmind.db.Decoder.decode(Decoder.java:87) at com.maxmind.db.Reader.<init>(Reader.java:132) at com.maxmind.db.Reader.<init>(Reader.java:89) at com.maxmind.geoip2.DatabaseReader.<init>(DatabaseReader.java:64) at com.maxmind.geoip2.DatabaseReader.<init>(DatabaseReader.java:54) at com.maxmind.geoip2.DatabaseReader$Builder.build(DatabaseReader.java:160) ...
GeoIP2のDecoder
クラスがArrayNode
クラスを生成しようとして、java.lang.NoSuchMethodError
(メソッドがないよ)とのことです。
解決策
なぜNoSuchMethodError
が発生するかは後述するとして、解決するにはbuild.sbt
に下記を記述して、jarを生成(sbt assembly
する)するだけです。
assemblyShadeRules in assembly := Seq( ShadeRule.rename("com.fasterxml.jackson.core.**" -> "shadedjackson.core.@1").inAll, ShadeRule.rename("com.fasterxml.jackson.annotation.**" -> "shadedjackson.annotation.@1").inAll, ShadeRule.rename("com.fasterxml.jackson.databind.**" -> "shadedjackson.databind.@1").inAll )
ちなみに、Mavenの場合はpom.xml
に下記を追加します
<relocations> <relocation> <pattern>com.fasterxml.jackson.core</pattern> <shadedPattern>shadedjackson.core</shadedPattern> <pattern>com.fasterxml.jackson.annotation</pattern> <shadedPattern>shadedjackson.annotation</shadedPattern> <pattern>com.fasterxml.jackson.databind</pattern> <shadedPattern>shadedjackson.databind</shadedPattern> </relocation> </relocations>
なぜ発生するのか
SparkもGeoIP2のどちらもjackson
に依存しており、両者で使っているバージョンが異なるために発生します。Sparkが依存するjackson
は2.6
系で、GeoIP2が依存するのは2.7
以降です。
そして、GeoIP2ではjackson
の2.7
から追加されたインタフェースを使っているコードがあるため、最低でも2.7
以降のバージョンのjackson
を利用する必要があります。具体的にはcom.fasterxml.jackson.databind.node.ArrayNode
の下記のコンストラクタをGeoIP2では利用しようとします。
/** * @since 2.7 */ public ArrayNode(JsonNodeFactory nf, List<JsonNode> children) { super(nf); _children = children; }
そのため、2.7
以降を使う必要があるのですが、Sparkは起動時にSpark自身が提供する依存をクラスパスに追加するようになっているので、アプリ側で如何に最新のjackson
を使おうが関係ありません。
ちなみに、Sparkが提供するjarは/.../apache-spark/2.3.2/libexec/jars/
以下にあります。なのでSparkが提供するjackson
を削除するというのも1つの手ですが、アプリ側でjackson
を使わなくなったら起動しなくなったりすると思うのであまり良い手段ではありません。
そこで、shadeという方法を使ってパッケージ名を変更することで解決します。
shadeによる解決方法
Sparkが提供するjackson
を使わないようにするために、shadeという設定をします。shadeとは依存の名前を変更する機能で、その依存を使う全てのクラスのパッケージ名を変更します。すると、アプリのjar内では名前が変更されたjackson
を使うようになるので、Sparkが提供するjackson
はアプリから参照されなくなります。
では、実際に確認してみましょう。生成したjarから、今回問題になったArrayNode.class
とDecoder.class
を取り出して中身を見ます。
$ jar xf <ビルドしたjar> shadedjackson/databind/node/ArrayNode.class $ jar xf <ビルドしたjar> com/maxmind/db/Decoder.class
package shadedjackson.databind.node; ... public class ArrayNode extends ContainerNode<ArrayNode> { private final List<JsonNode> _children; public ArrayNode(JsonNodeFactory nf) { super(nf); this._children = new ArrayList(); } public ArrayNode(JsonNodeFactory nf, int capacity) { super(nf); this._children = new ArrayList(capacity); } public ArrayNode(JsonNodeFactory nf, List<JsonNode> children) { super(nf); this._children = children; } ... }
package com.maxmind.db; ... import shadedjackson.databind.JsonNode; import shadedjackson.databind.ObjectMapper; import shadedjackson.databind.node.ArrayNode; import shadedjackson.databind.node.BigIntegerNode; import shadedjackson.databind.node.BinaryNode; import shadedjackson.databind.node.BooleanNode; import shadedjackson.databind.node.DoubleNode; import shadedjackson.databind.node.FloatNode; import shadedjackson.databind.node.IntNode; import shadedjackson.databind.node.LongNode; import shadedjackson.databind.node.ObjectNode; import shadedjackson.databind.node.TextNode; final class Decoder { ... private JsonNode decodeArray(int size) throws IOException { List<JsonNode> array = new ArrayList(size); for(int i = 0; i < size; ++i) { JsonNode r = this.decode(); array.add(r); } return new ArrayNode(OBJECT_MAPPER.getNodeFactory(), Collections.unmodifiableList(array)); } ... }
すると、ちゃんとパッケージ名が切り替わっているのが確認できます。これによって、Spark自身が提供する古いjackson
を使わず、jarの中に入っているjackson
(今回だとshadedjackson
)を使えるようになります。
ということで、無事SparkでGeoIP2ライブラリを使うことができました!(ºωº)/