はじめに
Apache Tikaのバージョンを1.85から2.9.2に変更したところ、nkf --guess
がASCIIと判定するWebページのデータを処理しようとしたタイミングで例外が発生するようになりました。
このファイル自体は1998年前後に作成されたもので、HTMLとしてはかなり古い仕様で作成されています。
もう少し調べてみるとUS-ASCII形式であることや古いファイルが問題を引き起すのではなく、文字コードがTika内部で判別できないことによるエラーであることが分かりました。問題はnginxが返すようなデフォルトのエラページでも発生します。
もう少し原因を探るとexec-maven-pluginを利用すると問題は発生しません。問題はJARファイルを利用した時に発生していてApache Tika 2.7.0までは問題なく動作し、2.8.0〜2.9.1までは maven-assembly-plugin で作成した *-jar-with-dependencies.jar ファイルを利用すると問題なく処理できることが分かり、2.9.2ではどうやってもエラーを回避する方法は見つかりませんでした。
エラーメッセージは次のとおりです。
Failed to detect the character encoding of a document
Tikaを1.85から2.9.2に対応させたので、その過程でコード上の処理に漏れがあったのかとも思ったのですが、前述したとおりJARファイルの構成によるもののようです。
根本原因の調査はできませんでしたが対応方法は分かったので顛末をまとめます。
1.85と2.9.2の処理の違いを調査する
htmlParseオブジェクトは、org.apache.tika.parser.html.HtmlParserのインスタンスなのですが、1.28.5でもparse()メソッドの本体は同じ処理をしています。
2.9.2のコードを確認すると、例外を投げている処理は org.apache.tika.detect.AutoDetectReader のdetect()メソッドが出しています。ここの処理も1.28.5と2.9.2で違いはありません。
Tika 2.9.2からバージョンを下げて挙動の違いを確認する
ここら辺から2.9.2固有の挙動なのではと思いライブラリのバージョンを下げていくと、Apache Tikaの2.7.0では例外を出さず、2.8.0から例外の送出が始まっていることが分かります。
2.7.0の挙動を確認すると無事に動作した場合はmetadataオブジェクトの内容がtryの前後で次のように変化します。
## tryブロックの直前
Content-Type=text/html
## ↓ htmlParser.parse()の処理後tryブロックの直後
Content-Encoding=windows-1252 Content-Type=text/html; charset=windows-1252
Windows-1252エンコーディングはいわゆるUS-ASCIIにラテン語圏の文字セットを加えた8ビットコードになっています。いずれにしてもTikaによってcharsetが判別できれば問題なさそうです。
Webサーバー側のContent-TypeにはUTF-8の指定は入っていません。
HTTP/1.1 200 OK
Date: Thu, 12 Sep 2024 05:16:34 GMT
Server: Apache
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
Last-Modified: Thu, 12 Sep 2024 05:10:10 GMT
ETag: "a0-621e51ee7d080"
Accept-Ranges: bytes
Content-Length: 160
X-Content-Type-Options: nosniff
Content-Type: text/html
問題が発生しないURLを確認するとサーバーのcontent-typeにcharset=utf-8が指定されていたり、HTML5等の仕様に従っているコンテンツのようでした。
概念実証コードの作成
ここまでの調査から、どうやらUTF-8などと判定できないようなWebページで問題になることが分かってきました。
## 正しく処理できないWebページ
$ curl https://www.yadiary.net/notfound | nkf --guess
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 178 100 178 0 0 715 0 --:--:-- --:--:-- --:--:-- 717
ASCII (CRLF)
## 正しく処理できるWebページ
$ curl https://www.yadiary.net/ | nkf --guess
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 6798 0 6798 0 0 51834 0 --:--:-- --:--:-- --:--:-- 51893
UTF-8 (LF)
これだと英語圏でかなり問題が出そうな感じですが、一緒に使っているcrawler4jのコードに起因するような感じがするのでシンプルなプログラムを作成して確認を続けます。
以下で行った作業はGitHubに登録しています。
コードを実行して分かったこと
最初はまったく気がついていなかったのですが、shadingしたJARファイルを利用すると問題が発生します。
通常はテストのためにexec-maven-pluginを利用していたため、単体テストでは気がついていませんでした。
$ make c
$ make run
mvn exec:java
...
builtin: https://www.yadiary.net/notfound
builtin: bodyBytes.length=196
dc:title=404 Not Found Content-Encoding=ISO-8859-1 Content-Type=text/html; charset=ISO-8859-1
...
JARファイルを利用すると問題が発生していてます。アプリケーションはコンテナにしているのでJARファイルを使っています。
$ java -jar target/encoding-issue-1.0.0.jar
builtin: https://www.yadiary.net/notfound
builtin: bodyBytes.length=196
Error: Failed to detect the character encoding of a document
...
exec-maven-pluginを利用すると実行の度にカスタムクラスローダーが必要なJARファイルを読み込むだろうと思うので、shadedされたJARファイルがclassファイルを配置する過程の挙動の違いが原因だろうなとは思いつつ、もう少しTikaのバージョンの違いについて調べてみます。
Tikaのバージョンによる挙動の違い
pom.xmlファイルをいくつか準備して挙動の違いを確認しました。
ファイルはGitHubのプロジェクトに含めています。
Tika 2.7.0の場合
$ mvn -f pom270.xml clean compile package
exec-maven-plugin経由で実行します。
$ mvn -f pom270.xml exec:java
...省略...
builtin: https://www.yadiary.net/notfound
builtin: bodyBytes.length=196
dc:title=404 Not Found Content-Encoding=ISO-8859-1 Content-Type=text/html; charset=ISO-8859-1
httpclient5: https://www.yadiary.net/notfound
httpclient5: bodyBytes.length=196
dc:title=404 Not Found Content-Encoding=ISO-8859-1 Content-Type=text/html; charset=ISO-8859-1
encoding-issue-1.0.0.jar を使用しても結果は同じです。
$ java -jar target/encoding-issue-1.0.0.jar
...省略...
builtin: https://www.yadiary.net/notfound
builtin: bodyBytes.length=196
dc:title=404 Not Found Content-Encoding=ISO-8859-1 Content-Type=text/html; charset=ISO-8859-1
httpclient5: https://www.yadiary.net/notfound
httpclient5: bodyBytes.length=196
dc:title=404 Not Found Content-Encoding=ISO-8859-1 Content-Type=text/html; charset=ISO-8859-1
encoding-issue-1.0.0-jar-with-dependencies.jarを使用します。
$ java -jar target/encoding-issue-1.0.0-jar-with-dependencies.jar
builtin: https://www.yadiary.net/notfound
builtin: bodyBytes.length=196
Error: Failed to detect the character encoding of a document
httpclient5: https://www.yadiary.net/notfound
httpclient5: bodyBytes.length=196
Error: Failed to detect the character encoding of a document
2.8.0, 2.9.1, 2.9.2でも同様に確認した結果のまとめ
次の表のようにそれぞれ実行方法によって結果が異なります。
Tika 2.9.1まではmaven-assembly-pluginを使うことで対応しましたが、2.9.2ではいまのところmavenを使わないと実行できない状況です。
Tika Version | exec:java | .jar | jar-with-dependencies.jar |
---|---|---|---|
2.7.0 | OK | OK | NG |
2.8.0 | OK | NG | OK |
2.9.1 | OK | NG | OK |
2.9.2 | OK | NG | NG |
2.7.0では完全にうまく動作していると思ったのですが、maven-assembly-pluginで生成されるJARファイルは失敗します。
ここまでが背景のまとめです。
参考資料
- https://tika.apache.org/2.9.2/detection.html
- https://qiita.com/YasuhiroABE/items/4df272fcc0c2cccc4173
- https://stackoverflow.com/questions/51382751/maven-exec-works-but-java-jar-does-not
- https://github.com/qos-ch/logback/issues/744
- https://github.com/aws/serverless-java-container/issues/133
- https://product.hubspot.com/blog/the-fault-in-our-jars-why-we-stopped-building-fat-jars
- https://github.com/HubSpot/SlimFast
対応の検討
実際に背景をまとめるまでは紆余曲折ありましたが、なんとか情報は整理できたと思います。
参考資料に挙げているGitHubのIssues#133にあるリンクを辿ってHubSpotの記事を読むとJARに全てのファイルをまとめることで規模の大きなプロジェクトではファイル転送時のネットワーク帯域にも悩んでいるようです。
target/libディレクトリの内容をコピーして、JARファイルには依存関係のないアプリケーションのコードだけを含めるという考え方だとexec-maven-pluginと同様の環境が得られるでしょうから問題が解決しそうです。
Examplesに挙げられているCopy Goalを単純に加えて最新版のTika 2.9.2で試してみます。
$ cat pom.xml
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>org.yasundial.app.tika.App</mainClass>
<classpathPrefix>lib/</classpathPrefix>
<classpathLayoutType>repository</classpathLayoutType>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>com.hubspot.maven.plugins</groupId>
<artifactId>slimfast-plugin</artifactId>
<version>0.22</version>
<executions>
<execution>
<goals>
<goal>copy</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
...
$ make c
$ make p
## 下記の実行方法はいずれもtarget/lib/ディレクトリに依存しています
$ java -jar target/encoding-issue-1.0.0.jar
$ java -cp target/lib:target/original-encoding-issue-1.0.0.jar org.yasundial.app.tika.App
いずれの方法でも問題なく無事にTika 2.9.2でも動作が確認できました。コメントにも記載していますが、JARファイル単独では起動できないのでJARファイルが配置されているディレクトリにtarget/lib/ディレクトリのコピーが必要です。
JARファイルのMANIFEST.MFを確認するとClass-Path:が大量に明記されています。
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Class-Path: lib/org/apache/tika/tika-core/2.9.2/tika-core-2.9.2.jar lib/
org/slf4j/slf4j-api/2.0.10/slf4j-api-2.0.10.jar lib/commons-io/commons-
...
Main-Class: org.yasundial.app.tika.App
現在はこの状態のpom.xmlをGitHubのプロジェクトに配置しています。
crawler4jでの問題解決
元々のcrawler4jのpom.xmlにslimfastを適用したところ、問題なく動作しました。
最終的にpom.xmlからmaven-assembly-pluginとmaven-shade-pluginの定義全体を削除しています。
さいごに
最終的には依存性のあるJARファイルをlibディレクトリに配置することでexec:javaと同様の環境することで解決しました。
バージョン違いのライブラリを使う時に必ずしも新しいバージョンのclassファイルが参照されていないのだろうと思いますが根本原因の調査は時間がかかりそうなので、とりあえず現実的なワークアラウンドを取ることで一旦終りにします。
Apache Tikaのバージョンを上げてみるとdeprecatedなメソッド呼び出しなどがあって修正箇所は少なかったですが、ライブラリの依存性以外にも少し手を加える必要がありました。
この他にはAutoDetectParserが実際にはほぼ何も自動的に判別してくれなくなっていたりして、ここら辺の挙動もひょっとすると関連した影響なのかなと思いましたが、最終的には個別にContent-Typeをみて明示的にParserを呼び出すようにして解決しています。
アプリケーション全体を最適化するには良い機会だったのですが、まだ日本語Postscriptをps2pdfで変換した後に日本語が抽出できないとか、httpclient5への移行も進めていますが日本語の抽出に失敗するなど、他の問題も顕在化しました。
いまのところGitHubに登録しているコードはhttpclient5を利用した部分は日本語部分が文字化けします。これは明示的に"UTF-8"を指定することで解決できます。
diff --git a/src/main/java/org/yasundial/app/tika/App.java b/src/main/java/org/yasundial/app/tika/App.java
index c2ad449..0935ee8 100644
--- a/src/main/java/org/yasundial/app/tika/App.java
+++ b/src/main/java/org/yasundial/app/tika/App.java
@@ -77,7 +77,7 @@ public class App {
final HttpGet httpget = new HttpGet(urlText);
final Result result = httpclient.execute(httpget, response -> {
- return new Result(response.getCode(), EntityUtils.toString(response.getEntity()));
+ return new Result(response.getCode(), EntityUtils.toString(response.getEntity(), "UTF-8"));
});
byte[] bodyBytes = result.content.getBytes();
System.out.println("httpclient5: bodyBytes.length=" + bodyBytes.length);
作成したGitHubのプロジェクトはWebクローラーこそ使用していませんが、取得するURLを変更することでTikaの動作確認には応用できるんじゃないかなと思います。この記事が何かしら参考になれば幸いです。