メインコンテンツにスキップ

JavaからHello Worldお出力するまで 1

· 16分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

プログラミングの世界では、常に「Hello World」という文を出力することから始まります。それはまるで不文律のようです。

# hello.py
print("Hello World")
python hello.py
// Hello World

Python?素晴らしい。

// hello.js
console.log("Hello World");
node hello.js
// Hello World

JavaScript?悪くない。

public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java
java VerboseLanguage
// Hello World

しかし、Javaはまるで別の世界から来たように感じます。クラス名がファイル名と一致しなければならないことさえまだ触れていません。

publicとは何か、classとは何か、staticとは何か、voidmainString[]、そしてSystem.out.printlnを経て、ようやく文字列「Hello World」にたどり着きます。さあ、別の言語を学びましょう。1

単に「Hello World」を出力するだけでも、Javaはかなりの背景知識を要求します。なぜJavaはこんなにも冗長なプロセスを必要とするのでしょうか?

このシリーズは3つの章に分かれています。目標は、2つの単語「Hello World」を出力するために裏で何が起こっているのかを詳しく探ることです。各章の具体的な内容は以下の通りです:

  • 第1章では、Hello Worldを出発点とする理由を紹介します。
  • 第2章では、コンパイルされたクラスファイルとコンピュータがJavaコードを解釈し実行する方法を検討します。
  • 最後に、JVMがpublic static void mainをロードして実行する方法とその動作原理を探ります。

3つの章の内容を組み合わせることで、ようやく「Hello World」の概念を理解することができます。かなり長い旅ですが、深呼吸して始めましょう。

第1章. なぜ?

JavaでHello Worldを出力する前に、いくつかの「なぜ」の瞬間を考慮する必要があります。

なぜクラス名はファイル名と一致しなければならないのか?

より正確には、publicクラスの名前がファイル名と一致しなければならないのです。なぜでしょうか?

Javaプログラムはコンピュータに直接理解されるものではありません。JVMという仮想マシンがプログラムの実行を助けます。Javaプログラムをコンピュータで実行可能にするためには、いくつかのステップを経てJVMが解釈できる機械語に変換する必要があります。最初のステップは、コンパイラを使用してプログラムをJVMが解釈できるバイトコードに変換することです。変換されたバイトコードはJVM内部のインタープリタを通じて機械語に翻訳され、実行されます。

コンパイルプロセスを簡単に見てみましょう。

public class Outer {
public static void main(String[] args) {
System.out.println("This is Outer class");
}

private class Inner {
}
}
javac Outer.java
Permissions Size User   Date Modified Name
.rw-r--r-- 302 haril 30 Nov 16:09 Outer$Inner.class
.rw-r--r-- 503 haril 30 Nov 16:09 Outer.class
.rw-r--r-- 159 haril 30 Nov 16:09 Outer.java

Javaはコンパイル時に各クラスごとに.classファイルを生成します

さて、JVMはプログラムを実行するためにmainメソッドを見つける必要があります。どうやってmainメソッドを見つけるのでしょうか?

なぜmain()を見つける必要があるのか?もう少し待ってください。

もしJavaファイル名がパブリッククラス名と一致しない場合、Javaインタープリタはmainメソッドを見つけるためにすべてのクラスファイルを読み込む必要があります。ファイル名がパブリッククラス名と一致している場合、Javaインタープリタは解釈する必要のあるファイルをよりよく特定できます。

1000クラスが含まれるJava1000というファイルを想像してみてください。1000クラスの中からmain()がどこにあるのかを特定するために、インタープリタはすべてのクラスファイルを調べる必要があります。

しかし、ファイル名がパブリッククラス名と一致している場合、main()により迅速にアクセスでき(mainはパブリッククラスに存在するため)、すべてのロジックがmain()から始まるため、他のクラスにも簡単にアクセスできます。

なぜpublicでなければならないのか?

JVMはクラス内のmainメソッドを見つける必要があります。クラスの外部からクラスにアクセスするJVMがクラス内のメソッドを見つけるためには、そのメソッドがpublicでなければなりません。実際、アクセス修飾子をprivateに変更すると、mainpublicとして宣言するように指示するエラーメッセージが表示されます。

Error: Main method not found in class VerboseLanguage, please define the main method as:
public static void main(String[] args)

なぜstaticでなければならないのか?

JVMはpublic main()メソッドを見つけました。しかし、このメソッドを呼び出すためには、まずオブジェクトを作成する必要があります。JVMはこのオブジェクトを必要とするのでしょうか?いいえ、JVMはただmainを呼び出すだけでよいのです。staticとして宣言することで、JVMは不要なオブジェクトを作成する必要がなくなり、メモリを節約できます。

なぜvoidでなければならないのか?

mainメソッドの終了はJavaの実行の終了を意味します。JVMはmainの戻り値を使用することができないため、戻り値の存在は意味がありません。したがって、voidとして宣言するのが自然です。

なぜmainという名前でなければならないのか?

mainというメソッド名は、JVMがアプリケーションの実行エントリーポイントを見つけるために設計されたものです。

「設計」という言葉は大げさに聞こえますが、実際にはmainという名前のメソッドを見つけるようにハードコーディングされています。もし見つける名前がmainではなくharilだった場合、harilという名前のメソッドを探していたでしょう。もちろん、Javaの作成者たちはmainを選んだ理由があったのでしょうが、それがすべてです。

mainClassName = GetMainClassName(env, jarfile);
mainClass = LoadClass(env, classname);

// Find the main method
mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");

jbject obj = (*env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE);

なぜargsが必要なのか?

これまで、main()String[] argsについては触れていませんでした。なぜこの引数が必要で、なぜ省略するとエラーが発生するのでしょうか?

public static void main(String[] args)はJavaアプリケーションのエントリーポイントであるため、この引数はJavaアプリケーションの外部から渡される必要があります。

標準入力のすべての型は文字列として入力されます。

これがargsが文字列配列として宣言される理由です。考えてみれば納得です。Javaアプリケーションが実行される前に、カスタムオブジェクトタイプを直接作成できますか?🤔

では、なぜargsが必要なのでしょうか?

外部から内部に引数を簡単に渡すことで、Javaアプリケーションの動作を変更することができます。このメカニズムは、Cプログラミングの初期からプログラムの動作を制御するために広く使用されてきました。特にシンプルなアプリケーションにとって、この方法は非常に効果的です。Javaはこの広く使用されている方法を採用しただけです

String[] argsを省略できない理由は、Javaがエントリーポイントとしてpublic static void main(String[] args)のみを許可しているためです。Javaの作成者たちは、argsを宣言して使用しない方が、省略を許可するよりも混乱が少ないと考えたのです。

System.out.println

最後に、出力に関連するメソッドについて話し始めることができます。

もう一度言いますが、Pythonではprint("Hello World")でした。2

Javaプログラムはオペレーティングシステム上で直接実行されるのではなく、JVMという仮想マシン上で実行されます。これにより、Javaプログラムはオペレーティングシステムに関係なくどこでも実行できるようになりますが、オペレーティングシステムが提供する特定の機能を使用するのが難しくなります。これが、JavaでCLIを作成したり、OSのメトリクスを収集したりするのが難しい理由です。

しかし、限られたOS機能を活用する方法(JNI)があり、Systemはこの機能を提供します。主な機能のいくつかは次のとおりです:

  • 標準入力
  • 標準出力
  • 環境変数の設定
  • 実行中のアプリケーションを終了し、ステータスコードを返す

Hello Worldを出力するために、Systemの標準出力機能を使用しています。

実際、System.out.printlnの流れを追うと、nativeキーワードが付いたwriteBytesメソッドに出会い、この操作をCコードに委譲し、標準出力に転送することがわかります。

// FileOutputStream.java
private native void writeBytes(byte b[], int off, int len, boolean append)
throws IOException;

nativeキーワードが付いたメソッドの呼び出しは、Java Native Interface(JNI)を通じて動作します。これは後の章で取り上げます。

String

Javaの文字列は少し特別です。いや、かなり特別なようです。文字列は別のメモリ空間に割り当てられ、特別に扱われていることがわかります。なぜでしょうか?

文字列の次の特性に注目することが重要です:

  • 非常に大きくなる可能性がある。
  • 比較的頻繁に再利用される。

したがって、文字列は一度作成されたら再利用することに重点を置いて設計されています。大きな文字列データがメモリ内でどのように管理されるかを完全に理解するには、後で取り上げるトピックの理解が必要です。ここでは、メモリ空間の節約の原則について簡単に触れておきます。

まず、Javaで文字列がどのように宣言されるかを見てみましょう。

String greeting = "Hello World";

内部的には次のように動作します:

文字列はString Constant Poolに作成され、不変の特性を持っています。一度文字列が作成されると変更されず、新しい文字列を作成する際にConstant Poolに同じ文字列が見つかると、それが再利用されます。

次の章では、JVMスタック、フレーム、ヒープについて取り上げます。

もう一つの文字列の宣言方法はインスタンス化です。

String greeting = new String("Hello World");

この方法は内部動作に違いがあるため、あまり使用されません。以下のように動作します。

newキーワードを使用せずに文字列を直接使用すると、String Constant Poolに作成され、再利用されます。しかし、newキーワードでインスタンス化すると、Constant Poolには作成されません。これにより、同じ文字列が複数回作成され、メモリ空間が無駄になる可能性があります。

まとめ

この章では、次の質問に答えました:

  • なぜ.javaファイル名はクラス名と一致しなければならないのか?
  • なぜpublic static void main(String[] args)でなければならないのか?
  • 出力操作の流れ
  • 文字列の特性とその作成および使用の基本原則

次の章では、Javaコードを自分でコンパイルし、バイトコードがどのように生成されるか、そのメモリアリアとの関係などを探ります。

参考文献

Footnotes

  1. Life Coding Python

  2. Life Coding Python

1台のサーバーアプリケーションは同時にどれだけのリクエストを処理できるか?

· 22分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

概要

Spring MVCのWebアプリケーションはどれだけの同時ユーザーを受け入れられるのか?🤔

多くのユーザーを受け入れながら安定したサービスを提供するために、サーバーが処理しなければならないユーザー数を推定するために、本記事ではSpring MVCのTomcat設定に焦点を当ててネットワークトラフィックの変化を探ります。

利便性のため、以下の文章は会話調で書かれています 🙏

情報

技術的な誤り、誤字脱字、または不正確な情報があれば、コメントでお知らせください。フィードバックは大変ありがたいです 🙇‍♂️

[システムデザインインタビュー] URL短縮サービスをゼロから実装する

· 7分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

情報

コードはGitHubで確認できます。

概要

URLの短縮は、もともとメールやSMSでURLが分断されるのを防ぐために始まりました。しかし、現在ではTwitterやInstagramなどのソーシャルメディアプラットフォームで特定のリンクを共有するためにより積極的に使用されています。URLが冗長に見えないことで可読性が向上し、リダイレクト前にユーザーの統計情報を収集するなどの追加機能も提供できます。

この記事では、URL短縮サービスをゼロから実装し、その仕組みを探ります。

URL短縮サービスとは?

まず、結果を見てみましょう。

この記事で実装するURL短縮サービスは、以下のコマンドで直接実行できます。

docker run -d -p 8080:8080 songkg7/url-shortener

使用方法は簡単です。短縮したい長いURLをlongUrlの値として入力します。

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"
# ランダムな値(例:tN47tML)が返されます。

次に、http://localhost:8080/tN47tMLにアクセスすると、

image

元のURLに正しくリダイレクトされることが確認できます。

短縮前

短縮後

では、どのようにしてURLを短縮するのか見てみましょう。

大まかな設計

URLの短縮

  1. longUrlを保存する前にIDを生成します。
  2. IDをbase62にエンコードしてshortUrlを作成します。
  3. ID、shortUrl、およびlongUrlをデータベースに保存します。

メモリは有限で比較的高価です。RDBはインデックスを通じて迅速にクエリを実行でき、メモリに比べて比較的安価なので、URLの管理にはRDBを使用します。

URLを管理するためには、まずID生成戦略を確保する必要があります。ID生成にはさまざまな方法がありますが、ここでは長くなるため省略します。今回は単純に現在のタイムスタンプを使用してIDを生成します。

Base62変換

ULIDを使用すると、タイムスタンプを含む一意のIDを生成できます。

val id: Long = Ulid.fast().time // 例:3145144998701、プライマリキーとして使用

この数値をbase62に変換すると、次のような文字列になります。

tN47tML

この文字列はshortUrlとしてデータベースに保存されます。

idshortlong
3145144998701tN47tMLhttps://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8

取得プロセスは次のように進行します:

  1. localhost:8080/tN47tMLにGETリクエストを送信します。
  2. tN47tMLをbase62からデコードします。
  3. プライマリキー3145144998701を取得し、データベースをクエリします。
  4. リクエストをlongUrlにリダイレクトします。

これで大まかな流れを見たので、実装して詳細を掘り下げていきましょう。

実装

前回の記事「Consistent Hashing」と同様に、自分で実装します。幸いなことに、URL短縮サービスの実装はそれほど難しくありません。

モデル

まず、ユーザーからのリクエストを受け取るモデルを実装します。短縮するURLのみを受け取るように構造を簡略化しました。

data class ShortenRequest(
val longUrl: String
)

POSTリクエストを処理するためのコントローラーを実装します。

@PostMapping("/api/v1/shorten")
fun shorten(@RequestBody request: ShortenRequest): ResponseEntity<ShortenResponse> {
val url = urlShortenService.shorten(request.longUrl)
return ResponseEntity.ok(ShortenResponse(url))
}

Base62変換

最後に、最も重要な部分です。IDを生成した後、base62にエンコードして短縮します。この短縮された文字列がshortUrlになります。逆に、shortUrlをデコードしてIDを見つけ、それを使用してデータベースをクエリし、longUrlを取得します。

private const val BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

class Base62Conversion : Conversion {
override fun encode(input: Long): String {
val sb = StringBuilder()
var num = BigInteger.valueOf(input)
while (num > BigInteger.ZERO) {
val remainder = num % BigInteger.valueOf(62)
sb.append(BASE62[remainder.toInt()])
num /= BigInteger.valueOf(62)
}
return sb.reverse().toString()
}

override fun decode(input: String): Long {
var num = BigInteger.ZERO
for (c in input) {
num *= BigInteger.valueOf(62)
num += BigInteger.valueOf(BASE62.indexOf(c).toLong())
}
return num.toLong()

}
}

短縮されたURLの長さはID番号のサイズに反比例します。生成されたID番号が小さいほど、URLを短くすることができます。

短縮されたURLの長さが8文字を超えないようにするには、IDのサイズが62^8を超えないようにする必要があります。したがって、IDの生成方法も重要です。前述のように、この記事では内容を簡略化するためにタイムスタンプ値を使用しました。

テスト

curlを使用してランダムなURLを短縮するためのPOSTリクエストを送信してみましょう。

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"

http://localhost:8080/{shortUrl}にアクセスして、正しくリダイレクトされることを確認できます。

結論

改善の余地がある点:

  • ID生成戦略をより正確に制御することで、shortUrlをさらに短縮できます。
    • トラフィックが多い場合、同時実行性に関連する問題を考慮する必要があります。
    • Snowflake
  • ホスト部分にDNSを使用することで、URLをさらに短縮できます。
  • 永続化レイヤーにキャッシュを適用することで、応答速度を向上させることができます。

Spring Boot 3.1におけるDocker Composeサポートの探求

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

Spring Boot 3.1で導入されたDocker Composeサポートについて簡単に見ていきましょう。

情報

不正確な点があればフィードバックをお願いします!

概要

Springフレームワークで開発する際、DB環境をローカルマシンに直接インストールするよりも、Dockerを使用してセットアップする方が一般的なようです。通常のワークフローは以下の通りです:

  1. bootRunの前にdocker runを使用してDBを起動状態にする
  2. bootRunを使用して開発および検証作業を行う
  3. bootRunを停止し、docker stopを使用してコンテナDBを停止する

開発作業の前後にDockerを実行および停止するプロセスは非常に面倒でした。しかし、Spring Boot 3.1からは、docker-compose.yamlファイルを使用してSpringとDockerコンテナのライフサイクルを同期させることができます。

内容

まず、依存関係を追加します:

dependencies {
// ...
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
// ...
}

次に、以下のようにcomposeファイルを作成します:

services:
elasticsearch:
image: 'docker.elastic.co/elasticsearch/elasticsearch:7.17.10'
environment:
- 'ELASTIC_PASSWORD=secret'
- 'discovery.type=single-node'
- 'xpack.security.enabled=false'
ports:
- '9200' # ランダムポートマッピング
- '9300'

image

bootRunの際に、composeファイルが自動的に認識され、docker compose up操作が最初に実行されます。

ただし、コンテナポートをランダムなホストポートにマッピングしている場合、docker compose downがトリガーされるたびにapplication.ymlを更新する必要があるかもしれません。幸いなことに、Spring Boot 3.1からは、composeファイルを書くだけでSpring Bootが残りの作業を引き受けてくれます。非常に便利です!

composeファイルのパスを変更する必要がある場合は、fileプロパティを変更するだけです:

spring:
docker:
compose:
file: infrastructure/compose.yaml

ライフサイクル管理に関連するプロパティもあり、コンテナのライフサイクルを適切に調整できます。Bootをシャットダウンするたびにコンテナを停止したくない場合は、start_onlyオプションを使用できます:

spring:
docker:
compose:
lifecycle-management: start_and_stop # none, start_only

他にもさまざまなオプションがあるので、探求してみると良いでしょう。

image

結論

どれだけテストコードを書いても、実際のDBとの相互作用を検証することは開発プロセスにおいて不可欠でした。その環境をセットアップすることは面倒な作業に感じられました。コンテナ技術により設定は非常に簡単になりましたが、Spring Bootを起動する前後にdockerコマンドを実行することを忘れないようにするのは手間でした。

しかし、Spring Boot 3.1からは、コンテナの起動や停止を忘れることがなくなり、メモリ消費を防ぐことができます。これにより、開発者は開発にもっと集中できるようになります。DockerとSpringのシームレスな統合は非常に魅力的で便利です。ぜひ試してみてください!

参考

1年間のブログ旅

· 9分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

この投稿は私にとって非常に意味深いものです。今年の初めから続けてきたブログ旅の最終エントリーとして、これまでのブログ経験を振り返り、まとめることを目的としています。

ブログプラットフォーム選びの基準

便利に投稿できるプラットフォームを探していて、以下の基準を満たすものを求めていました:

  • Markdownの簡単な使用
  • 便利な画像アップロード
  • 継続的なメンテナンス(特にオープンソースプラットフォームの場合)

TistoryのようなプラットフォームはMarkdownのサポートが不十分で、画像のアップロードが面倒でした。Velogは開発者の間で人気があるものの、最近は放置されているように感じたため、選びませんでした。最終的に、GitHub Pages + JekyllがMarkdownを完全にサポートし、画像のアップロードも簡単で、長期的なメンテナンスが可能であるため、最も合理的な選択だと判断しました。Jekyllの管理にはRubyの知識が必要ですが、基本的な理解があったので、必要に応じて学びながら運用してきました。

SEOの苦労

全てのページをインデックスさせるために努力しましたが、思ったようには進みませんでした。クロールがいつ始まるのか、待ち遠しいです。

しかし、この旅を通じて、SEOの分野を学び、忍耐の重要性を実感しました。ページがインデックスされるまで時間がかかるものの、トラフィックが増えれば自然にインデックスされると信じています。徐々にインデックスされるページ数が増えてきました。コンテンツの公開速度がインデックス速度を上回っているため、Googleのクロールポリシーにより、ページがインデックスされて検索結果に表示されるまでの時間をコントロールできないことを受け入れなければなりません。

image

コンテンツの進化

最初にTistoryでブログを始めたときは、アルゴリズムの問題解決に焦点を当てていました。

image

実務に取り組む中で、アルゴリズムの解決策はアルゴリズム問題解決プラットフォームで説明する方が良いと感じ、単に知識を列挙することは公式ドキュメントを参照するのに比べて冗長だと感じました。私のブログがただの平凡なものになるのは避けたかったのです。

他のブログとは一線を画し、個性的で独自のものにしたいという願望が続き、コンテンツの質と独自性を向上させるために努力してきました。個人的に満足している投稿には、オープンソースプロジェクトを作成する旅概念を読むだけでなく実装するものがあります。

image

情報

2024年には、Docusaurusを使用したブログに進化しました😄。

Obsidianプラグインのオープンソース化

ブログ投稿専用のプラグインとして、O2を開発しました。これはObsidianとJekyllのタスクを連携させるものです。このプラグインを開発するために、TypeScriptも学びました😅。

幸いなことに、2023年7月時点で約400人のユーザーがこのプラグインを使用しています。おそらくほとんどの人が10分以内にアンインストールしたでしょうが... DAU 1...

image

最初は多くのバグがありましたが、現在では多くの小さな問題を解決し、プラグインは安定した段階に入りました。もしObsidianユーザーでJekyllをブログプラットフォームとして使用している方がいれば、このプラグインに興味を持っていただけると嬉しいです!

image

また、Obsidian Discordコミュニティでplugin devの役割を取得し、積極的に参加しています。Obsidianに関する質問があれば、気軽にお尋ねください!

成長指標

ブログを始める際に一貫したモチベーションと方向性を維持するために、Google Analyticsを使用することが重要だと考えました。グラフが徐々に上昇するのを見ると、達成感を感じました。初期のブログ訪問者が少ないことがネガティブな影響を与えるという意見もありますが、個人的にはそれがモチベーションになりました。もっと多くの人にブログを訪れてもらいたいという気持ちが湧きました。

以下は、過去1年間のブログの成長率です。

image

グラフは動的に見えますが、影響力のある多くのブロガーと比べると数字はそれほど高くありません。それが統計のパラドックスです... それでも、全体的な上昇傾向は励みになります。

ライティングプログラムに参加することで、投稿の質により注意を払うようになり、その結果、外部リンクが増え、トラフィックが増加しました。特に、Serfitコミュニティサイトで頻繁にキュレーションされることで、トラフィックが大幅に増加しました。私の平凡な投稿を選んでくれたキュレーターに感謝します。今後も一生懸命に執筆し、作品を磨いていきます。

今後の目標

今年後半と来年の目標をまとめると、以下のようになります:

  1. 単なる知識共有を超えた、高品質で独自性のある実用的な投稿を目指す。
  2. 新規ユーザーを30,000人以上獲得する。
  3. 月に少なくとも2つの投稿を公開する。
  4. 英語学習のために英語での投稿を開始する。

特に英語の投稿に最適なアプローチとプラットフォームを考えています。将来的には英語以外の言語でも投稿したいので、多言語対応を考慮することが重要です。ライティングプログラムを進める中で(第9期に選ばれますように)、これらの計画をさらに洗練させていきます。

これまでの旅にお付き合いいただき、ありがとうございました。今後ともよろしくお願いいたします🙏。

JenkinsでEC2コストを節約する方法

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

特定の時間や条件で実行する必要があるバッチアプリケーションのリソースコストを最適化するための非常にシンプルな方法を共有したいと思います。

問題

  1. バッチは特定の時間にのみ実行されます。例えば、日次、月次、年次などの定期的な計算タスク。
  2. 応答速度は重要ではなく、バッチが実行されることが優先されます。
  3. 特定の時間に必要なリソースのために24時間EC2インスタンスを維持するのは非効率です。
  4. クラウドサーバーのリソースが必要なときだけEC2インスタンスを準備することは可能でしょうか?

もちろん可能です。AWS ECSやAWS EKSなどの自動化ソリューションもありますが、ここではJenkinsを使ってバッチとEC2サーバーを直接管理し、環境を設定する方法を考えます。

アーキテクチャ

このインフラストラクチャ設計により、バッチ実行のためにリソースが必要なときだけコストが発生するようにできます。

Jenkins

Jenkinsノード管理ポリシー

image

キューにリクエストが待機しているときのみノードをアクティブにし、不要なエラーログを最小限に抑えます。また、1分間アクティビティがない場合はアイドル状態に移行します。

AWS CLI

AWS CLIのインストール

AWS CLIを使用すると、ターミナル環境でAWSリソースを管理できます。以下のコマンドを使用して、現在実行中のインスタンスのリストを取得できます:

aws ec2 describe-instances

必要なリソースの情報を確認したら、ターゲットを指定して特定のアクションを実行できます。コマンドは以下の通りです:

EC2の起動

aws ec2 start-instances --instance-ids {instanceId}

EC2の停止

aws ec2 stop-instances --instance-ids {instanceId}

スケジューリング

バッチを月に一度実行するためのcron式を書いて、簡単に設定できます。

image

H 9 1 * *

これで、EC2インスタンスはほとんどの時間停止状態にあり、月に一度Jenkinsによってバッチ処理のために起動されます。

結論

使用していないときにEC2インスタンスを稼働状態にしておくのはコスト面で非効率です。この記事では、Jenkinsと簡単なコマンドを使用して、必要なときだけEC2を利用する方法を示しました。

EKSのような高レベルのクラウドオーケストレーションツールもこのような問題をエレガントに解決できますが、時にはシンプルなアプローチが最も効率的であることもあります。この記事を締めくくるにあたり、あなたの状況に最適な方法を選んでいただければ幸いです。

Spring Batch 5.0の変更点

· 4分の読み時間
Haril Song
Owner, Software Engineer at 42dot

ここでは、Spring Batch 5.0の変更点についてまとめます。

新しい点は?

@EnableBatchProcessingは推奨されなくなりました

@AutoConfiguration(after = { HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class })
@ConditionalOnClass({ JobLauncher.class, DataSource.class, DatabasePopulator.class })
@ConditionalOnBean({ DataSource.class, PlatformTransactionManager.class })
@ConditionalOnMissingBean(value = DefaultBatchConfiguration.class, annotation = EnableBatchProcessing.class) // 5.0から追加されました。
@EnableConfigurationProperties(BatchProperties.class)
@Import(DatabaseInitializationDependencyConfigurer.class)
public class BatchAutoConfiguration {
// ...
}

以前は、@EnableBatchProcessingアノテーションを使用してSpring BatchのSpring Boot自動構成を有効にすることができました。しかし、現在ではこれを削除してSpring Bootの自動構成を使用する必要があります。 @EnableBatchProcessingを指定したり、DefaultBatchConfigurationを継承すると、Spring Bootの自動構成が後回しにされ、アプリケーション設定のカスタマイズに使用されます。

そのため、@EnableBatchProcessingDefaultBatchConfigurationを使用すると、spring.batch.jdbc.initialize-schemaのようなデフォルト設定が機能しなくなります。さらに、Bootが起動したときにジョブが自動的に実行されなくなるため、Runnerの実装が必要です。

複数ジョブの実行がサポートされなくなりました

以前は、バッチ内に複数のジョブがある場合、それらを一度に実行することができました。しかし、現在ではBootが単一のジョブを検出するとそれを実行します。コンテキストに複数のジョブがある場合、Bootを起動する際にspring.batch.job.nameを使用して実行するジョブを指定する必要があります。

JobParameterのサポートが拡張されました

Spring Batch v4では、ジョブパラメータはLongStringDateDoubleの型のみを使用できましたが、v5ではコンバータを実装することで任意の型をJobParameterとして使用できるようになりました。しかし、Spring Batchのデフォルトの変換サービスは依然としてLocalDateLocalDateTimeをサポートしておらず、例外が発生します。デフォルトの変換サービスに対してコンバータを実装することでこれを解決できますが、JobParametersBuilderが関連メソッドを提供しているにもかかわらず、実際には変換が行われず例外が発生するのは問題です。 この問題についてはissueがオープンされており、5.0.1で修正される予定です。

JobParameters jobParameters = jobLauncherTestUtils.getUniqueJobParametersBuilder()
.addLocalDate("date", LocalDate.now()) // このメソッドを使用すると、提供されているにもかかわらず例外が発生します。
.toJobParameters();

image

この問題は2023-02-23にリリースされた5.0.1で解決されました。

initializeSchema

spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres?currentSchema=mySchema
username: postgres
password: 1234
driver-class-name: org.postgresql.Driver
batch:
jdbc:
initialize-schema: always
table-prefix: mySchema.BATCH_
sql:
init:
mode: always

正しく機能させるためにcurrentSchemaオプションを指定してください。

参考文献

[システムデザイン面接] 第5章: 一貫性ハッシュ

· 19分の読み時間
Haril Song
Owner, Software Engineer at 42dot

大規模なシステムを設計するために必要な基本的なコンポーネントは何でしょうか?

この記事では、ルーティングシステムで一般的に使用される一貫性ハッシュを直接実装し、データに基づいて議論します。

情報

完全なコードはGithubで確認できます。

この記事はかなり長いので、説明の便宜上、以降は「~」を使用します。🙏

ハッシュとは?

一貫性ハッシュに入る前に、まずハッシュについて簡単に触れておきましょう。

辞書的な定義によると、ハッシュとは「任意の長さのデータ文字列を入力として受け取り、固定サイズの出力(通常はハッシュ値またはハッシュコード)を生成する数学的関数」です。

簡単に言えば、同じ入力文字列は常に同じハッシュコードを返すということです。このハッシュの特性は、暗号化やファイルの整合性検証など、さまざまな目的で使用されます。

では、一貫性ハッシュとは?

一貫性ハッシュは、分散サーバーやサービス間でデータを均等に分散させるための技術です。

一貫性ハッシュを使用しなくても、データを均等に分散させることは不可能ではありません。しかし、一貫性ハッシュは水平スケーリングを容易にすることに焦点を当てています。一貫性ハッシュを探る前に、簡単なハッシュルーティング方法を通じて一貫性ハッシュがなぜ登場したのかを理解しましょう。

ノードベースのハッシュルーティング方法

hash(key) % n

image

この方法はシンプルでありながら効率的にトラフィックを分散させます。

しかし、水平スケーリングには大きな弱点があります。ノードリストが変更されると、トラフィックが再分配される可能性が高く、新しいノードにルーティングされることになります。

特定のノードでキャッシュを管理している場合、ノードがグループから離れると大規模なキャッシュミスが発生し、サービスの中断を引き起こす可能性があります。

image

4つのノードで実験したところ、1つのノードが離れるだけでキャッシュヒット率が27%に急落することが観察されました。実験方法の詳細は以下の段落で説明します。

一貫性ハッシュルーティング方法

一貫性ハッシュは、大規模なキャッシュミスの可能性を最小限に抑えるために設計された概念です。

image

アイデアはシンプルです。ハッシュ空間の開始と終了をリングで接続し、その上にノードを配置します。各ノードは自分のハッシュ空間を割り当てられ、トラフィックを待ちます。

情報

ノードを配置するために使用されるハッシュ関数は、モジュロ演算とは独立しています。

次に、この一貫性ハッシュを実装したルーターにトラフィックが入る状況を仮定しましょう。

image

ハッシュ関数を通過したトラフィックは、リング上の最も近いノードにルーティングされます。ノードBは将来のリクエストに備えてkey1をキャッシュします。

大量のトラフィックが発生しても、トラフィックは同じ原則に従ってそれぞれのノードにルーティングされます。

一貫性ハッシュの利点

ノードリストが変更されてもキャッシュミスの確率が低い

ノードEが追加される状況を考えてみましょう。

image

以前に入力されたキーは同じポイントに配置されます。ノードDとCの間に配置された一部のキーは新しいノードEを指すようになり、キャッシュミスが発生します。しかし、他のスペースに配置されたキーはキャッシュミスを経験しません。

ネットワークエラーでノードCが消える場合でも、結果は同様です。

image

ノードCに向かっていたキーはノードDにルーティングされ、キャッシュミスが発生します。しかし、他のスペースに配置されたキーはキャッシュミスを経験しません。

結論として、ノードリストに変更があっても、変更されたノードに直接関連するキーだけがキャッシュミスを経験します。これにより、ノードベースのハッシュルーティングと比較してキャッシュヒット率が向上し、システム全体のパフォーマンスが向上します。

一貫性ハッシュの欠点

他のすべての設計と同様に、一見エレガントに見える一貫性ハッシュにも欠点があります。

均一なパーティションの維持が難しい

image 異なるサイズのハッシュ空間を持つノードがリング上に配置されています。

どのキーが生成されるかを知らずにハッシュ関数の結果を予測するのは非常に難しいです。したがって、ハッシュ結果に基づいてリング上の位置を決定する一貫性ハッシュは、ノードが均一なハッシュ空間を持ち、リング上に均等に分散されることを保証できません。

均一な分散の達成が難しい

image ノードのハッシュ空間が異常に広い場合、トラフィックが集中する可能性があります。

この問題は、ノードがハッシュリング上に均等に分散されていないために発生します。ノードDのハッシュ空間が他のノードよりも異常に広い場合、特定のノードにトラフィックが集中し、システム全体の障害を引き起こすホットスポット問題が発生する可能性があります。

仮想ノード

ハッシュ空間は有限です。したがって、ハッシュ空間に配置されるノードの数が多いほど、標準偏差が減少し、1つのノードが削除されても次のノードに大きな負担がかかりません。問題は、現実の世界では物理ノードの数がコストに直結することです。

これに対処するために、物理ノードを模倣する仮想ノードが実装され、これを賢く解決します。

image

仮想ノードは内部的に物理ノードのハッシュ値を指します。これを一種の複製マジックと考えてください。主要な物理ノードはハッシュリング上に配置されず、複製された仮想ノードだけがハッシュリング上でトラフィックを待ちます。トラフィックが仮想ノードに割り当てられると、それはそれが表す実際のノードのハッシュ値に基づいてルーティングされます。

DIY一貫性ハッシュ

DIY: Do It Yourself

これまで理論的な側面を議論してきました。個人的には、概念を学ぶ最良の方法は自分で実装することだと信じています。実装してみましょう。

ハッシュアルゴリズムの選択

名前にハッシュが含まれているので当然のように思えるかもしれませんが、一貫性ハッシュを実装する際には適切なハッシュアルゴリズムを選択することが重要です。ハッシュ関数の速度はパフォーマンスに直接関係します。一般的に使用されるハッシュアルゴリズムはMD5とSHA-256です。

  • MD5: セキュリティよりも速度が重要なアプリケーションに適しています。SHA-256に比べてハッシュ空間が小さいです。2^128
  • SHA-256: より長いハッシュサイズと強力な暗号化特性を持ちます。MD5よりも遅いです。約2^256の非常に大きなハッシュ空間を持ち、衝突はほとんどありません。

ルーティングでは、セキュリティよりも速度が重要であり、ハッシュ衝突の懸念が少ないため、MD5はハッシュ関数の実装に十分と考えられます。

public class MD5Hash implements HashAlgorithm {
MessageDigest instance;

public MD5Hash() {
try {
instance = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("no algorithm found");
}
}

@Override
public long hash(String key) {
instance.reset();
instance.update(key.getBytes());
byte[] digest = instance.digest();
long h = 0;
for (int i = 0; i < 4; i++) {
h <<= 8;
h |= (digest[i]) & 0xFF;
}
return h;
}
}
ヒント

Javaでは、MessageDigestを使用してMD5アルゴリズムを使用したハッシュ関数を便利に実装できます。

ハッシュリング

// businessKeyをハッシュし、リング上に配置されたハッシュ値(ノード)を見つけます。
public T routeNode(String businessKey) {
if (ring.isEmpty()) { // リングが空の場合、ノードがないことを意味するのでnullを返します
return null;
}
Long hashOfBusinessKey = this.hashAlgorithm.hash(businessKey);
SortedMap<Long, VirtualNode<T>> biggerTailMap = ring.tailMap(hashOfBusinessKey);
Long nodeHash;
if (biggerTailMap.isEmpty()) {
nodeHash = ring.firstKey();
} else {
nodeHash = biggerTailMap.firstKey();
}
VirtualNode<T> virtualNode = ring.get(nodeHash);
return virtualNode.getPhysicalNode();
}

ハッシュリングはTreeMapを使用して実装されています。TreeMapはキー(ハッシュ値)を昇順に保持するため、tailMap(key)メソッドを使用してキー(ハッシュ値)より大きい値を見つけ、より大きなキーが見つからない場合は最大のキーに接続できます。

情報

TreeMapに慣れていない場合は、このリンクを参照してください。

テスト

一貫性ハッシュは標準的なルーティング方法と比較してどれほど効果的でしょうか?自分で実装したので、この疑問を解決しましょう。大まかなテスト設計は次のとおりです:

  • 100万件のリクエストを処理し、その後ノードリストに変更を加え、同じトラフィックが再度発生することを仮定します。
  • 4つの物理ノード

数値データは簡単なテストコード1を通じて定量化され、グラフ化すると6つのケースが明らかになりました。それぞれのケースを見てみましょう。

ケース1: シンプルハッシュ、ノード変更なし

image

100万件のリクエストを送信し、その後同じリクエストをもう100万件送信した場合、ノードに変更がなかったため、2回目のリクエスト以降のキャッシュヒット率は100%でした。

情報

キャッシュヒット率が低かったとしても、最初のリクエスト(灰色のグラフ)でキャッシュヒットの可能性があったのは、テストで使用されたキーがランダムであり、重複キーの確率が低かったためです。

ノードごとのグラフの高さを見ると、hash % Nを使用したルーティングがすべてのトラフィックを非常に均等に分散させていることがわかります。

ケース2: シンプルハッシュ、1ノードの離脱

image

緑のグラフで示されるキャッシュヒット率が大幅に低下しました。ノード1が離脱すると、トラフィックはノード2、3、4に分散されました。運良く同じノードでキャッシュヒットしたトラフィックもありましたが、大部分は異なるノードに向かい、キャッシュミスが発生しました。

ケース3: 一貫性ハッシュ、ノード変更なし、仮想ノードなし

image

情報

物理ノードがハッシュリング上に配置されないことを考えると、仮想ノードを1つだけ使用することは実質的に仮想ノードを使用しないことを意味します。

ケース1と同様に、最初のリクエストではキャッシュヒットがすぐに発生しないため、赤いグラフが最初に上昇します。2回目のリクエストではキャッシュヒット率が100%となり、緑と赤のグラフの高さが一致します。

しかし、各ノードのグラフの高さが異なることが観察され、一貫性ハッシュの欠点である均一でないパーティションによるトラフィックの不均等分散が示されています。

ケース4: 一貫性ハッシュ、1ノードの離脱、仮想ノードなし

image

ノード1が離脱した後、キャッシュヒット率はケース2と比較して圧倒的に改善されました。

詳細に見ると、元々ノード1に向かっていたトラフィックが2回目のトラフィック波でノード2に移動したことがわかります。ノード2は約45万件のリクエストを処理し、これはノード3が処理した22万件のリクエストの2倍以上です。一方、ノード3と4へのトラフィックは変わりませんでした。これにより、一貫性ハッシュの利点が示されると同時に、一種のホットスポット現象が強調されました。

ケース5: 一貫性ハッシュ、1ノードの離脱、10仮想ノード

均一なパーティションを達成し、ホットスポット問題を解決するために、仮想ノードを適用してみましょう。

image

全体的にグラフに変化があります。ノード1に向かっていたトラフィックがノード2、3、4に分散されました。パーティションは均等に分配されていませんが、ケース4と比較してホットスポット問題が徐々に解決されていることがわかります。10仮想ノードでは不十分なようなので、さらに増やしてみましょう。

ケース6: 一貫性ハッシュ、1ノードの離脱、100仮想ノード

image

最後に、ノード2、3、4のグラフが似ています。ノード1が離脱した後、各物理ノードに100の仮想ノードがハッシュリング上に配置され、合計300の仮想ノードが存在します。まとめると:

  • ケース1に耐えられるほどトラフィックが均等に分散されていることがわかります。
  • ノード1が離脱しても、ノード1に向かっていたトラフィックが複数のノードに分散され、ホットスポット問題が防止されます。
  • ノード1に向かっていたトラフィック以外はキャッシュヒットが発生します。

十分な数の仮想ノードを配置することで、一貫ハッシュ法を用いたルーティング方法が他の操作に比べて水平スケーリングに非常に有利であることが観察されました。

結論

第5章で説明されている大規模システム設計の基本をもとに、一貫ハッシュ法について検討しました。一貫ハッシュ法が何であるか、そしてなぜ特定の問題を解決するために存在するのかを理解するのに役立ったことを願っています。

別のケースで言及されていませんが、完全に均一な分布を達成するためにいくつの仮想ノードを追加すべきか気になりました。そこで、仮想ノードの数を10,000に増やしてみましたが、仮想ノードを増やしても効果はほとんどありませんでした。理論的には、仮想ノードを増やすと分散はゼロに収束し、均一な分布が達成されるはずです。しかし、仮想ノードを増やすということは、ハッシュリング上に多くのインスタンスが存在することを意味し、不要なオーバーヘッドが発生します。新しいノードが追加されたり削除されたりするたびに、ハッシュリング上の仮想ノードを見つけて整理する作業が必要になります。実運用環境では、データに基づいて適切な仮想ノードの数を設定してください。

参考文献

Footnotes

  1. SimpleHashRouterTest

ガベージコレクションの理解

· 12分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

JVMにおけるガベージコレクション(GC)のトピックについて掘り下げてみましょう。

GCとは?

JVMのメモリは複数の領域に分かれています。

image

ヒープ領域は、newなどの操作で作成されたオブジェクトや配列が格納される場所です。ヒープ領域で作成されたオブジェクトや配列は他のオブジェクトから参照されることがあります。GCはまさにこのヒープ領域で行われます。

Javaプログラムが終了せずに実行を続けると、メモリにデータが蓄積され続けます。GCはこの問題を解決します。

どうやって解決するのでしょうか?JVMは到達不能なオブジェクトをGCの対象として識別します。どのオブジェクトが到達不能になるかを理解するためには、以下のコードを見てみましょう。

public class Main {
public static void main(String[] args) {
Person person = new Person("a", "すぐに参照されなくなる");
person = new Person("b", "参照が維持される");
}
}

personが最初に初期化されると、作成されたaは次の行でbに再割り当てされ、到達不能なオブジェクトになります。次のGCでaはメモリから解放されます。

ストップ・ザ・ワールド

image

ザ・ワールド!時よ止まれ! - ジョジョの奇妙な冒険

アプリケーションの実行を停止してGCを行います。「ストップ・ザ・ワールド」イベントが発生すると、GCを実行しているスレッド以外のすべてのスレッドが一時停止します。 GC操作が完了すると、一時停止していたタスクが再開されます。使用されるGCアルゴリズムに関係なく、「ストップ・ザ・ワールド」イベントは発生し、GCのチューニングは通常、この一時停止状態の時間を短縮することを目的としています。

警告

Javaではプログラムコード内で明示的にメモリを解放することはありません。オブジェクトをnullに設定して解放することは大きな問題ではありませんが、System.gc()を呼び出すとシステムのパフォーマンスに大きな影響を与える可能性があり、絶対に使用すべきではありません。さらに、System.gc()は実際にGCが発生することを保証しません。

GCが発生する2つの領域

Javaでは開発者が明示的にメモリを解放しないため、ガベージコレクタが不要になった(ガベージ)オブジェクトを識別して削除する役割を担います。ガベージコレクタは2つの主要な仮定に基づいて動作します:

  • ほとんどのオブジェクトはすぐに到達不能になる。
  • 古いオブジェクトから若いオブジェクトへの参照は非常に少ない。

ほとんどのオブジェクトはすぐに到達不能になる

for (int i = 0; i < 10000; i++) {
NewObject obj = new NewObject();
obj.doSomething();
}

このループ内で使用される10,000個のNewObjectインスタンスは、ループ外では必要ありません。これらのオブジェクトがメモリを占有し続けると、他のコードを実行するためのリソースが徐々に減少します。

古いオブジェクトから若いオブジェクトへの参照は非常に少ない

以下のコードスニペットを考えてみましょう。

Model model = new Model("value");
doSomething(model);

// modelはもう使用されない

最初に作成されたmodeldoSomething内で使用されますが、その後はあまり使用されることはありません。再利用される場合もありますが、GCはそのようなケースが稀であるという仮定のもとで設計されています。Oracleの統計を見ると、ほとんどのオブジェクトは作成されてからすぐにGCによってクリーンアップされることがわかります。

image

この仮定は弱い世代仮説として知られています。この仮説の利点を最大限に活用するために、HotSpot VMは物理的なスペースを2つの主要な領域に分けています:Young GenerationとOld Generationです。

image

  • Young Generation: この領域には主に新しく作成されたオブジェクトが格納されます。ほとんどのオブジェクトはすぐに到達不能になるため、多くのオブジェクトがYoung Generationで作成され、消滅します。この領域からオブジェクトが消滅すると、Minor GCがトリガーされます。
  • Old Generation: Young Generationで到達不能にならずに生き残ったオブジェクトはOld Generationに移動されます。この領域は通常、Young Generationよりも大きく、GCの頻度も少なくなります。この領域からオブジェクトが消滅すると、Major GC(またはFull GC)がトリガーされます。

Young Generationの各オブジェクトには、Minor GCを生き延びるたびにインクリメントされる年齢ビットがあります。この年齢ビットがMaxTenuringThresholdという設定を超えると、オブジェクトはOld Generationに移動されます。ただし、年齢ビットが設定を超えなくても、Survivorスペースに十分なメモリがない場合、オブジェクトはOld Generationに移動されることがあります。

情報

Permanentスペースは、作成されたオブジェクトのアドレスが格納される場所です。クラスローダーがロードされたクラスやメソッドに関するメタ情報を格納するために使用されます。Java 7以前では、ヒープ内に存在していました。

GCの種類

Old Generationが満杯になるとGCがトリガーされます。異なるGC方法を理解することで、関連する手続きを理解するのに役立ちます。

Serial GC

-XX:+UseSerialGC

Serial GCを理解するためには、まずMark-Sweep-Compactアルゴリズムを理解する必要があります。このアルゴリズムの最初のステップは、Old Generation内の生存オブジェクトを識別することです(Mark)。次に、ヒープの前から後ろまでスイープし、生存オブジェクトだけを保持します(Sweep)。最後のステップでは、オブジェクトが連続して積み重なるようにヒープを前から埋めていき、オブジェクトがあるセクションとないセクションに分けます(Compaction)。

警告

Serial GCはメモリとCPUコアが限られたシステムに適しています。ただし、Serial GCを使用するとアプリケーションのパフォーマンスに大きな影響を与える可能性があります。

Parallel GC

-XX:+UseParallelGC

  • Java 8のデフォルトGC

基本的なアルゴリズムはSerial GCと似ていますが、Parallel GCはYoung GenerationでのMinor GCを複数のスレッドで実行します。

Parallel Old GC

-XX:+UseParallelOldGC

  • Parallel GCの改良版

名前が示すように、このGC方法はOld Generationに関連しています。ParallelGCがYoung Generationのみで複数のスレッドを使用するのに対し、Parallel Old GCはOld Generationでも複数のスレッドを使用してGCを実行します。

CMS GC(Concurrent Mark Sweep)

このGCは、アプリケーションスレッドとGCスレッドが同時に実行されることで「ストップ・ザ・ワールド」時間を最小限に抑えるように設計されています。GCターゲットを識別するための多段階プロセスのため、他のGC方法と比較してCPU使用率が高くなります。

最終的に、CMS GCはJava 9から非推奨となり、Java 14で完全に廃止されました

G1GC(Garbage First)

-XX:+UseG1GC

  • CMS GCを置き換えるためにJDK 7でリリース
  • Java 9以降のデフォルトGC
  • 4GB以上のヒープメモリが必要で、「ストップ・ザ・ワールド」時間が約0.5秒で許容される場合に推奨(小さなヒープの場合は他のアルゴリズムが推奨されます)

G1GCは完全に再設計されたGC方法であり、新しいアプローチが必要です。

Q. G1GCが後のバージョンでデフォルトとなっていることを考えると、以前のCMSと比較しての利点と欠点は何ですか?

  • 利点
    • G1GCはスキャン中にコンパクションを行い、「ストップ・ザ・ワールド」時間を短縮します。
    • 追加の「ストップ・ザ・ワールド」ポーズなしで空きメモリスペースを圧縮する能力を提供します。
    • 文字列重複排除の最適化
    • サイズ、カウントなどのチューニングオプション
  • 欠点
    • Full GC中はシングルスレッドで動作します。
    • 小さなヒープサイズのアプリケーションでは頻繁にFull GCイベントが発生する可能性があります。

Shenandoah GC

-XX:+UseShenandoahGC

  • Java 12でリリース
  • Red Hatによって開発
  • CMSのメモリ断片化問題とG1のポーズ問題に対処
  • 強力な並行性と軽量なGCロジックで知られ、ヒープサイズに関係なく一貫したポーズ時間を保証

image

ZGC

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

  • Java 15でリリース
  • 大規模メモリサイズ(8MBから16TB)の低レイテンシ処理用に設計
  • G1のリージョンに似たZPagesを使用しますが、ZPagesは2MBの倍数で動的に管理されます(大きなオブジェクトに対応するためにリージョンサイズを動的に調整)
  • ZGCの主要な利点の1つは、ヒープサイズに関係なく「ストップ・ザ・ワールド」時間が10msを超えないことです

image

結論

さまざまなGCタイプが利用可能ですが、ほとんどの場合、デフォルトのGCを使用するだけで十分です。GCのチューニングには多大な労力が必要であり、GCログやヒープダンプの分析などのタスクが含まれます。GCログの分析については別の記事で取り上げます。

参考文献

ブログ検索露出のための画像最適化

· 7分の読み時間
Haril Song
Owner, Software Engineer at 42dot

ブログ投稿の自動化プロセスにおいて、SEOのための画像最適化について議論します。これは成功の話ではなく、むしろ失敗の話であり、最終的にはプランBに頼ることになりました。

情報

コードはGitHubで確認できます。

問題の特定

SEO最適化のためには、ブログ投稿内の画像をできるだけ小さくするのが最善です。これにより、検索エンジンのクロールボットの効率が向上し、ページの読み込み速度が速くなり、ユーザーエクスペリエンスにも良い影響を与えます。

では、どの画像フォーマットを使用すべきでしょうか? 🤔

Googleはこの問題に対処するためにWebPという画像フォーマットを開発し、その使用を積極的に推奨しています。広告から利益を得るGoogleにとって、画像の最適化はユーザーがウェブサイトの広告に迅速にアクセスできるようにするため、利益に直結します。

実際、約2.8MBのjpgファイルをwebpに変換すると、約47kbに減少しました。これは50分の1以上の削減です! 多少の画質の低下はありましたが、ウェブページ上ではほとんど気になりませんでした。

image

このレベルの改善があれば、問題を解決するモチベーションは十分です。実装するための情報を集めましょう。

解決へのアプローチ

プランA. O2への機能追加

ブログ投稿のために開発したプラグインO2があります。このプラグインの機能の一部としてWebP変換タスクを含めるのが最も理想的だと考え、まずこのアプローチを試みました。

画像処理で最も有名なライブラリはsharpですが、これはOS依存であり、Obsidianプラグインでは使用できません。これを確認するためにObsidianコミュニティで質問したところ、使用できないという明確な回答を得ました。

image

image

image

関連するコミュニティの会話

sharpが使用できないため、代替としてimageminを使用することにしました。

しかし、重大な問題がありました:imageminはesbuildを実行する際にプラットフォームがnodeであることを要求しますが、Obsidianプラグインはプラットフォームがブラウザであることを要求します。両方のプラットフォームで動作するはずのneutralに設定しても、どちらでも動作しませんでした...

image

O2に適用できる適切なライブラリをすぐに見つけることができなかったため、フォーマット変換タスクを処理するための簡単なスクリプトを実装することにしました。

プランB. npmスクリプト

プラグインに機能を追加する代わりに、Jekyllプロジェクト内で直接スクリプトを使ってフォーマットを簡単に変換できます。

async function deleteFilesInDirectory(dir) {
const files = fs.readdirSync(dir);

files.forEach(function (file) {
const filePath = path.join(dir, file);
const extname = path.extname(filePath);
if (extname === '.png' || extname === '.jpg' || extname === '.jpeg') {
fs.unlinkSync(filePath);
console.log(`remove ${filePath}`);
}
});
}

async function convertImages(dir) {
const subDirs = fs
.readdirSync(dir)
.filter((file) => fs.statSync(path.join(dir, file)).isDirectory());

await imagemin([`${dir}/*.{png,jpg,jpeg}`], {
destination: dir,
plugins: [imageminWebp({quality: 75})]
});
await deleteFilesInDirectory(dir);

for (const subDir of subDirs) {
const subDirPath = path.join(dir, subDir);
await convertImages(subDirPath);
}
}

(async () => {
await convertImages('assets/img');
})();

この方法では、望む機能を迅速に実装できますが、ユーザーがO2の制御外で変更された画像を手動でマークダウン文書に再リンクする必要があります。

この方法を使用する場合、正規表現を使用してすべてのファイルにリンクされた画像の拡張子をwebpに変更し、文書内で画像を再リンクするタスクをスキップすることにしました。

// 省略
async function updateMarkdownFile(dir) {
const files = fs.readdirSync(dir);

files.forEach(function (file) {
const filePath = path.join(dir, file);
const extname = path.extname(filePath);
if (extname === '.md') {
const data = fs.readFileSync(filePath, 'utf-8');
const newData = data.replace(
/(!\^\*]\((.*?)\.(png|jpg|jpeg)\))/g,
(match, p1, p2, p3) => {
return p1.replace(`${p2}.${p3}`, `${p2}.webp`);
}
);
fs.writeFileSync(filePath, newData);
}
});
}

(async () => {
await convertImages('assets/img');
await updateMarkdownFile('_posts');
})();

次に、ブログ投稿を公開する際に実行するスクリプトを書きました。

#!/usr/bin/env bash

echo "画像最適化中...🖼️"
node tools/imagemin.js

git add .
git commit -m "post: publishing"

echo "プッシュ中...📦"
git push origin master

echo "完了! 🎉"
./tools/publish

ターミナルで直接shを実行するのはなんとなくエレガントではないと感じました。package.jsonに追加して、よりクリーンに使用できるようにしましょう。

{
"scripts": {
"publish": "./tools/publish"
}
}
npm run publish

image かなりうまくいきます。

とりあえず、これで結論を出しました。

結論

このプロセスを通じて、ブログ投稿のパイプラインは次のように変わりました:

以前

現在

結果だけを見ると、それほど悪くないように見えますね...? 🤔

画像フォーマット変換機能をO2プラグインの一部として追加したかったのですが、さまざまな理由で適用できませんでした(今のところ)。JSとshを使用する方法は、ユーザーに追加のアクションを要求し、メンテナンスが容易ではありません。この機能を内部的にO2に取り込む方法を一貫して考える必要があります。

参考文献