BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Javaに革命を起こすGraalVM Native Image

Javaに革命を起こすGraalVM Native Image

Lire ce contenu en français

ブックマーク

キーポイント

  • GraalVM Native Image is an ahead-of-time compilation technology that generates native platform executables.
  • Native executables are ideal for containers and cloud deployments as they are small, start very fast, and require significantly less CPU and memory.
  • Deploy native executables on distroless and even Scratch container images for reduced size and improved security.
  • With profile-guided optimization and the G1 garbage collector, native executables built with GraalVM Native Image can achieve peak throughput on par with the JVM.
  • GraalVM Native Image enjoys significant adoption with support from leading Java frameworks such as Spring Boot, Micronaut, Quarkus, Gluon Substrate, etc.

原文(投稿日:2022/04/14)へのリンク

Javaはエンタープライズアプリケーションの主流です。しかしクラウドにおいては、一部の競合技術よりも高価な選択となっています。ネイティブコンパイルは、クラウドにおけるJavaを安価な存在にします -起動が非常に速く、メモリ使用量の少ないアプリケーションを実現するのです。

そのため、ネイティブコンパイルは、すべてのJavaユーザに対して多くの疑問を投げかけます。ネイティブJavaは開発をどう変えるのか?ネイティブJavaに切り替えるべきなのはいつか?切り替えない方がよいのは、どのような場合か?どのフレームワークを使用すればよいのか?このシリーズでは、これらの疑問に答えていきたいと思います。

このInfoQ記事は"Native Compilations Boot Java"シリーズの一部です。シリーズに関しては、RSSによる通知の登録が可能です。

3年前のローンチ以来、GraalVMは、Java開発に革命を起こしてきました。その特徴として最も論じられているのが、AOT(ahead-of-time)コンパイラを基盤とするNative Imageです・Native Imageは、これまでのJavaエコシステムが持っていた開発者生産性とツーリングを維持しながら、ネイティブアプリケーションの実行時パフォーマンスプロファイルを実現します。

従来のJavaアプリケーションの実行方法

Javaプラットフォームの最も強力かつ興味深い部分のひとつとして、その素晴らしいピークパフォーマンスを実現しているものは、Java仮想マシン(JVM)によるコードの実行方法です。

アプリケーションが初めて実行されると、VMはそのコードを解釈して、プロファイリング情報を収集します。JVMインタプリタのパフォーマンスは高いものの、コンパイルされたコードの実行ほど速くはありません。そのため、OracleのJVM(HotSpot)には、ジャスト・イン・タイム(JIT)コンパイラも搭載されています。プログラムの実行と合わせて、アプリケーションコードをマシンコードにコンパイルするのです。これにより、コードが"ウォームアップ" — 何度か実行されると、C1 JITコンパイラによるマシンコードへのコンパイルが行われます。さらに実行が繰り返されて、一定のしきい値に到達すると、トップティア(top-tier)JITコンパイラ(C2あるいはGraalコンパイラ)によるコンパイルが実行されます。トップティアコンパイラは、どのコードブランチが最も頻繁に実行されるか、ループが何度実行されるか、どの型がポリモルフィックコードで使用されるか、といったプロファイル情報に基づいた最適化を行います。

場合によっては投機的最適化(speculative optimization)も実行します。これによって、例えば、収集したプロファイル情報に基づいて最適化された、コンパイル済バージョンのメソッドを生成することが可能になるのです。ただし、JVMのコード実行は動的であるため、後にその仮定が無効になった場合、JVMは脱最適化(deoptimize)、すなわちコンパイル済コードを破棄してインタプリタモードに戻ります。この柔軟性こそが、JVMを強力なものにしているのです — コードを速く起動する一方で、繰り返し実行されるコードには最適化コンパイラを活用し、さらにアグレッシブな最適化の適用も検討するのです。

一見すると、このアプローチは、アプリケーションを実行する理想的な方法のように思えます。しかしながら、世の常として、このアプローチにもコストやトレードオフは存在します — では、それは何なのでしょうか?まず、JVMが行うオペレーション(コードの検証、クラスのロード、動的コンパイル、プロファイル情報の収集など)は、複雑な計算を伴うことから、実行には相応のCPU時間を必要とします。そのコストに加えて、プロファイル情報を格納するために相当量のメモリが必要になります。起動時に要する時間やメモリも、それに応じたものになるのです。多くの企業がアプリケーションをクラウドに展開しているため、このコストはさらに重要なものになります。起動時間やメモリの使用量は、アプリケーションの展開コストに直接的に関わってくるからです。それでは、起動時間やメモリ使用量を抑制しながら、Javaの持つ生産性やライブラリやツーリングといったメリットを引き続き享受できる方法はないのでしょうか?

答は"イエス"です。それを実現するのがGraalVM Native Imageなのです。

GraalVMで決まり!

GraalVMは10年前、Oracle Labsの研究プロジェクトとして始まりました。Oracle LabsはOracleのR&D部門で、プログラミング言語や仮想マシン、マシンラーニング、セキュリテイ、グラフ処理、その他の領域に取り組んでいます。その活動を示す好例のひとつがGraalVMです。GraalVMは、長年にわたる研究と、100を数える発表論文に基いています。

プロジェクトの中核をなすのは、Graalコンパイラです。高度な最適化機能を備えた、最新のこのコンパイラは、スクラッチから開発されました。その高度な最適化により、多くのシナリオにおいて、C2コンパイラよりも優れたコードを生成することが可能です。そのような最適化のひとつが部分的エスケープ分析(partial escape analysis)です。これは、オブジェクトがコンパイル単位をエスケープしないブランチにおいて、スカラ置換によってヒープ上の不要なオブジェクト割り当てを取り除くものです。エスケープを行うオブジェクトに関しては、それがヒープ内に存在することをGraalコンパイラが保証します。

このアプローチは、ヒープ上に存在するオブジェクトの数を少なくすることによって、メモリフットプリントを削減します。さらに、ガベージコレクションの必要性も低くなることから、CPUの負荷も低減することができます。また、GraalVMの高度な推測機能は、動的な実行時フィードバックを活用して、より高速なマシンコードを生成します。プログラムの特定部分が実行されないと推測することによって、GraalVMコンパイラは、コードをさらに効率的なものにすることができるのです。

このGraalコンパイラの大部分がJavaで記述されていると知れば、驚くかも知れません。GitHubリポジトリにあるGraalVMのコアを見ると、そこにあるコードの90パーセント以上がJavaプログラミング言語で記述されていることが分かります。Javaの強力さ、多用途性を改めて認識することになるでしょう。

ネイティブイメージの動作

GraalコンパイラはAOT(ahead-of-time)コンパイラとしても機能して、ネイティブな実行ファイルを生成します。動的な性質を持つJavaにおいて、実際にはどのように動作するのでしょうか?

コンパイルと実行が同時に行われるJITモードとは異なり、AOTモードでは実行前のビルド時に、すべてのコンパイルが行われます。ここでの発想の中心は、"重労働" — 高価な計算処理 — をすべてビルド時に移動するというものです。一度コンパイルしておけば、実行時には生成済の実行ファイルが高速に起動します。すべてが事前に計算され、コンパイルされることで、実行準備が最初から整っているのです。

GraalVMの ‘native-image‘ ユーティリティは、Javaバイトコードの入力からネイティブな実行ファイルを出力します。これを実現するために、このユーティリティでは、閉世界仮説(closed world assumption)に基づいたバイトコードの静的解析を行っています。この解析では、アプリケーションが実際に使用するコードをすべて探索して、不要なものはすべて削除します。

次の3つの重要な概念に注目すれば、Native Imageの生成プロセスをよりよく理解できるでしょう。

  • ポインタ解析(points-to analysis)。GraalVM Native Imageは、実行時に到達可能なクラス、メソッド、フィールドを判断して、それらのみをネイティブ実行イメージに含めるようにします。ポインタ解析はエントリポイント — 通常はアプリケーションのmainメソッドを始点として、定点に達して解析が終了するまで、遷移的に到達可能なすべてのコードパスを反復的に処理します。アプリケーションだけでなく、ライブラリやJDKクラスも対象です。これによって、アプリケーションのパッケージングに必要なすべてのものが、自己完結型のバイナリに格納されるのです。
  • ビルド時の初期化GraalVM Native Imageは、正しい動作を保証するために、デフォルトでは実行時にクラスの初期化を行っていますが、安全であることが証明可能なクラスに関しては、ビルド時に初期化を行います。これによって、実行時の初期化とチェックが不要になり、パフォーマンスが向上します。
  • ヒープスナップショットNative Imageのヒープスナップショットは、その話題だけでひとつの記事が書けるような、非常に興味深い機能です。イメージの構築プロセス中、静的イニシャライザによってアロケートされるJavaオブジェクトと、到達可能なオブジェクトは、すべてイメージヒープ上に書き込まれます。ですから、事前にヒープを設定しておけば、アプリケーションの起動は大幅に高速化されるのです。

興味深いのは、ポインタ解析によってイメージヒープ内のオブジェクトが到達可能になり、イメージヒープを構築するスナップショット処理によって新たなメソッドがポインタ解析で到達可能になるということです。すなわち、ポインタ解析とヒープスナップショットは、定点に到達するまで反復的に実行されるのです。

ネイティブイメージ構築プロセス

解析が完了すると、到達可能なすべてのコードが、プラットフォーム依存のネイティブな実行形式にコンパイルされます。この実行形式はそれ自体で完全に機能するもので、実行時にJVMを必要としません。結果として、機能的には完全に同じでありながら、必要なコードと依存関係のみを含んだ、スリムで高速なネイティブ実行バージョンのJavaアプリケーションが完成します。

ところで、メモリ管理やスレッドスケジュールといった機能は、ネイティブ実行形式では誰が処理するのでしょうか?実は、Native Imageには"Substrate VM" — ガベージコレクタやスレッドスケジューラといった実行時コンポーネントを提供するスリムなVM実装が含まれているのです。Graalコンパイラと同様に、Substrate VMもJavaプログラミング言語で記述されています。従って、GraalVM Native ImageによってネイティブコードにAOTコンパイルされるのです!

AOTコンパイルとヒープスナップショットによって、Native Imageは、あなたのJavaアプリケーションにまったく新しいパフォーマンスプロファイルを実現してくれます。次に、これを詳しく見ていきましょう。

Javaの起動パフォーマンスを次のレベルに

Native Imageで生成された実行ファイルは起動パフォーマンスが優れている、という話を聞いたことがあるかも知れません。具体的にはどういうことなのでしょう?

短時間で起動。コードがまず検証され、解釈され、それから(ウォームアップ後に)最終的にコンパイルされる、というJVM上での実行とは違い、ネイティブ実行形式は最初から最適化されたマシンコードになっています。これを説明する上で私がよく使うのは、"インスタントパフォーマンス"という用語です。つまり、プロファイリングやコンパイルといったオーバヘッドを伴わずに、実行の数ミリ秒後からアプリケーションが有意な処理を行うことができるようになる、という意味です。

JIT AOT
  • オペレーティングシステムがJVM実行ファイルをロードする
  • VMがファイルシステムからクラスをロードする
  • バイトコードが検証される
  • バイトコードの解釈が開始される
  • 静的イニシャライザの実行
  • 第1層コンパイル(C1)
  • プロファイリングメトリクスの収集
  • ... (しばらくの後)
  • 第2層コンパイル(C2/Graalコンパイラ)
  • 最適化されたマシンコードによる実行
  • オペレーティングシステムが実行ファイルと設定済ヒープをロードする
  • 最適化されたマシンコードでアプリケーションが起動する

JITとNative Imageモードの起動時間への影響

メモリ効率ネイティブな実行形式は、JVMやJITコンパイルインフラも、コンパイルしたコードを格納するメモリも、プロファイルデータも、バイトコードキャッシュも必要としません。必要なのは、実行形式とアプリケーションデータのためのメモリのみです。一例を挙げましょう。

JITモードとNative ImageモードにおけるメモリおよびCPU使用率

上のグラフは、JVM(左)およびネイティブ実行形式(右)におけるWebサーバの動作状況を示したものです。青色の線はメモリ使用量を示しています。JITモードが200MBであるのに対して、ネイティブ実行形式では40MBです。赤色の線はCPUのアクティビティです。JVMが前述したJITのウォームアップ処理中にCPUを多用しているのに比べると、高価な計算処理をすべてビルド時に実行しているネイティブ実行形式では、CPUをほとんど使用しません。このように、高速かつリソース効率に優れた実行時特性を備えたNative Imageは、その処理時間の短さとリソース使用量の少なさから、マイクロサービスやサーバレス、さらにはクラウドワークロード一般の大幅なコスト低減を可能にする、優れた展開モデルとなっています。

パッケージサイズネイティブ実行形式には必要なコードのみが格納されるため、アプリケーションコードとライブラリとJVMを組み合わせたサイズよりも大幅に小さくなります。リソースに制約のある環境での運用などのシナリオでは、アプリケーションのパッケージサイズが重要な場合があります。UPXのようなユーティリティを使えば、ネイティブ実行形式はさらに小さくなります。

JVMと同等のピークパフォーマンス

では、ピークパフォーマンスについてはどうでしょう?すべてのコンパイルを事前に行うNative Imageでは、実行時のスループットをどのように最適化するのでしょうか?

Native Imageでは、起動の高速性と合わせて、優れたピークパフォーマンスを提供できるような取り組みを行っています。すでにいくつかの、ネイティブ実行形式のピークパフォーマンスを改善する方法があります。

  • プロファイルに基く最適化Native Imageはコードの最適化とコンパイルを事前に行うので、アプリケーション実行時に、実行中のプロファイル情報にアクセスしてコードを最適化することはありません。この問題に対処する方法のひとつが、プロファイルに基づく最適化(profile-guided optimization、PGO)の実施です。PGOでは、アプリケーションを実行して収集したプロファイル情報を、ネイティブイメージ生成プロセスにフィードバックします。‘native-image‘ユーティリティはこの情報を使用することで、アプリケーションの実行時の動作に基づいて、最終的な実行ファイルのパフォーマンスを最適化します。PGOは、GraalVMの商用バージョンとしてOracleが提供する、GraalVM Enterpriseで使用可能です。
  • Native Imageのメモリ管理Native Imageが生成する実行形式では、デフォルトのガベージコレクタとしてSerial GCを使用します。これはヒープサイズの小さなマイクロサービスに適しています。使用可能なGCオプションは他にもあります。
    • Srerial GCには、若い世代用のSurvivor領域を有効にすることで、アプリケーション実行時のメモリフットプリントを削減するという、新たなポリシが追加されました。このポリシを導入したことにより、Spring Petclinicのような一般的なマイクロサービスワークロードにおいて、最大で23.22パーセントのピークスループット改善を確認しています。
    • 別の選択肢として、低レイテンシのG1ガベージコレクタを使用することで、さらなるスループットの向上も可能です(GraalVM Enterpriseで使用可能)。大規模なヒープにはこちらが適しています。

PGOとG1 GCを使用することにより、JVMに比肩するピークパフォーマンスを達成

RenaissanceおよびDaCapoベンチマークの幾何平均

これらのオプションにより、起動時間、メモリ効率、ピークスループットというすべての次元において、Native Imageを使用したアプリケーションのパフォーマンスを最大化することが可能になります。

リフレクション、コンフィギュレーション、その他のNative Imageに対する誤解を解く

Native ImageはJavaアプリケーションのまったく新しい実行方法であるため、注意すべき点がいくつかあります。

GraalVM Native Imageがリフレクションをサポートしていない、という話を聞いたことがあるかも知れませんが、これは事実ではありません。

Native Imageは、閉世界仮説(closed-world assumption)の下で静的解析を行います。そのため、リフレクションのような動的な機能に関しては、ビルドプロセスを正しく実行するための付加的な設定が必要になります。Javaアプリケーションの静的解析を行う時、Native Imageは、Reflection APIの呼び出しの検出と処置を試みます。しかしながら、一般論として、この自動解析は必ずしも十分ではありません。実行時にリフレクション経由でアクセスされるプログラム要素を、コンフィギュレーションを通じて特定する必要があるのです。このコンフィギュレーションは手書きすることもできますが、Native Imageトレースエージェントを利用することも可能です。このエージェントは、JVM上でのプログラム実行中に使用される動的機能を追跡して、コンフィギュレーションファイルを生成します。Native Imageユーティリティはこのファイルを使って、リフレクション経由でアクセスされるプログラム部分を生成コードに含めるのです。エージェントは最初のコンフィギュレーションを作成するには便利ですが、必要に応じて手作業による調査と補完を行うことをお勧めします。

Java Native Interface(JNI)やProxyオブジェクト、クラスパスリソースなどを使用する場合にも、同じようなコンフィギュレーションが必要になることがありますが、これらの機能に関しても、同じトレースエージェントを使って設定を行うことができます。

最後に、Native Imageのコンパイルを視覚化するWebベースのアプリケーションであるGraalVM Dashboardを使えば、ネイティブ実行ファイルに含まれるパッケージやクラス、メソッドの確認や、ヒープ内で最も多くの空間を占めているオブジェクトの特定が可能になります。

Javaクラウドの流れを変える

Native Imageは、アプリケーションのリソース使用プロファイルに大きな影響を与えられることから、クラウド展開において大きな違いを生み出します。これまでに、Native Imageの生成するネイティブ実行ファイルの起動が速く、メモリ使用量の少ないことを見てきました。クラウド展開において、それは具体的にどのような意味があるのでしょう、また、Javaコンテナイメージを最小化する上で、どのように役立つのでしょうか?

すでに述べたように、Native Imageで生成したアプリケーションは、実行にJVMを必要としません。アプリケーションの実行に必要なすべてのものを含んでいるため、自己完結が可能です。これはつまり、アプリケーションだけをスリムなDockerイメージに置けば、それだけで完全に機能するものになる、ということです。イメージのサイズは、アプリケーションが何をするか、どのような依存関係を含むかによって変わります。Javaのマイクロサービスフレームワークで構築した基本的な"Hello, World!"アプリケーションは、およそ20MBになります。

Native Imageを使えば、静的、ないしほぼ静的な実行ファイルを作ることも可能です。ほぼ静的(mostly-static)な実行ファイルでは、コンテナイメージが提供する‘libc'以外の、すべてのライブラリが静的リンクされています。いわゆるディストロレス(distroless)コンテナイメージを使って、軽量なデプロイメントを行うこともできます。ディストロレスイメージとは、アプリケーションを実行するためのライブラリだけを含む、シェルやパッケージマネージャなど、他のプラグラムを持たないイメージです。Dockerfileは、次のようなシンプルなものになります。

```
FROM gcr.io/distroless/base
COPY build/native-image/application app
ENTRYPOINT ["/app"]
```

コンテナイメージのlibcも必要としない、完全自立型のデプロイメントを行うには、アプリケーションに'musl-libc'を静的にリンクします。完全に自立しているので、'FROM scratch' Dockerイメージに置くことも可能です。

Native Imageをプロダクションで使用する    

ここまでは、Native Imageを使って生成することによるアプリケーションのパフォーマンスの最大化について議論するとともに、ビルドプロセス中に利用できる有用なハックをいくつか紹介してきました。では、アプリケーションを最大限に活用するために、他にできることはないでしょうか?あります — それも、たくさん。

Javaアプリケーションをネイティブ実行ファイルとして構築、テスト、実行する手順を簡略化するには、GraalVMチームの提供するMavenおよびGradle用の公式プラグインを使用してください。これらのプラグインは、ネイティブなJUnit 5テストもサポートしています。JUnit、Micronaut、Springの各チームの協力で開発された公式プラグインは、JVMエコシステムにおけるコラボレーションの好例とも言えます。

GraalVM Native ImageをGitHub Actionワークフロー内に設定するには、"GitHub action for GraalVM"を使用します。コンフィギュレーション可能なこのアクションは、複数のGraalVMリリースと開発者のビルドをサポートして、GraalVMと指定されたコンポーネントの完全なセットアップを行います。

ツーリングについても少し触れておきましょう。実行ファイルとして配布するJavaアプリケーションの開発には、通常の開発で使用するものと同じツールを使用することができます。任意のIDEと、GraalVM JDKを含む任意のJDKを使って、アプリケーションの構築、テスト、デバッグを行った上で、最後にGraalVM Native Imageユーティリティを使ったネイティブコンパイルのステップを実行すればよいのです。複雑なアプリケーションでは、Native Imageコンパイルに時間を要する場合があるので、最終ステップとして実行するとよいでしょう。ただし、私たちは今、プロダクション展開に必要な最適化の多くを実施しないことで、コンパイル時間を大幅に短縮できる、"クイック開発モード"に取り組んでいます。

アプリケーションの開発はJVM上で行って、開発プロセスの最後の方でネイティブ実行ファイルを生成することが可能なのですが、それでもコミュニティからは、ビルド時間とリソース使用量の改善に対する要求が数多く寄せられています。この問題については、過去数回のリリースを通じて多くの作業を行いました。GraalVMの最新リリース(22.0)を使用すれば、"hello world" Javaアプリケーションから13.8秒程度でネイティブ実行ファイルを生成することができます。実行ファイルのサイズは5MB程度です。メモリ使用量も10パーセント程度削減できました。

Native Imageを使って実行ビルドをデバッグするには、コマンドラインから'gdb'を使用するか(LinuxまたはmacOS)、GraalVMのVSCodeエクステンションを使用します。こちらのチュートリアルに、ステップ・バイ・ステップの説明があります。

ネイティブ実行ファイルのパフォーマンス監視には、JDK Flight Recorderを使用します。Native Imageの完全なサポートはまだ開発途中ですが、カスタムイベントやシステムイベントの監視はすでに可能です。

その他のパフォーマンス監視としては、ネイティブ実行ファイルのヒープダンプを生成してVisualVMなどのツールで解析する、という方法もあります。これはGraalVM Enterpriseの機能です。

Javaフレームワークでの採用

Javaフレームワークのサポートがなければ、業務レベルのアプリケーション開発は非常に困難なものになりますが、幸いにもその心配はありません。Gluon SubstrateHelidonMicronautQuarkusSpring Boot(アルファベット順)など、すべての主要なフレームワークがNative Imageをサポートしているからです。これらのフレームワークでは、いずれもGraalVM Native Imageを使用することでアプリケーションの起動時間やリソース使用量が大幅に改善されるため、効率的なクラウド展開には完璧なものになります。本シリーズの今後の記事では、GraalVM Native Imageをフレームワークで使用する方法について解説する予定です。

Native Imageの今後

最初の公式リリース以来、Native Imageは長足の進歩を遂げています。Javaフレームワークで広く採用され、クラウドベンダはJavaランタイムとしてNative Imageを提供し、多くのライブラリが最初からNative Imageとの連携を用意しています。開発者エクスペリエンスにもいくつかの変更を加えました。昨年の調査では、GraalVMを使用する開発者の70パーセントが、ネイティブ実行ファイルの開発と配布に使用している、という結果が出ています。

Native Imageの新機能や改良についても、次のように数多くのアイデアがあります。

  • プラットフォームサポートの拡大
  • Javaライブラリの設定の単純化と互換性向上
  • ピークパフォーマンスの継続的改善
  • Native Imageの全機能の活用、新機能の開発、パフォーマンス向上、優れた開発者エクスペリエンスの確保を、Javaフレームワークチームとの連携を通じて継続的に実施
  • より高速な開発コンパイルモードの導入
  • Project Loomの仮想スレッドのサポート
  • Native Imageコンフィギュレーションとエージェントベースのコンフィギュレーションに関するIDEサポート
  • GCパフォーマンスのさらなる向上と新たなGC実装の導入

Native Imageを前進させ、すべてのJava開発者に対してさらに有用なものにする上での、コミュニティやパートナの協力に感謝しています。Native Imageに望む新機能や改善がありましたら、GraalVMのコミュニティプラットフォームを通じてフィードバックをお寄せください。

Javaはエンタープライズアプリケーションの主流です。しかしクラウドにおいては、一部の競合技術よりも高価な選択となっています。ネイティブコンパイルは、クラウドにおけるJavaを安価な存在にします -起動が非常に速く、メモリ使用量の少ないアプリケーションを実現するのです。

そのため、ネイティブコンパイルは、すべてのJavaユーザに対して多くの疑問を投げかけます。ネイティブJavaは開発をどう変えるのか?ネイティブJavaに切り替えるべきなのはいつか?切り替えない方がよいのは、どのような場合か?どのフレームワークを使用すればよいのか?このシリーズでは、これらの疑問に答えていきたいと思います。

このInfoQ記事は"Native Compilations Boot Java"シリーズの一部です。シリーズに関しては、RSSによる通知の登録が可能です。

作者について

この記事に星をつける

おすすめ度
スタイル

こんにちは

コメントするには InfoQアカウントの登録 または が必要です。InfoQ に登録するとさまざまなことができます。

アカウント登録をしてInfoQをお楽しみください。

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

コミュニティコメント

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

BT