Seleniumで画像比較を活用したUI自動テスト
はじめに
この記事は、UL Systems Advent Calendar 2020 - 19日目の転記です。
システム開発の最終段階ではUIからバックエンドロジックまでを含めて期待通りの結果になっているか確認をE2Eテストで行うと思います。 ただE2Eテストでは人手による操作を行い目視での確認を行うケースが多いのではないでしょうか。そうした場合、一部のロジックを修正し再度テストを行う場合も自動で行う事は出来ません。 また、Seleniumを使用したテストではE2Eテストを自動で行う事はできますがテストコード作成に対する工数がかかり採用されないケースもあります。
そこで、画像比較を用いる事でテストコード作成の工数を削減出来るのでは無いかと思いSeleniumと画像比較を使用したUIテストをご紹介いたします。
今回使用するImageComparisonは、画像の比較を行うライブラリです。
ImageComparisonとは
同じサイズの2つの画像を比較し、異なる部分に長方形を描画することで違いを視覚的に示すことが出来るライブラリです。
ライブラリのプロパティ説明
ライブラリを使用して指定可能なプロパティです。
プロパティ | 説明 |
---|---|
threshold | 等しくないと判断するピクセル間の最大距離の閾値(デフォルト:5) |
rectangleLineWidth | 長方形の線幅(デフォルト:1) |
destination | 比較結果ファイルの保存先 |
minimalRectangleSize | 最小長方形サイズ(デフォルト:1) |
maximalRectangleCount | 描画される長方形の最大数(デフォルト:-1[制限無]) |
pixelToleranceLevel | ピクセル許容レベル(デフォルト:0.1[10%]) |
excludedAreas | 画像を比較するときに無視されるリスト |
drawExcludedRectangles | 除外された長方形を描くの可否 |
fillExcludedRectangles | 除外された長方形を塗りつぶすかの可否 |
percentOpacityExcludedRectangles | 除外された長方形の不透明度 |
fillDifferenceRectangles | 差分長方形の可否 |
percentOpacityDifferenceRectangles | 差分長方形の不透明度 |
allowingPercentOfDifferentPixels | 無視されるピクセル割合(デフォルト:-1[制限無]) |
Seleniumを使用した例
今回はSeleniumを使用しGoogleでウルシステムズ株式会社
と検索した検索結果画面とウルシステムズ
と検索した検索結果画面の比較を行う自動テストを行います。テスト結果がfalseの場合は、異なる箇所が長方形で囲まれた比較画像を生成します。
現状の画面キャプチャを取得する
まず始めに比較元となる画面キャプチャ画像を取得します。
Seleniumを使用しchromeを起動後にGoogleでウルシステムズ株式会社
と検索し検索結果を表示した画面のキャプチャ画像を保存します。
public class ExpectedImage { public static void main(String[] args) throws InterruptedException { // Chrome System.setProperty("webdriver.chrome.driver", "selenium/webdriver/chrome/87.0.4280.88/win32/chromedriver.exe"); // Chrome 起動オプションを構成 ChromeOptions options = new ChromeOptions(); options.addArguments("--headless"); WebDriver driver = new ChromeDriver(options); driver.get("https://www.google.co.jp/"); WebElement element = driver.findElement(By.name("q")); element.sendKeys("ウルシステムズ株式会社"); element.submit(); driver.manage().timeouts().pageLoadTimeout(15, TimeUnit.SECONDS); Thread.sleep(5000L); driver.manage().window().setSize(new Dimension(Integer.parseInt("1500"), Integer.parseInt("3000"))); File screenFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); Files.copy(screenFile.toPath(), Paths.get("src/test/resources/screenshot/expected/ウルシステムズ株式会社.png"), StandardCopyOption.REPLACE_EXISTING); driver.quit(); } }
比較元の検索結果画面
比較元となる画面キャプチャ画像です。
検索結果のUI自動テスト
SeleniumとImageComparisonを使用してUI自動テストコードを書き比較元の画面キャプチャ画像と比較を行います。比較結果が異なる場合は異なる部分が長方形で覆われた画像が出力されます。
今回はSeleniumを使用しchromeを起動後にGoogleでウルシステムズ
と検索し検索結果画面の画像比較を行いたいと思います。
public class SearchULSTest { private WebDriver driver; @Before public void setUp() { // Chrome System.setProperty("webdriver.chrome.driver", "selenium/webdriver/chrome/87.0.4280.88/win32/chromedriver.exe"); // Chrome 起動オプションを構成 ChromeOptions options = new ChromeOptions(); options.addArguments("--headless"); this.driver = new ChromeDriver(options); } @After public void closeDriver() { this.driver.quit(); } @Test public void test0001() throws IOException, InterruptedException { driver.get("https://www.google.co.jp/"); WebElement element = driver.findElement(By.name("q")); element.sendKeys("ウルシステムズ"); element.submit(); driver.manage().timeouts().pageLoadTimeout(15, TimeUnit.SECONDS); Thread.sleep(5000L); driver.manage().window().setSize(new Dimension(Integer.parseInt("1500"), Integer.parseInt("3000"))); File screenFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); Files.copy(screenFile.toPath(), Paths.get("src/test/resources/screenshot/actual/ウルシステムズ.png"), StandardCopyOption.REPLACE_EXISTING); Thread.sleep(5000L); boolean result = compareImage("screenshot/expected/ウルシステムズ株式会社.png", "screenshot/actual/ウルシステムズ.png"); Assert.assertTrue(result); } private static boolean compareImage(String expected, String actual) { // 比較する画像のロード BufferedImage expectedImage = ImageComparisonUtil.readImageFromResources(expected); BufferedImage actualImage = ImageComparisonUtil.readImageFromResources(actual); // 比較結果ファイルの保存先 File resultDestination = new File("compareimage.png"); // 画像比較オブジェクトの生成 ImageComparison imageComparison = new ImageComparison(expectedImage, actualImage, resultDestination); // 画像比較を判断するピクセル間の最大距離 imageComparison.setThreshold(10); // 差分長方形の線幅 imageComparison.setRectangleLineWidth(2); // 差分長方形の内側を塗りつぶすかの指定と透明度 imageComparison.setDifferenceRectangleFilling(true, 10.0); // 除外長方形の内側を塗りつぶすかの指定と透明度 imageComparison.setExcludedRectangleFilling(true, 10.0); // 描画される長方形の最大数 imageComparison.setMaximalRectangleCount(100); // 最小長方形サイズ imageComparison.setMinimalRectangleSize(10); // ピクセル許容レベル imageComparison.setPixelToleranceLevel(0.2); // 画像の比較 ImageComparisonResult imageComparisonResult = imageComparison.compareImages(); if (ImageComparisonState.MATCH == imageComparisonResult.getImageComparisonState()) return true; // 比較結果の画像を保存する ImageComparisonState imageComparisonState = imageComparisonResult.getImageComparisonState(); BufferedImage resultImage = imageComparisonResult.getResult(); ImageComparisonUtil.saveImage(resultDestination, resultImage); return false; } }
比較対象の検索結果画面
比較するGoogleでウルシステムズ
と検索し検索結果を表示した画面のキャプチャ画像です。
比較結果の画像
元画像と比較を行い異なった部分が赤い長方形でマークされた画像です。
まとめ
今回はImageComparisonを使用し画像比較による自動テストを行ってみました。 画面の一つ一つの項目に対して検証用コードを書くのはとても大変ですが、画像比較を活用する事で同一かどうか違う場合は何処が違っているのかを一気に見つける事が出来ます。 注意点としては、事前に正となる画面の画像が必要、あくまで静的な画面画像による比較検証ですので画面の動作検証には適用できません。
このように画像比較を使用しテストコード作成の工数削減を図ってみてはどうでしょうか。
「みんなのJava」が来た! #minjava
2020/03/06に発売の「みんなのJava」という書籍を @kencharos さんより献本を頂きました。ありがとうございます! gihyo.jp と言う事で、早速読んでみました。
本書の内容に関して
内容は、Java 9 から Java 14の変化、JDKディストリビューション、Jakarta EE、MicroProfile、GreeVM、次世代フレームワークと幅広く網羅されています。 各章をそれぞれ詳しく書くととても一冊の本ではすまない範囲の為、章によってはあまり深く記載されていませんが、今のJavaを網羅的に知るにはとても良い本ではないでしょうか。
著者の方々
そして、なんと言っても各章を書かれている著者の方々です。各章とも日本のJavaコミュニティで、その章を代表する方々が書かれているため正しい情報へのアクセスが出来ます。本書に書かれているように大変革期には、このように正しい情報を知る事はとても大切だと思います。
個人的に興味があった箇所
第一章のJava SEに関する内容で、LTS版のJava 11以降あまり追えていなかったのでとてもワクワクする内容でした。次のLTS版であるJava 17が出るまで待つと、とても追いつけなくなるなと思いました。また、第二章のJDKディストリビューションの章では、リリースサイクル変更に伴いどの様な変更が起きたか現在どの様なJDKが存在するのかなど詳しく書かれています。なかなかここまで詳しく書かれているものは無く改めて勉強になりました。リリースサイクル変更の話をコミュニティなどでの勉強会に参加し聞いた事が無い方などには是非読んできただきたい内容です。
そう言う私もまだ修行が足りないせいか、話の中でOpenJDKとOracle OpenJDKが混在してくると時折「んんん?」ってなります(汗)
さいごに
本書を送って頂いた@kencharos さん、著者の方々大変ありがとう御座います。
Javaエンジニアの方々は是非手に取ってお読み下さい!
MicroProfile OpenTracing with Helidon
はじめに
この記事は、UL Systems Advent Calendar 2019 - 18日目の転記です。
本日は、HelidonのTracing MP Guideを参考にEclipse MicroProfile OpenTracingとJaegerで分散トレーシングの可視化を試したいと思います。(Tracing MP GuideではZipkinを使用していますが今回はロゴの可愛さからJaegerを使用してみたいと思います。)
最近では、マイクロサービスアーキテクチャでシステム全体を構成する事も多くなってきました。その際、従来のモノリシックなシステムで使用していたメソッドトレースなどでは複数のサービスを跨がる処理に障害や遅延が起きた場合、どこで発生しているのか判別が難しい為、全体を通してトレースする分散トレーシングと呼ばれる仕組みが必要になります。
Helidon とは
2018年9月にOracle社より発表された、Java向けのマイクロサービス作成に活用できる軽量マイクロサービス開発のためのオープンソースベースのフレームワークです。
Helidonの特徴
今回は MaicroProfile をサポートしているHelidon MPをしようします。
Eclipse MicroProfile とは
Eclipse MicroProfileは、マイクロサービス向け Enterprise Java API の仕様です。現在では、JAX-RS、JSON-P、CDIなどのAPIが含まれており、構成、メトリック、フォールトトレランスなどのAPIも存在します。 今回使用する Helidon MP は Eclipse MicroProfile 3.2 をサポートしています。
OpenTracing とは
OpenTracingは、API仕様とそれを実装したフレームワークとライブラリ、およびプロジェクトのドキュメントで構成されています。 OpenTracingを使用すると、開発者は特定の製品やベンダーにロックされていないAPIを使用してアプリケーションコードにトレーシング機能を追加できます。
Jaeger とは
DapperとOpenZipkinにインスパイヤーされUber TechnologiesによってリリースされたオープンソースのOpenTracing互換の分散トレースシステムです。 今回は下記の図の OpenTracing API 部分が MicroProfile OpenTracing になりjaeger-client の部分が Helidon から提供されているJaeger用のClientになります。
Jaeger のセットアップ
それではまず、Client以外の部分に関してセットアップを行いたいと思います。セットアップに関してはAll-in-oneのDockerイメージが公開されているのでそれを使用します。 (必要最低限のポートのみを指定して起動しています)
$ docker run -d --name jaeger \ -p 5778:5778 \ -p 16686:16686 \ -p 14268:14268 \ jaegertracing/all-in-one:1.15
各ポートの説明
Helidon の jaeger-client はデフォルトではHTTPの5778
を使用しているようです。
(これは設定により変更可能です)
Main サービス の作成
次にメインとなるサービスプロジェクトを Helidon MP Maven archetype の quickstart を使用して作成します。
$ mvn archetype:generate -DinteractiveMode=false \ -DarchetypeGroupId=io.helidon.archetypes \ -DarchetypeArtifactId=helidon-quickstart-mp \ -DarchetypeVersion=1.4.0 \ -DgroupId=io.helidon.main_service \ -DartifactId=main_service \ -Dpackage=io.helidon.main_service
Jaeger Clientを追加
pom.xml に以下の依存関係を追加し Jaeger Client を追加します。
<dependency> <groupId>io.helidon.tracing</groupId> <artifactId>helidon-tracing-jaeger</artifactId> </dependency>
トレーシング サービス名の追加
Helidon から Jaeger に送信されるトレーシングデータへ紐付ける為のサービス名をMETA-INF/microprofile-config.properties
へ指定します。
tracing.service=helidon-main-service
Main サービス を動かして見る
では、ここまで作成した結果を見てみたいと思います。quickstart で作成したプロジェクトは、io.helidon.main_service.Main
クラスのMain
メソッドを実行する事でサーバが起動するように作成されています。
サーバ起動後にhttp://localhost:8080/greet
へアクセスすると quickstart で作成されたサービスが実行され{"message":"Hello World!"}
が表示されるはずです。
$ curl http://localhost:8080/greet {"message":"Hello World!"}
数回アクセスした後に jaeger UI(http://localhost:16686/search
) からトレーシングデータを見ると以下のようなトレーシングデータが可視化されるはずです。
さらに各トレースをクリックすると、スパンがリストされているトレース詳細ページが表示されます。 ルートスパンと、トレース内のすべてのスパン間の関係、およびタイミング情報を明確に確認できます。
各スパン行をクリックすると、スパンの詳細も確認できます。
クラスやメソッドレベルでのトレーシング
さらに、MicroProfile OpenTracingで提供されている@Traced
アノテーションを付与することでクラスやメソッドレベルでのトレーシングも可能です。
@Traced @ApplicationScoped public class GreetingProvider { ... }
サービス間を跨いだトレーシング
次にサービス間を跨いだトレーシングを行って見たいと思います。
Second サービスの作成
Main サービスと同様に Helidon MP Maven archetype の quickstart を使用して作成し、Jaeger Client を追加します。
$ mvn archetype:generate -DinteractiveMode=false \ -DarchetypeGroupId=io.helidon.archetypes \ -DarchetypeArtifactId=helidon-quickstart-mp \ -DarchetypeVersion=1.4.0 \ -DgroupId=io.helidon.second_service \ -DartifactId=second_service \ -Dpackage=io.helidon.second_service
<dependency> <groupId>io.helidon.tracing</groupId> <artifactId>helidon-tracing-jaeger</artifactId> </dependency>
設定変更
- greetingで返される文字をSecond サービスからの返却だと識別しやすいように
Hello From Second Service
と変更します。 - 起動ポートを
8081
へ変更します。
# Application properties. This is the default greeting app.greeting=Hello From Second Service # Microprofile server properties server.port=8081
動作確認
Second サービスを実行し動作確認をすると以下のような結果になります。
$ curl http://localhost:8081/greet {"message":"Hello From Second Service World!"}
Main サービスからSecond サービスの呼出
最初に作成したMain サービスからSecond サービスを呼び出すように変更します。
まずは、Second サービスを呼び出すためのRest ClientをMain サービスに作成する必要があります。
Tracing MP Guide とは違いここではMicroProfile Rest Client
を使用して作成してみたいと思います。
package io.helidon.main_service; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import javax.json.JsonObject; import javax.ws.rs.GET; import javax.ws.rs.Path; @RegisterRestClient(baseUri="http://localhost:8081") public interface SecondServiceClient { @Path("/greet") @GET JsonObject getDefaultMessage(); }
そして、作成したClientを使用しMain サービスからSecond サービスの呼出を行います。 作成したSecondServiceClientを呼出クラスへインジェクションし元々レスポンスを返していたメソッドへ SecondServiceClient を使用しSecond サービスの呼出を行います。
@Path("/greet") @RequestScoped public class GreetResource { @Inject @RestClient SecondServiceClient secondServiceClient; @GET @Produces(MediaType.APPLICATION_JSON) public JsonObject getDefaultMessage() { // return createResponse("World"); return secondServiceClient.getDefaultMessage(); }
動作確認
ポート8080
のMain サービスを実行しSecond サービスが呼ばれていることを確認します。
$ curl http://localhost:8080/greet {"message":"Hello From Second Service World!"}
分散トレーシングデータの確認
今回の処理は、コンソールからのリクエストがMain サービスを回しSecond サービスを呼び出すと言う構成になっています。
このリクエストのトレーシングデータをJaeger UIで見てみると全体でそれだけの処理時間がかかり、処理の流れが確認できます。
まとめ
今回はEclipse MicroProfile OpenTracingを使用し分散トレーシングを試してみました。 分散システムを採用した場合に分散トレーシングの可視化が出来ている状態であれば、仮にシステムに障害や遅延が発生しても把握しやすいのではないでしょうか。
また、Eclipse MicroProfile には OpenTracing の他にもEclipse MicroProfile MetricsやEclipse MicroProfile Fault Toleranceなどの仕様が有り Helidon もそれらをサポートしています。現在JavaEEを使用しているアプリケーションからの移行など Spring Framework 以外の選択として検討する事が出来るのではないでしょうか。
Mavenを利用してWebLogic12cへデプロイしてみた
今さらですが、Mavenを利用してWeblogic12cへデプロイする為のMavenの設定方法などのメモです
weblogic-maven-pluginプラグインのインストール
ローカルにWebLogicをインストールしている場合
1. Oracle Maven同期プラグインのインストール
ディレクトリを${ORACLE_HOME}\oracle_common\plugins\maven\com\oracle\maven\oracle-maven-sync\12.2.1
へ移動し下記を実行します。
(WebLogic Server 12.2.1 の場合)
$ mvn install:install-file -DpomFile=oracle-maven-sync-12.2.1.pom -Dfile=oracle-maven-sync-12.2.1.jar
2. ローカルにあるモジュールをローカルレポジトリに取り込む
$ mvn com.oracle.maven:oracle-maven-sync:push -DoracleHome=${ORACLE_HOME}
ローカルにWebLogicをインストールしていない場合
ローカルにWeblogicをインストール指定に場合は、Oracle Mavenリポジトリから必要なモジュールを取得する。
1. Oracle OTN Account の作成
Oracle Mavenリポジトリにアクセスするのに必要ですので事前に作成します。 www.oracle.com
2. 必要な接続情報等の設定を行う
リポジトリ、プラグインリポジトリの設定をpom.xmlもしくはsetting.xmlへ、認証情報をsetting.xmlへ下記の様に記述します。
<repositories> <repository> <id>maven.oracle.com</id> <releases> <enabled>true</enabled> </releases> <snapshots> <enabled>false</enabled> </snapshots> <url>https://maven.oracle.com</url> <layout>default</layout> </repository> </repositories> <pluginRepositories> <pluginRepository> <id>maven.oracle.com</id> <url>https://maven.oracle.com</url> </pluginRepository> </pluginRepositories>
setting.xml
<servers> <server> <id>maven.oracle.com</id> <username>xxxxxxxxxxxxxxxxxxxxxxxx</username> <password>xxxxxxxxxxxxxxxxxxxxxxxx</password> <configuration> <basicAuthScope> <host>ANY</host> <port>ANY</port> <realm>OAM 11g</realm> </basicAuthScope> <httpConfiguration> <all> <params> <property> <name>http.protocol.allow-circular-redirects</name> <value>%b,true</value> </property> </params> </all> </httpConfiguration> </configuration> </server> </servers>
usernameやpasswordはmavenの機能でmvn --encrypt-master-password
、mvn --encrypt-password
を利用して暗号化できます。
WebLogic プラグインの追加
下記の様にpom.xmlへ記述しWebLogic Pluginの追加をします。
<build> <plugins> <plugin> <!-- This is the configuration for the weblogic-maven-plugin --> <groupId>com.oracle.weblogic</groupId> <artifactId>weblogic-maven-plugin</artifactId> <version>12.2.1-0-0</version> <configuration> <middlewareHome>/fmwhome/wls12210</middlewareHome> </configuration> <executions> <!-- Execute the appc goal during the package phase --> <execution> <id>wls-appc</id> <phase>package</phase> <goals> <goal>appc</goal> </goals> <configuration> <source>${project.build.directory}/${project.name}.${project.packaging}</source> </configuration> </execution> <!-- Deploy the application to the WebLogic Server in the pre-integration-test phase --> <execution> <id>wls-deploy</id> <phase>pre-integration-test</phase> <goals> <goal>deploy</goal> </goals> <configuration> <!--The admin URL where the app is deployed. Here use the plugin's default value t3://localhost:7001--> <adminurl>t3://127.0.0.1:7001</adminurl> <user>weblogic</user> <password>password</password> <!--The location of the file or directory to be deployed--> <source>${project.build.directory}/${project.build.finalName}.${project.packaging}</source> <!--The target servers where the application is deployed. Here use the plugin's default value AdminServer--> <targets>AdminServer</targets> <verbose>true</verbose> <name>${project.build.finalName}</name> </configuration> </execution> <!-- Stop the application in the pre-integration-test phase --> <execution> <id>wls-stop-app</id> <phase>pre-integration-test</phase> <goals> <goal>stop-app</goal> </goals> <configuration> <adminurl>t3://127.0.0.1:7001</adminurl> <user>weblogic</user> <password>password</password> <name>${project.build.finalName}</name> </configuration> </execution> </executions> </plugin> </plugins> </build>
WebLogic12cへデプロイ
下記のコマンドでデプロイします。
mvn com.oracle.weblogic:weblogic-maven-plugin:deploy
その他にもコマンドがあります。 docs.oracle.com
Oracle Mavenリポジトリを使用する場合は、初回実行時はWebLogicのモジュール情報を取得するので時間がかかります。
また2回目移行もモジュール情報のチェック?を行うので時間がかかります。
その場合は、-o
を指定してオフラインモードで実行します。
参考サイト
EnvoyとJaegerを使用した分散トレーシングを試してみた
Envoyとは
ライドシェアサービスのLyftが開発し現在はCNCFでホストされている大規模サービス指向アーキテクチャ用L4/L7プロキシです。 また、Kubernetesを使用した場合にサービスメッシュをマネージメントために使用されるIstioのコアコンポーネントの一つです。
ただ、Envoy自体は単なるプロキシサーバなのでKubernetesを使わなくても使用する事は可能です。 今回はEnvoyを使用して分散トレーシングデータの収集を行いJaegerを使用して可視化して見たいと思います。
サンドボックス[Jaeger Tracing]
Envoyの公式サイトにサンドボックスが用意されているのでその中の'Jaeger Tracing'を試してみました。
Jaeger Tracingを立ち上げる
提供されているdocker-composeを使用し立ち上げると下記の様に4つのコンテナが立ち上がります。
$ docker-compose up -d Creating network "jaeger-tracing_envoymesh" with the default driver Creating jaeger-tracing_service1_1 ... done Creating jaeger-tracing_front-envoy_1 ... done Creating jaeger-tracing_service2_1 ... done Creating jaeger-tracing_jaeger_1 ... done $ docker-compose ps Name Command State Ports -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- jaeger-tracing_front-envoy_1 /docker-entrypoint.sh /bin ... Up 10000/tcp, 0.0.0.0:8000->80/tcp, 0.0.0.0:8001->8001/tcp jaeger-tracing_jaeger_1 /go/bin/all-in-one-linux - ... Up 14250/tcp, 14268/tcp, 0.0.0.0:16686->16686/tcp, 5775/udp, 5778/tcp, 6831/udp, 6832/udp, 0.0.0.0:9411->9411/tcp jaeger-tracing_service1_1 /bin/sh -c /usr/local/bin/ ... Up 10000/tcp, 80/tcp jaeger-tracing_service2_1 /bin/sh -c /usr/local/bin/ ... Up 10000/tcp, 80/tcp
されにそれぞれどのようプロセルが立ち上がっているか見てみます。
- Front Envoyコンテナ:フロントに立ってルーティングを行うEnvoyのコンテナです
$ docker exec -it jaeger-tracing_front-envoy_1 bash root@63dc868edcda:/# ps auxwwf USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 47 1.5 0.1 18232 3316 pts/0 Ss 16:58 0:00 bash root 57 0.0 0.1 34420 2904 pts/0 R+ 16:58 0:00 \_ ps auxwwf root 1 0.0 0.0 4500 696 ? Ss 16:22 0:00 /bin/sh -c /usr/local/bin/envoy -c /etc/front-envoy.yaml --service-cluster front-proxy root 7 0.3 1.0 116736 22152 ? Sl 16:22 0:07 /usr/local/bin/envoy -c /etc/front-envoy.yaml --service-cluster front-proxy
- Serviceコンテナ:提供するRest APIサービスとEnvoyが同梱されたコンテナです
$ docker exec -it jaeger-tracing_service1_1 bash bash-4.4# ps auxwwf PID USER TIME COMMAND 1 root 0:00 {start_service.s} /bin/sh /usr/local/bin/start_service.sh 7 root 0:00 python3 /code/service.py 8 root 0:07 envoy -c /etc/service-envoy.yaml --service-cluster service1 19 root 0:29 /usr/bin/python3 /code/service.py 23 root 0:00 bash 30 root 0:00 ps auxwwf
- Jaegerコンテナ:Jaegerに必要なコンポートが一式入ったAll in Oneのコンテナです。今回はUI(port:16686)とcollector(port:9411)を使用しています
Front EnvoyコンテナとServiceコンテナは下記の図の様な構成になっており、各ServiceのEnvoyからJaegerコンテナのcollectorへトレーシングデータの送信がされます。
- front-envoyの設定ファイル(front-envoy-jaeger.yaml)の概要
0.0.0.0:80
を listenして全てのリクエストをclusterで定義されているservice1へルーティングする
static_resources: listeners: - address: # 0.0.0.0:80でlistenする socket_address: address: 0.0.0.0 port_value: 80 filter_chains: - filters: - name: envoy.http_connection_manager config: tracing: operation_name: egress codec_type: auto stat_prefix: ingress_http route_config: name: local_route virtual_hosts: # 全てのリクエストをservice1と宣言しているclusterへルーティングする - name: backend domains: - "*" routes: - match: prefix: "/" route: cluster: service1 decorator: operation: checkAvailability http_filters: - name: envoy.router config: {} clusters: # service1のcluster、service1のポート80へ - name: service1 connect_timeout: 0.250s type: strict_dns lb_policy: round_robin http2_protocol_options: {} hosts: - socket_address: address: service1 port_value: 80 - name: jaeger connect_timeout: 1s type: strict_dns lb_policy: round_robin hosts: - socket_address: address: jaeger port_value: 9411 tracing: http: name: envoy.zipkin config: collector_cluster: jaeger collector_endpoint: "/api/v1/spans" shared_span_context: false admin: access_log_path: "/dev/null" address: socket_address: address: 0.0.0.0 port_value: 8001
- service1-envoyの設定ファイル(service1-envoy-jaeger.yaml)の概要
static_resources: listeners: - address: socket_address: address: 0.0.0.0 port_value: 80 filter_chains: - filters: - name: envoy.http_connection_manager config: tracing: operation_name: ingress codec_type: auto stat_prefix: ingress_http route_config: name: service1_route virtual_hosts: - name: service1 domains: - "*" routes: - match: prefix: "/" route: cluster: local_service decorator: operation: checkAvailability http_filters: - name: envoy.router config: {} - address: socket_address: address: 0.0.0.0 port_value: 9000 filter_chains: - filters: - name: envoy.http_connection_manager config: tracing: operation_name: egress codec_type: auto stat_prefix: egress_http route_config: name: service2_route virtual_hosts: - name: service2 domains: - "*" routes: - match: prefix: "/trace/2" route: cluster: service2 decorator: operation: checkStock http_filters: - name: envoy.router config: {} clusters: - name: local_service connect_timeout: 0.250s type: strict_dns lb_policy: round_robin hosts: - socket_address: address: 127.0.0.1 port_value: 8080 - name: service2 connect_timeout: 0.250s type: strict_dns lb_policy: round_robin http2_protocol_options: {} hosts: - socket_address: address: service2 port_value: 80 - name: jaeger connect_timeout: 1s type: strict_dns lb_policy: round_robin hosts: - socket_address: address: jaeger port_value: 9411 tracing: http: name: envoy.zipkin config: collector_cluster: jaeger collector_endpoint: "/api/v1/spans" shared_span_context: false admin: access_log_path: "/dev/null" address: socket_address: address: 0.0.0.0 port_value: 8001
Service1へのリクエストを行う
$ curl -v localhost:8000/trace/1 * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8000 (#0) > GET /trace/1 HTTP/1.1 > Host: localhost:8000 > User-Agent: curl/7.54.0 > Accept: */* > < HTTP/1.1 200 OK < content-type: text/html; charset=utf-8 < content-length: 89 < server: envoy < date: Thu, 27 Dec 2018 17:50:09 GMT < x-envoy-upstream-service-time: 62 < Hello from behind Envoy (service 1)! hostname: 74aee62abcce resolvedhostname: 172.22.0.2 * Connection #0 to host localhost left intact
リクエストのシーケンス
Jaeger UI でトレーシング結果を見る
[おまけ]ServiceをSpring Bootで作成してみる
トレースしたいアプリケーションを作成する際の注意点として、Envoyが生成したトレースヘッダーを独自に伝播させる実装を行う必要があります。 サンドボックスでは、service1として機能する単純なアプリケーション(service.py)が、service2への呼び出しを行う際にトレースヘッダーを伝播を実装しています。
@RestController @RequestMapping("/trace/1") public class MainController { private static final String[] TRACING_HEADERS = { "X-Ot-Span-Context", "X-Request-Id", // Zipkin headers "X-B3-TraceId", "X-B3-SpanId", "X-B3-ParentSpanId", "X-B3-Sampled", "X-B3-Flags", // Jaeger header (for native client) "uber-trace-id"}; @Autowired private RestTemplate restTemplate; @RequestMapping(method = RequestMethod.GET) public String main(HttpServletRequest request) { HttpEntity entity = new HttpEntity(tracingHeaders(request)); ResponseEntity<String> response = restTemplate.exchange( "http://localhost:9000/trace/2", HttpMethod.GET, entity, String.class); return response.getBody(); } private HttpHeaders tracingHeaders(HttpServletRequest request) { final HttpHeaders headers = new HttpHeaders(); Arrays.asList(TRACING_HEADERS).stream().forEach(s -> { String h = request.getHeader(s); if (StringUtils.isEmpty(h)) return; headers.set(s, h); }); return headers; } }
参考サイト
Spring BootでJaegerを使用してトレーシングしてみた
Spring BootでJaegerを使用してトレーシングしてみた
今回のサービスの構成
今回の構成はmainサービス、formatterサービス、publisherサービスの3つのサービスを作成します。
- formatterサービスは、クエリーパラメータで渡された文字列を"Hello, %s!"の形式に変換し返します。
- publisherサービスは、クエリーパラメータで渡された文字列をSystem.out.println(String)で標準出力します。
- mainサービスは、クエリーパラメータで渡された文字列をformatterサービスへ渡し変換します。そして、その変換後の文字列をpublisherサービスへ渡します。
formatterサービス
@RestController @RequestMapping("/formatter") public class FormatController { @RequestMapping(method = RequestMethod.GET) public String format(@RequestParam String str) { return String.format("Hello, %s!", str); } }
publisherサービス
@RestController @RequestMapping("/publisher") public class PublishController { @RequestMapping(method = RequestMethod.GET) public String publish(String str) { System.out.println(str); return "published:" + str; } }
mainサービス
@RestController @RequestMapping("/main") public class MainController { @Autowired private RestTemplate restTemplate; @RequestMapping(method = RequestMethod.GET) public String main(@RequestParam String str) { ResponseEntity<String> FormatResponse = restTemplate.getForEntity( "http://localhost:8081/formatter?str={str}", String.class, str); String formatString = FormatResponse.getBody(); ResponseEntity<String> publishResponse = restTemplate.getForEntity( "http://localhost:8082/publisher?str={str}", String.class, formatString); return publishResponse.getBody(); } }
Spring BootでJaegerを使用する方法
前回まではOpenTracingのチュートリアルに沿ってSpanコンテキストを伝播させる仕組みなどを独自に実装していきましたが、 今回のSpring Bootで作成されてたREST APIの場合は、その様にすればトレーニング出来るのかを見てみたいと思います。
まず、下記のページにOpenTracingのトレーサーの実装とその他のアドインが掲載されいますので目的に沿ったプロジェクトを探します。 github.com
今回はSpring BootでREST APIを作成するので、この中からTracerにjava-spring-jaeger - Spring starter for Jaeger
をOpenTracingの実装にjava-spring-web - Spring
を使用します。
java-spring-jaeger - Spring starter for Jaeger github.com
java-spring-web - Spring github.com
ライブラリの依存関係(pom.xml)
<!-- https://mvnrepository.com/artifact/io.opentracing.contrib/opentracing-spring-web-starter --> <dependency> <groupId>io.opentracing.contrib</groupId> <artifactId>opentracing-spring-web-starter</artifactId> <version>1.0.1</version> </dependency> <!-- https://mvnrepository.com/artifact/io.opentracing.contrib/opentracing-spring-jaeger-web-starter --> <dependency> <groupId>io.opentracing.contrib</groupId> <artifactId>opentracing-spring-jaeger-web-starter</artifactId> <version>1.0.1</version> </dependency>
行う事はこれだけです!
Spring BootでJaegerを使用したトレーシング結果
mainサービスへリクエストした際のトレーシング結果です。
こちらがトレースした結果を展開したもの
Spring BootでJaegerを使用してトレース情報を可視化は、ライブラリの追加だけで既存のソースコードに手を入れなくてもトレース情報を取得出来るのでとてもお手軽に出来ました。
OpenTracingチュートリアルをやってみた(その2)
OpenTracingチュートリアルをやったメモ
前回の「OpenTracingチュートリアルをやってみた(その1)」の続きです。
リモートプロセス呼出をトレーシングしてみる
前回のつづきLesson 3 - Tracing RPC Requestsを。
前回作成したformatString
メソッドとprintHello
メソッドの内容をFormatter
サービスとPublisher
サービスの2つのサービスに独立させトレーシングしてみます。
- 前回レッスンの
Hello.java
をベースに処理をHTTP呼出のサービスへ独立させ呼び出します。 Hello.java
ではHTTP Clientを使用し2つのサービスを呼び出します。
サービス間のコンテキスト伝播について
まず、リモートプロセス呼出のトレースを続けるためにはSpanコンテキストを伝播する必要があります。
OpenTracing APIはそれを実現する為にTracerインターフェースにinject(spanContext, format, carrier)
とextract(format, carrier)
の2つのメソッドを提供します。
引数のformat
のパラメータはOpenTracing APIが定義する3つの標準エンコーディングのうちの1つを指定します。
TEXT_MAP
:Spanコンテキストは文字列のKey:Valueのコレクションにされます。HTTP_HEADERS
:TEXT_MAP
に似ていますが、安全にHTTPヘッダーを使用します。BINARY
:Spanコンテキストはバイト配列にされます。
Client側の実装
tracer.inject
を利用してspanContext
をHTTPリクエストに乗せて伝播させます。下記の例ではHTTP_HEADERS
を使用します。
private String formatString(String helloTo) { try (Scope scope = tracer.buildSpan("formatString").startActive(true)) { String helloStr = getHttp(8081, "format", "helloTo", helloTo); scope.span().log(ImmutableMap.of("event", "string-format", "value", helloStr)); return helloStr; } } private void printHello(String helloStr) { try (Scope scope = tracer.buildSpan("printHello").startActive(true)) { getHttp(8082, "publish", "helloStr", helloStr); scope.span().log(ImmutableMap.of("event", "println")); } } private String getHttp(int port, String path, String param, String value) { try { HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path) .addQueryParameter(param, value).build(); Request.Builder requestBuilder = new Request.Builder().url(url); Tags.SPAN_KIND.set(tracer.activeSpan(), Tags.SPAN_KIND_CLIENT); Tags.HTTP_METHOD.set(tracer.activeSpan(), "GET"); Tags.HTTP_URL.set(tracer.activeSpan(), url.toString()); tracer.inject(tracer.activeSpan().context(), Format.Builtin.HTTP_HEADERS, new HttpHeadersCarrier(requestBuilder)); Request request = requestBuilder.build(); Response response = client.newCall(request).execute(); if (response.code() != 200) { throw new RuntimeException("Bad HTTP result: " + response); } return response.body().string(); } catch (IOException e) { throw new RuntimeException(e); } } private static class HttpHeadersCarrier implements TextMap { private final Request.Builder builder; HttpHeadersCarrier(Request.Builder builder) { this.builder = builder; } @Override public Iterator<Map.Entry<String, String>> iterator() { throw new UnsupportedOperationException("carrier is write-only"); } @Override public void put(String key, String value) { builder.addHeader(key, value); } }
パケットを確認するとuber-trace-id
というheaderで渡されています。
ちなみにTEXT_MAPで行った場合は下記の様になっています。
TraceId、SpanId、ParentIdがコロン区切りで繋げた文字列をHTTP_HEADERS
を指定した場合はURLエンコーディングした物になり、TEXT_MAP
の場合はそのままheaderに設定されるようです。
io.jaegertracing.internal.propagation.TextMapCodec#contextAsString(JaegerSpanContext context)
public static String contextAsString(JaegerSpanContext context) { int intFlag = context.getFlags() & 0xFF; return new StringBuilder() .append(context.getTraceId()).append(":") .append(Long.toHexString(context.getSpanId())).append(":") .append(Long.toHexString(context.getParentId())).append(":") .append(Integer.toHexString(intFlag)) .toString(); } private String encodedValue(String value) { if (!urlEncoding) { return value; } try { return URLEncoder.encode(value, "UTF-8"); } catch (UnsupportedEncodingException e) { // not much we can do, try raw value return value; } }
Service側の実装
チュートリアルではサービスはDropwizardを使用してサービスを実装しています。
まず、各サービスではHello.java
と同様にTracerインスタンを生成します。
各サービスではClient側と同じようにbuildSpan()
を使用してScope
オブジェクトを生成しますが、
まず標準アダプタのTextMapExtractAdapterを使用してリクエストのheaderから伝播されたspanContext
情報をHashMap <String、String>に変換し
extract(format, carrier)
を使用してspanContext
を取得します。
header情報からspanContext
が生成された場合は、その子SpanとしてScope
オブジェクトを生成します。
public class Formatter extends Application<Configuration> { private final Tracer tracer; private Formatter(Tracer tracer) { this.tracer = tracer; } public static void main(String[] args) throws Exception { System.setProperty("dw.server.applicationConnectors[0].port", "8081"); System.setProperty("dw.server.adminConnectors[0].port", "9081"); io.jaegertracing.Configuration.SamplerConfiguration samplerConfiguration = io.jaegertracing.Configuration.SamplerConfiguration.fromEnv().withType("const").withParam(1); io.jaegertracing.Configuration.ReporterConfiguration reporterConfiguration = io.jaegertracing.Configuration.ReporterConfiguration.fromEnv().withLogSpans(true); io.jaegertracing.Configuration configuration = new io.jaegertracing.Configuration("formatter").withSampler(samplerConfiguration).withReporter(reporterConfiguration); Tracer tracer = configuration.getTracer(); new Formatter(tracer).run("server"); } @Override public void run(Configuration configuration, Environment environment) throws Exception { environment.jersey().register(new FormatterResource()); } @Path("/format") @Produces(MediaType.TEXT_PLAIN) public class FormatterResource { @GET public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) { try (Scope scope = startServerSpan(tracer, httpHeaders, "format")) { return String.format("Hello, %s!", helloTo); } } } protected static Scope startServerSpan(Tracer tracer, HttpHeaders httpHeaders, String operationName) { MultivaluedMap<String, String> rawHeaders = httpHeaders.getRequestHeaders(); final HashMap<String, String> headers = new HashMap<String, String>(); for (String key : rawHeaders.keySet()) { headers.put(key, rawHeaders.get(key).get(0)); } Tracer.SpanBuilder spanBuilder; try { SpanContext parentSpan = tracer.extract(Format.Builtin.HTTP_HEADERS, new TextMapExtractAdapter(headers)); if (parentSpan == null) { spanBuilder = tracer.buildSpan(operationName); } else { spanBuilder = tracer.buildSpan(operationName).asChildOf(parentSpan); } } catch (IllegalArgumentException e) { spanBuilder = tracer.buildSpan(operationName); } return spanBuilder.withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER).startActive(true); } }
Publisher
サービスの方も同様に実装します。違いはサービスのポートと実際の処理部分だけです。
トレース結果
Client側のmainメソッドを実行じ2つのサービスを呼出たトレース結果です。
トレーシングコンテキスト以外の伝播
OpenTracingを使用することトレーシングコンテキスト以外のものも伝播出来る様にすることが出来ます。 このトレーシングコンテキスト以外で伝播させたい情報を手荷物と同じように全てのリモートプロセスで持ち回れる事からBaggageと呼びます。
BaggageはScope.span().setBaggageItem(String key, Syring vlue)
で設定します。取得はScope.span().getBaggageItem(String key)
で行います。
Client側の実装
private void sayHello(String helloTo) { try (Scope scope = tracer.buildSpan("say-hello").startActive(true)) { scope.span().setTag("hello-to", helloTo); scope.span().setBaggageItem("item1","this is item1"); String helloStr = formatString(helloTo); printHello(helloStr); } }
設定したBaggageはhaederに追加されています。
Service側の実装
Formatter
サービス
@Path("/format") @Produces(MediaType.TEXT_PLAIN) public class FormatterResource { @GET public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) { try (Scope scope = startServerSpan(tracer, httpHeaders, "format")) { String item1 = scope.span().getBaggageItem("item1"); scope.span().setTag("item1", item1); return String.format("Hello, %s!", helloTo); } } }
Publisher
サービス
@Path("/publish") @Produces(MediaType.TEXT_PLAIN) public class PublisherResource { @GET public String publish(@QueryParam("helloStr") String helloStr, @Context HttpHeaders httpHeaders) { try (Scope scope = startServerSpan(tracer, httpHeaders, "publish")) { String item1 = scope.span().getBaggageItem("item1"); scope.span().setTag("item1", item1); System.out.println(helloStr); return "published"; } } }
設定したBaggageはサービスを跨いでも伝播されています。