JavaでAPIやバッチを開発していると、
- Javaはコンパイルすると何になるのか
- Javaの機能はどうやって呼び出されるのか
- ShellからJavaを呼び出すのはなぜか
- Javaから直接Javaの機能を呼ぶこともできるのか
- 新しく作るならShellとJavaのどちらがよいのか
といった疑問が出てくる。
これらは別々の話に見えるが、実際には、
プログラムをどう実行し、機能同士をどう接続するか
という一つの話としてつながっている。
1. そもそもJavaはどうやって実行される?
Javaでは、書いたソースコードをそのままCPUが実行しているわけではない。
Javaソースコード
Main.java
↓
javac
↓
Javaバイトコード
Main.class
↓
JVM
↓
インタプリタ / JITコンパイラ
↓
CPUが実行できる機械語
例えば、
public int add(int a, int b) {
return a + b;
}
というJavaコードを書いたとする。
これをコンパイルすると、
Example.java
↓ javac
Example.class
となる。
.classファイルの中に入っているのがJavaバイトコードである。
Javaバイトコードは広い意味ではバイナリデータだが、
CPUが直接実行するネイティブ機械語ではない
という点が重要である。
JVM(Java Virtual Machine)がバイトコードを読み取り、実行する。
2. .jarとは?
Java開発では.jarというファイルもよく登場する。
大まかには、
複数の
.classファイルや設定ファイルなどをまとめたもの
と考えると分かりやすい。
application.jar
│
├── Main.class
├── Service.class
├── Repository.class
└── 設定ファイルなど
Shellなどから、
java -jar application.jar
と実行できる。
3. ShellからJavaを呼び出す
バッチシステムなどでは、
ジョブ管理
↓
Shell
↓
Java
↓
業務処理
↓
DB / ファイル / 外部システム
という構成を取ることがある。
例えば、
java -jar batch.jar input.csv
のようにJavaプログラムを起動する。
Shellは、
Javaを起動
↓
引数を渡す
↓
終了を待つ
↓
終了コードを確認
↓
次の処理を決める
という処理の制御役を担当できる。
4. なぜShellを使うのか?
Shellには、
OS上のプログラムやファイルを簡単につなぎ合わせられる
という強みがある。
例えば、
Shell
├─ JavaバッチAを起動
├─ ファイル存在確認
├─ ファイルを移動
├─ JavaバッチBを起動
└─ 終了コード確認
といった処理はShellが得意である。
5. JavaからJavaの機能を直接呼ぶこともできる
同じJavaアプリケーション内なら、普通のメソッド呼び出しとして実現できる。
Result result = functionA.execute();
if (result.isSuccess()) {
functionB.execute(result);
}
例えばSpring Bootなら、
HTTP Request
↓
Controller
↓
Service A
↓
Service B
↓
Repository
↓
PostgreSQL
という構成も作れる。
6. Shell → Java と Java → Java の違い
Shell → Java
Shell
↓
Javaプログラム
Shellが別のプログラムを起動する。
java -jar batchA.jar
Java → Java
同じJVM・同じアプリケーションなら、
Java Application
├─ Service A
│ ↓
└─ Service B
として、
serviceB.execute(data);
のように直接呼び出せる。
7. ShellとJavaのメリット・デメリット
| 観点 | Shell | Java |
|---|---|---|
| 単純な処理の手軽さ | ◎ | ○ |
| プログラム起動 | ◎ | △ |
| OS操作 | ◎ | △ |
| ファイル操作 | ◎ | ○ |
| 複雑な業務ロジック | △ | ◎ |
| 型安全性 | △ | ◎ |
| エラー処理 | △ | ◎ |
| 単体テスト | △ | ◎ |
| IDE支援 | △ | ◎ |
| 大規模化した場合の保守 | △ | ◎ |
重要なのは、
ShellとJavaのどちらが優れているかではなく、何を担当させるか
である。
8. 一から作るならShellのほうが手軽なのか?
単純なプログラム起動ならShellはかなり手軽である。
java -jar functionA.jar
if [ $? -eq 0 ]; then
java -jar functionB.jar
fi
一方、業務処理そのものを連携させるなら、
Result result = functionA.execute();
if (result.isSuccess()) {
functionB.execute(result);
}
のようにJavaで表現したほうが自然な場合が多い。
9. 現在の開発では手軽さと可読性のどちらを取る?
基本的には、
最初に書くときの手軽さだけでなく、長期的な可読性・保守性を重視する
と考える。
ただし、
可読性重視
↓
何でもJava
という意味ではない。
単純な起動だけなら、
java -jar batch.jar
のほうがむしろ分かりやすい。
重要なのは、
その処理を最も単純に表現できる技術を使うこと
である。
10. プロセス・JVM・モジュール・サービスの境界は何が違うのか?
ここまで理解すると、次に重要になるのが**「境界」**である。
Javaシステムを見ていると、
- モジュール
- JVM
- プロセス
- サービス
という言葉が出てくる。
これらはすべて「何かを分ける」という意味を持っているが、分けているものが違う。
まず全体像を単純化すると、
OS
│
├─ Process A
│ │
│ └─ JVM
│ │
│ ├─ Module A
│ ├─ Module B
│ └─ Module C
│
└─ Process B
│
└─ JVM
│
├─ Module D
└─ Module E
さらに、これらのプロセスを独立したシステム機能として運用すると、
Service A
│
└─ Process
└─ JVM
├─ Module A
└─ Module B
HTTP / Message
Service B
│
└─ Process
└─ JVM
└─ Module C
のような構成になることがある。
11. モジュールの境界
モジュールは、
コードや責務を整理するための境界
である。
例えば、
Application
│
├─ Contract Module
│
├─ Billing Module
│
└─ File Module
と分割する。
しかし、これらは同じJavaアプリケーションの中に存在できる。
billingService.calculate();
のように直接呼び出すことも可能である。
つまり、
Module A
↓ メソッド呼び出し
Module B
となる。
この場合、
- 同じプロセス
- 同じJVM
- 同じメモリ空間
で動いている可能性がある。
したがって、
モジュールを分けたからといって、プログラムそのものが別々に動いているとは限らない。
ここは非常に重要である。
12. JVMの境界
JVMは、
Javaバイトコードを実行するための実行環境
である。
例えば、
java -jar app.jar
と実行すると、通常そのJavaアプリケーションを動かすJVMが起動する。
イメージとしては、
JVM
│
├─ ContractService
├─ BillingService
├─ FileService
│
├─ Heap
├─ Threads
└─ Garbage Collector
となる。
同じJVMの中にいるJavaオブジェクト同士なら、
billingService.execute(data);
のような通常のJava呼び出しができる。
しかしJVMが分かれると、
JVM A
ContractService
│
│ 直接メソッド呼び出しはできない
↓
JVM B
BillingService
となる。
別JVMのJavaオブジェクトを、そのまま普通のJavaメソッドとして呼び出すことはできない。
そこで、
HTTP
RPC
Message Queue
Socket
などの通信方法が必要になる。
13. プロセスの境界
プロセスとは、
OSが管理する実行中のプログラムの単位
である。
例えば、
java -jar appA.jar
java -jar appB.jar
を別々に起動すると、通常は別々のJavaプロセスとして動く。
OS
│
├─ Process A
│ └─ JVM A
│ └─ appA
│
└─ Process B
└─ JVM B
└─ appB
プロセスが違えば、基本的にメモリ空間も分離される。
そのため、
Process A
↓
×
↓
Process B
と直接オブジェクトを渡すことはできない。
代わりに、
Process A
↓
HTTP / RPC / File / Message
↓
Process B
などを使う。
Shellから、
java -jar batchA.jar
java -jar batchB.jar
と起動する場合も、
Shell
├─ Java Process A
└─ Java Process B
という境界が生まれる。
14. JVMとプロセスは何が違う?
ここは少し紛らわしい。
Javaアプリケーションでは多くの場合、
Java Process
│
└─ JVM
となるため、
「JVMが違う」
と
「Javaプロセスが違う」
がほぼ同時に起きる。
ただし概念としては、
Process
=OSが管理する実行単位
JVM
=Javaを実行する仮想マシン
であり、意味は違う。
15. サービスの境界
サービスは少し性質が違う。
サービスとは、
ある業務・機能上の責務を外部に提供する単位
として考えると分かりやすい。
例えば、
注文サービス
決済サービス
在庫サービス
という分割がある。
それぞれを独立したアプリケーションとして動かせば、
Order Service
↓ HTTP
Payment Service
↓ HTTP
Inventory Service
のようになる。
マイクロサービスでは特に、
Service A
・独立した責務
・独立したデプロイ
Service B
・独立した責務
・独立したデプロイ
という独立性が重要になる。
16. サービス = プロセスではない
ここも重要である。
「サービス」という言葉は論理的・アーキテクチャ的な概念であり、
「プロセス」はOS上の実行単位である。
そのため、
サービス
≠
必ず1プロセス
である。
例えば負荷が高くなれば、
Order Service
┌→ Process A
Request
├→ Process B
│
└→ Process C
のように、同じサービスを複数プロセス・複数インスタンスで動かすこともある。
Kubernetesなどで、
Order Service
│
├─ Pod A
├─ Pod B
└─ Pod C
とスケールさせるイメージにもつながる。
したがって、
サービスは「何を提供するか」の境界、プロセスは「どう実行されているか」の境界
と考えると区別しやすい。
17. 4つを比較する
| 境界 | 何を分ける? | 分かれると何が起きる? |
|---|---|---|
| モジュール | コード・責務 | コード上の依存関係を整理できる |
| JVM | Java実行環境 | Javaオブジェクトを直接共有できなくなる |
| プロセス | OS上の実行単位 | メモリ・実行状態などが分離される |
| サービス | システム・業務上の責務 | API等を契約として独立して連携できる |
つまり、分離のレベルが全部同じではない。
18. 具体例で考える
例えば、
契約
請求
ファイル出力
という3機能があるとする。
パターンA:同じJavaアプリケーション
1 Process
└─ 1 JVM
│
├─ Contract Module
├─ Billing Module
└─ File Module
呼び出しは、
billingService.execute();
のようにできる。
これはコード上では分割されているが、実行環境としては一緒である。
モジュラーモノリスなどでは、このような構造が考えられる。
パターンB:別Javaプログラムとして実行
Shell
│
├─ Process A
│ └─ JVM
│ └─ Contract
│
├─ Process B
│ └─ JVM
│ └─ Billing
│
└─ Process C
└─ JVM
└─ File
この場合、Javaプログラム同士は直接メソッド呼び出しできない。
Shell、ファイル、DBなどを介して処理をつなぐことになる。
パターンC:サービスとして分離
Contract Service
│
│ HTTP / Message
↓
Billing Service
│
│ HTTP / Message
↓
File Service
各サービスを独立して起動・デプロイできる。
ただし、その代わり、
- ネットワーク通信
- タイムアウト
- リトライ
- APIのバージョニング
- 障害時の扱い
- 分散トレーシング
など、分散システム特有の問題も考える必要が出てくる。
19. 境界を強くすると独立性は上がるが、コストも上がる
ここがアーキテクチャを考えるうえでかなり重要である。
同じJVMなら、
A
↓
B
というメソッド呼び出しだけで済む。
非常に速く、単純である。
一方、サービスを分けると、
Service A
↓
JSONへ変換
↓
HTTP
↓
Network
↓
Service B
↓
JSONをJavaオブジェクトへ変換
のような処理が必要になる。
さらにService Bが落ちている可能性もある。
つまり、
境界が弱い
↓
連携が簡単
↓
独立性が低い
境界が強い
↓
独立性が高い
↓
連携が複雑
というトレードオフがある。
そのため、
「分割できるから分割する」のではなく、「分割するメリットが複雑さを上回るか」を考える
必要がある。
20. Shellの話ともつながる
最初の疑問だった、
ShellからJavaを呼ぶのと、JavaからJavaを呼ぶのは何が違う?
も、境界として見ると理解しやすい。
Java
↓ メソッド
Java
=同じJVM内で完結できる
一方、
Shell
↓
java -jar A.jar
Shell
↓
java -jar B.jar
なら、通常は、
Process A / JVM A
Process B / JVM B
というプロセス・JVMの境界ができる。
つまりShellを挟むことは、単なる「書き方の違い」ではなく、場合によっては実行単位そのものを分離する設計にもつながる。
21. 最終的に見るべきポイント
システムを見るとき、
「機能ごとに分かれています」
と言われただけでは、実は情報が足りない。
次のように考える必要がある。
機能は分かれている?
↓
コードのModuleだけ?
それとも
↓
別JVM?
それとも
↓
別Process?
それとも
↓
独立Service?
例えば、
機能A
機能B
機能C
と分かれていても、
1 JVM
├─ A
├─ B
└─ C
なのか、
JVM A
JVM B
JVM C
なのかでは、アーキテクチャとして全く意味が違う。
22. まとめ
Javaシステムを見るときは、次の4つを区別すると理解しやすい。
Module
↓
コード・責務の境界
JVM
↓
Java実行環境の境界
Process
↓
OS上の実行単位の境界
Service
↓
システム・業務責務の境界
そして、
1 Process
└─ JVM
├─ Module A
├─ Module B
└─ Module C
という構成もあれば、
Service A
└─ Process / JVM
↓ HTTP
Service B
└─ Process / JVM
という構成もある。
重要なのは、
「機能が分かれているか」だけではなく、「どの境界で分かれているのか」を確認すること。
これが分かると、
- モノリスなのか
- モジュラーモノリスなのか
- 複数のバッチプログラムなのか
- サービスとして分割されているのか
- マイクロサービスなのか
を判断するための材料が増える。
そして設計するときも、
どこまでを同じJVMに置き、どこでプロセスを分け、どこを独立したサービスとして扱うのか
という形で考えられるようになる。
これはまさに、アーキテクチャにおける「境界を設計する」という考え方につながっている。