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

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に取り込む方法を一貫して考える必要があります。

参考文献

良い文章を書くとはどういうことか? - ライティングパイプライン

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

私は主にMarkdownエディタのObsidianを使って執筆し、ブログはGitHub Pagesでホストしています。この2つの異なるプラットフォームで中断せずに書く習慣を維持するために、私の方法を共有します。

情報

この投稿は、Sungyunさんの글또(geultto)でのプレゼンテーションに触発されました。

素材の収集

仕事やサイドプロジェクト、勉強などのさまざまな状況で、知らないトピックに出会うことがよくあります。そのたびに、新しいノートをすぐに作成します1。このノートには、よく知らなかったキーワードに焦点を当てて、1~2行の簡単な要約を書きます。

最初から詳細に整理しようとはしません。まだトピックに詳しくないので、疲れることがあります。また、新しく学んだ情報がすぐに重要であるとは限りません。しかし、後で同じトピックについてのノートを作成しないように、ノートのタイトルやタグに注意を払い、簡単に検索できるようにします。

重要なポイントは、このプロセスが継続的であることです。すでに同じトピックに関するノートが存在する場合、それらは充実していきます。繰り返しを通じて、最終的には良い投稿が生まれます。

最初に作成されたノートは「inbox」というディレクトリに保存されます。

学習と整理

ノートがinboxに積み重なっていきます... それを片付ける必要がありますよね?

有用で整理しやすい素材を見つけたら、そのトピックを勉強し、ラフなドラフトを書きます。この段階では、ブログのためではなく、自分の学習のために書いています。簡単なメモなので、書き方や表現は多少柔軟です。投稿の構造を面白くするために、ユーモアを加えることもあります...

このドラフトを書いた後、それがブログに投稿するのに適しているかどうかを評価します。もし他のコミュニティやブログで過度にカバーされているトピックであれば、差別化のために別途投稿しないことが多いです。

情報

しかし、問題の解決策を紹介したり、個人的な経験を共有したりするような個人的な経験に関連するコンテンツについては、他のブログに似た投稿があっても、自分の感情や視点が異なるため、書くようにしています。

整理された投稿は、backlogディレクトリに移動されます。

ブログ投稿の選定

inboxほどではありませんが、backlogにはある程度完成した投稿が蓄積されます。そこには約10件の投稿がバッファのように存在します。時間が経つと、内容に対する考えが変わり、編集が必要になったり、誤った情報が見つかり再度勉強が必要になったりする場合、いくつかの投稿は再びinboxに降格されます。これは、誤った情報の拡散を防ぐために私が個人的に行う最小限の検証プロセスです。すべての困難を乗り越えた投稿は、個人的な学習投稿から他の人が見るための投稿に洗練されます。

投稿が満足のいくものになったら、「ready」ディレクトリに移動し、ブログの公開準備が整います。

アップロード

アップロードの準備が整ったら、O2を使用して「ready」にあるノートをMarkdown形式に変換し、Jekyllプロジェクトフォルダに移動します。

情報

O2は、Obsidianで書かれたノートをMarkdown形式に変換するためのコミュニティプラグインです。

gif

画像リンクが自動的に変換される様子がわかります。

「ready」にあるノートは、Jekyllプロジェクトに移動する前に公開ディレクトリにコピーされ、バックアップとして保存されます。すべてのObsidian固有の構文は基本的なMarkdownに変換され、添付ファイルがある場合はノートと一緒にJekyllプロジェクトフォルダにコピーされます。添付ファイルのパスが変更されるため、Obsidianで機能していたMarkdownリンクが壊れることがありますが、すべてがO2によって自動化されているため心配いりません2。😄 3

ここで、ツールをObsidianからVScodeに切り替えます。Jekyllブログの管理にはコードを扱う必要があることがあります。これは単純なMarkdownエディタでは対応できないため、Obsidianで作業を続けるといくつかの課題が生じるかもしれません。

文法やコンテキストを簡単に確認し、npm run publishを実行してブログ投稿の公開プロセスを完了します。

情報

公開についての詳細はこの投稿で学ぶことができます。

校正

定期的に投稿を見直し、見逃した文法の誤りや不自然な表現を修正し、徐々に洗練させていきます。このプロセスには明確な終わりはなく、時々ブログをチェックして一貫して修正を行います。

ブログ投稿のパイプラインはここで終了しますが、より良い投稿を書くための活用方法を簡単に説明します。

オプション. データ分析

Obsidianにはグラフビュー機能があります。この機能を利用することで、ノートが有機的にどのように接続されているかを視覚化し、データ分析に活用できます。

image グラフでは、明るい緑色のノードだけがブログに公開された投稿です。

ほとんどのノートはまだ勉強中のトピックや、ブログ公開に至らなかった投稿です。このグラフから以下のことが推測できます:

  • 中心に多くのエッジがあるがブログ投稿として公開されていないノードは、非常に一般的なトピックをカバーしている可能性が高く、公開しないことを選んだものです。または単に怠けていたかもしれません...
  • 外縁に散らばってエッジがないノードは、まだ深く掘り下げていない断片的な知識を表しています。これらはどのトピックにもリンクされていないため、関連するトピックを学んで内部的に接続する必要があります😂。これらのノードは、関連するトピックを学ぶことで内部的にリンクする必要があります。
  • 外縁にあり、公開された投稿は、新しい知識を取得する過程で衝動的に公開された投稿を表しています。衝動的に公開されたため、定期的に見直して内容に誤りがないか確認することが重要です。

この客観的なデータに基づいて、自分がどれだけ知っているか、何を知らないかを定期的に確認し、知識を広げるよう努めています。🧐

結論

この投稿では、私のライティングパイプラインと、Obsidianを使ったデータ分析を通じて自分を正確に理解する方法を紹介しました。書くことが単なる作業ではなく、日常の一部になることを願っています!

  • 新たに浮かんだアイデアを素早くメモすることで、作業の文脈を失わずに書くことができ、一貫して書くことが可能になります。状況に応じて適切なツールを選び、活用しましょう。
  • 書くことが苦痛に感じないようにするためには、毎日数分ずつ一貫して追加する方が、何時間もかけて一から書くよりも効率的です。
  • ブログの公開は面倒な作業になることがあるため、できるだけ自動化してワークフローをシンプルに保ちましょう。執筆に集中しましょう!
  • 自分がどれだけ知っているか、何を知らないかを評価する(自己客観化)。これはブログ投稿のトピックを選定し、学習の方向性を決定するのに大いに役立ちます。
情報

ブログに投稿されていないドラフトを含むすべての執筆は、GitHubで公開されています。


Footnotes

  1. 音楽を勉強していた頃から、常にノートを手元に置いていました。何か思いつくのは寝る直前が一番多かったようです。今もあまり変わりません。バグの解決策も寝る直前に思いつくことが多いです...

  2. O2プラグイン開発ストーリー

  3. 各投稿に新しいバグの問題が追加されることもありますが... 😭 ???: それは機能です

chezmoiを最大限に活用する方法

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

前回の記事に続いて、chezmoiをより効果的に活用する方法をいくつか紹介します。

情報

現在使用している設定はこちらで確認できます。

使い方

chezmoiのコマンドの使い方はchezmoi helpや公式ドキュメントで確認できますが、この記事ではchezmoiをより便利に使うための高度な方法を説明します。

設定

chezmoiは設定ファイルとして~/.config/chezmoi/chezmoi.tomlを使用します。ツール固有の設定が必要な場合、このファイルに定義できます。tomlだけでなく、yamljsonもサポートしているので、慣れた形式で記述できます。公式ドキュメントではtomlを使っているので、ここでもデフォルトとしてtomlを使って説明します。

マージツールとデフォルトエディタの設定

chezmoiのデフォルトエディタはviです。私は主にnvimを使っているので、デフォルトエディタをnvimに変更する方法を紹介します。

chezmoi edit-config
[edit]
command = "nvim"

[merge]
command = "nvim"
args = ["-d", "{{ .Destination }}", "{{ .Source }}", "{{ .Target }}"]

VScodeを使っている場合は、次のように設定できます:

[edit]
command = "code"
args = ["--wait"]

テンプレートを使ったgitconfigの管理

一部の設定を統一するのではなく、環境ごとに異なる設定が必要な場合があります。例えば、仕事用と個人用で異なるgitconfig設定が必要な場合です。このように特定のデータだけを分けたい場合、chezmoiではテンプレートという方法を使って環境変数を注入することができます。

まず、gitconfigファイルを作成します:

mkdir ~/.config/git
touch ~/.config/git/config

gitconfigをテンプレートとして登録し、変数の使用を可能にします:

chezmoi add --template ~/.config/git/config

データの置換が必要な部分を記述します:

chezmoi edit ~/.config/git/config
[user]
name = {{ .name }}
email = {{ .email }}

これらの中括弧はローカル環境で定義された変数で埋められます。デフォルトの変数リストはchezmoi dataで確認できます。

変数をchezmoi.tomlに記述します:

# `chezmoi edit-config`の代わりにローカル設定を記述します。
vi ~/.config/chezmoi/chezmoi.toml
[data]
name = "privateUser"
email = "private@gmail.com"

これらをすべて記述した後、chezmoi apply -vnchezmoi init -vnを使って、テンプレート変数がデータ値で埋められたconfigファイルが生成されるのを確認してみてください。

自動コミットとプッシュ

chezmoi editでdotfilesを編集するだけでは、ローカルリポジトリのgitに変更が自動的に反映されません。

# 手動で行う必要があります。
chezmoi cd
git add .
git commit -m "update something"
git push

このプロセスを自動化するには、chezmoi.tomlに設定を追加する必要があります。

# `~/.config/chezmoi/chezmoi.toml`
[git]
# autoAdd = true
autoCommit = true # add + commit
autoPush = true

ただし、プッシュも自動化すると、機密ファイルが誤ってリモートリポジトリにアップロードされる可能性があります。したがって、個人的にはコミットまでの自動オプションのみを有効にすることをお勧めします

Brewパッケージの管理

仕事で便利なツールを見つけたら、個人環境にもインストールするのを忘れないようにしましょう。chezmoiで管理しましょう。

chezmoi cd
vi run_once_before_install-packages-darwin.sh.tmpl

run_once_はchezmoiが使用するスクリプトキーワードで、一度も実行されていない場合にのみスクリプトを実行したいときに使用します。before_キーワードを使用することで、dotfilesを作成する前にスクリプトを実行できます。これらのキーワードを使用して記述されたスクリプトは、次の2つの場合に実行されます:

  • 初回セットアップ時(これまでに一度も実行されていない場合)
  • スクリプト自体が変更された場合(更新)

これらのキーワードを使用してbrew bundleをスクリプト化することで、すべての環境で統一されたbrewパッケージを持つことができます。以下は私が使用しているスクリプトです:

# MacOSでのみ実行
{{- if eq .chezmoi.os "darwin" -}}
#!/bin/bash

PACKAGES=(
asdf
exa
ranger
chezmoi
difftastic
gnupg
fzf
gh
glab
htop
httpie
neovim
nmap
starship
daipeihust/tap/im-select
)

CASKS=(
alt-tab
shottr
raycast
docker
hammerspoon
hiddenbar
karabiner-elements
obsidian
notion
slack
stats
visual-studio-code
warp
wireshark
google-chrome
)

# Homebrewがインストールされていない場合はインストール
if test ! $(which brew); then
printf '\n\n\e[33mHomebrewが見つかりません。 \e[0mHomebrewをインストールします...'
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
else
printf '\n\n\e[0mHomebrewが見つかりました。続行します...'
fi

# Homebrewパッケージを更新
printf '\nHomebrewの更新を開始します...\n'
brew update

printf '\nパッケージをインストールしています...\n'
brew install ${PACKAGES[@]}

printf '\n\n古いパッケージを削除しています...\n'
brew cleanup

printf '\n\ncaskアプリをインストールしています...\n'
brew install --cask ${CASKS[@]}

{{ end -}}

shに詳しくなくても、それほど難しくないはずです。brew installでインストールするパッケージのリストをPACKAGESに、brew install --caskでインストールするアプリケーションのリストをCASKSに定義します。インストールプロセスはスクリプトによって実行されます。

スクリプト化はchezmoiの機能の中でも比較的複雑な機能です。適用方法はさまざまで、同じ機能を異なる方法で定義することもできます。詳細な使用方法については、公式ドキュメントを参照してください。

結論

この記事では、前回の記事で説明した基本的な使い方に続いて、便利なchezmoiの設定をまとめました。最後に紹介したスクリプトの使用は、基本設定のタイトルに反してやや複雑に見えるかもしれませんが、一度適用すれば非常に便利に使えるようになります。

参考

Chezmoiでドットファイルを便利に管理する方法

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

新しいMacBookを手に入れた後、開発環境を再設定することに圧倒されたことはありませんか?または、仕事中に素晴らしいツールを見つけたけれど、自宅の個人環境で再設定するのが面倒だと感じたことはありませんか?設定をGitHubにプッシュするのをセキュリティの懸念からためらったことはありませんか?

複数のデバイスを使用している場合、これらのジレンマに直面したことがあるかもしれません。異なるプラットフォーム間で設定を一貫して管理するにはどうすれば良いのでしょうか?

問題

さまざまなソフトウェアの設定ファイル(例:.zshrc)は、$HOME(ルート)を含む異なるパスに散在しています。しかし、これらのファイルをバージョン管理するためにルートでGitを設定するのは大変です。広範囲にわたるスキャンが実際にはファイル管理をさらに難しくすることがあります。

仕事用のMacBook、自宅のiMac、個人用のMacBookの3つのデバイスで一貫した開発環境を維持するのは、ほぼ不可能に思えました。

仕事中にVimのショートカットを1つ変更しただけで、仕事が終わった後に他の2つのデバイスでも同じ変更をしなければならないことに気づく... 😭

Apple Silicon時代の到来により、Intel Macと新しいデバイスとの間の大きな違いが、一貫した環境を実現するのをさらに難しくしました。仕事で頻繁に使用するエイリアスを自宅のマシンで設定するのを忘れることが多かったため、この問題について長い間考えていました。

この問題を解決するために試した方法のいくつかは次のとおりです:

  1. ドットファイルを特定のフォルダーに集中させ、Gitプロジェクトとして管理する

    1. ドットファイルの場所はさまざまです。ほとんどの場合、ルートにない場合でも事前に定義された場所があります。
    2. Gitが設定されたフォルダーで直接作業することはできず、他のデバイスにコピー&ペーストする必要があります。
  2. シンボリックリンク

    1. 新しいデバイスでセットアップするには、すべてのファイルのシンボリックリンクを正しい場所に再作成する必要があります(...)。管理するファイルが多い場合、これは面倒な作業です。
    2. Gitよりも使用が複雑で、さまざまな詳細に注意を払う必要があります。

最終的に、Gitメソッドを使用しましたが、ルートにないファイル(~/.ssh/config~/.config/nvimなど)に対してのみで、ルートを使用するファイル(~/.zshrc~/.gitconfigなど)については部分的に諦めていました。しかし、chezmoiを発見するまでのことです!

それでは、この難しい問題をエレガントに解決するchezmoiを紹介します。

Chezmoiとは?

複数の多様なマシン間でドットファイルを安全に管理します。 - chezmoi.io

Chezmoiは、さまざまな環境やデバイス間で多数のドットファイルを一貫して管理できるツールです。公式ドキュメントに記載されているように、いくつかの設定を行うだけで「セキュリティ」を確保できます。ドットファイルがどこにあるか、どこに配置すべきかを心配する必要はありません。chezmoiに管理するドットファイルを伝えるだけで済みます。

コンセプト

この一見魔法のような偉業はどのように可能なのでしょうか? 🤔

本質的に、chezmoiはドットファイルを~/.local/share/chezmoiに保存し、chezmoi applyを実行すると、各ドットファイルの状態をチェックし、最小限の変更を加えて希望する状態に一致させます。詳細なコンセプトについては、リファレンスマニュアルを参照してください。

それでは、簡単に使い方を説明します。

Chezmoiの始め方

chezmoiをインストールしたら(インストールガイドはこちら)、次のコマンドで初期化を行います:

chezmoi init

この操作により、ローカルデバイスの~/.local/share/chezmoi(作業ディレクトリ)に新しいGitリポジトリが作成され、ドットファイルが保存されます。デフォルトでは、chezmoiはローカルデバイスの作業ディレクトリに変更を反映します。

~/.zshrcファイルをchezmoiで管理したい場合は、次のコマンドを実行します:

chezmoi add ~/.zshrc

~/.zshrcファイルが~/.local/share/chezmoi/dot_zshrcにコピーされたことがわかります。

chezmoiで管理されている~/.zshrcファイルを編集するには、次のコマンドを使用します:

chezmoi edit ~/.zshrc

このコマンドは、$EDITOR~/.local/share/chezmoi/dot_zshrcを開いて編集します。テストのためにいくつかの変更を加えて保存します。

情報

環境変数に$EDITORが設定されていない場合、デフォルトでviが使用されます。

作業ディレクトリでどのような変更が行われたかを確認するには、次のコマンドを使用します:

chezmoi diff

chezmoiによってローカルデバイスに適用された変更を反映するには、次のコマンドを使用します:

chezmoi apply -v

すべてのchezmoiコマンドは-v(詳細)オプションを使用できます。このオプションは、ローカルデバイスに適用される内容を視覚的に表示し、コンソールで明確にします。-n(ドライラン)オプションを使用すると、コマンドを適用せずに実行できます。したがって、-v-nオプションを組み合わせることで、見慣れないコマンドを実行する前にどのようなアクションが取られるかをプレビューできます。

それでは、ソースディレクトリに直接アクセスし、chezmoiの内容をリモートリポジトリにプッシュしましょう。リポジトリ名をdotfilesにすることをお勧めします。後で説明します。

chezmoi cd
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/$GITHUB_USERNAME/dotfiles.git
git push
ヒント

chezmoi.tomlファイルに関連設定を書き込むことで、リポジトリの同期プロセスを自動化し、より便利に使用できます。

chezmoiの作業ディレクトリを終了するには、次のコマンドを使用します:

exit

ここまでのプロセスを視覚化すると、次のようになります:

image

別のデバイスでChezmoiを使用する

これがchezmoiを使用する理由です。chezmoiを使用して2台目のデバイスに内容を取得しましょう。この例ではSSH URLを使用しています。2台目のデバイスにchezmoiがすでにインストールされていると仮定します。

chezmoi init git@github.com:$GITHUB_USERNAME/dotfiles.git

特定のリポジトリで初期化することで、chezmoiは自動的にサブモジュールや必要な外部ソースファイルをチェックし、オプションに基づいてchezmoiの設定ファイルを生成します。

先ほど見たdiffコマンドを使用して、chezmoiが2台目のデバイスにどのような変更をもたらすかを確認します。

chezmoi diff

すべての変更を適用することに満足している場合は、先ほど説明したapplyコマンドを使用します。

chezmoi apply -v

ローカルに適用する前にいくつかのファイルを変更する必要がある場合は、editを使用します。

chezmoi edit $FILE

または、マージツールを使用してGitマージのようにローカルの変更を適用することもできます。

chezmoi merge $FILE
ヒント

chezmoi merge-allを使用すると、マージが必要なすべてのファイルに対してマージ操作を実行できます。

これらの手順をすべて一度に実行するには、次のコマンドを使用します:

chezmoi update -v

このプロセスを視覚化すると、次のようになります:

image

初期化時に2台目のデバイスで必要なすべての手順を適用することもできます...!この機能は、2台目のデバイスが新しく購入したものである場合に非常に便利です。

chezmoi init --apply https://github.com/$GITHUB_USERNAME/dotfiles.git

リポジトリ名をdotfilesにすることをお勧めした理由は、リポジトリがdotfilesという名前であれば、前述のコマンドの短縮版を使用できるからです。

chezmoi init --apply $GITHUB_USERNAME

image

本当に便利です...🥹 2023年に発見された最高のオープンソースツールの1つになると信じています。

結論

chezmoiは非常に良く文書化されており、活発に開発されています。Golangで開発されているため、非常に高速に感じます 😄。シェルスクリプトの知識があれば、高度に自動化されたプロセスを実装し、複数のデバイス間で設定にほとんど介入する必要のない環境を作成できます。

この記事では、chezmoiの基本的な使い方を紹介しました。次の記事では、chezmoiの設定ファイルの管理とセキュリティの維持について詳しく説明します。

情報

私の設定に興味がある場合は、こちらで確認できます。

参考文献

Spring Batchで複合キーを使用したページネーションの最適化

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

この記事では、Spring Batchを使用して数百万件のデータを持つテーブルをクエリする際に直面した問題とその解決策について説明します。

環境

  • Spring Batch 5.0.1
  • PostgreSQL 11

問題

JdbcPagingItemReaderを使用して大規模なテーブルをクエリしていると、時間の経過とともにクエリのパフォーマンスが著しく低下することに気付き、コードを詳細に調査することにしました。

デフォルトの動作

PagingQueryProviderによって自動生成され実行されるクエリは次の通りです:

SELECT *
FROM large_table
WHERE id > ?
ORDER BY id
LIMIT 1000;

Spring Batchでは、JdbcPagingItemReaderを使用する際にオフセットを使用する代わりに、ページネーションのためのwhere句を生成します。これにより、数百万件のレコードを持つテーブルからでも遅延なくデータを高速に取得できます。

ヒント

LIMITを使用しても、OFFSETを使用すると以前のデータをすべて再度読み込むことになります。そのため、読み込むデータ量が増えるとパフォーマンスが低下します。詳細については、この記事1を参照してください。

複数のソート条件を使用する場合

複合キーを使用するテーブルをクエリする際に問題が発生します。3つのカラムからなる複合キーをソートキーとして使用すると、生成されるクエリは次のようになります:

SELECT *
FROM large_table
WHERE ((create_at > ?) OR
(create_at = ? AND user_id > ?) OR
(create_at = ? AND user_id = ? AND content_no > ?))
ORDER BY create_at, user_id, content_no
LIMIT 1000;

しかし、where句にOR操作が含まれるクエリはインデックスを効果的に利用できません。OR操作は複数の条件を実行する必要があり、オプティマイザが正確な判断を下すのが難しくなります。explainの出力を調べたところ、次のような結果が得られました:

Limit  (cost=0.56..1902.12 rows=1000 width=327) (actual time=29065.549..29070.808 rows=1000 loops=1)
-> Index Scan using th_large_table_pkey on large_table (cost=0.56..31990859.76 rows=16823528 width=327) (actual time=29065.547..29070.627 rows=1000 loops=1)
" Filter: ((""create_at"" > '2023-01-28 06:58:13'::create_at without time zone) OR ((""create_at"" = '2023-01-28 06:58:13'::create_at without time zone) AND ((user_id)::text > '441997000'::text)) OR ((""create_at"" = '2023-01-28 06:58:13'::create_at without time zone) AND ((user_id)::text = '441997000'::text) AND ((content_no)::text > '9070711'::text)))"
Rows Removed by Filter: 10000001
Planning Time: 0.152 ms
Execution Time: 29070.915 ms

クエリの実行時間が30秒近くかかり、インデックス上でフィルタリング中にほとんどのデータが破棄され、不要な時間が浪費されています。

PostgreSQLは複合キーをタプルとして管理しているため、タプルを使用してクエリを書くことで、複雑なwhere句でもインデックススキャンの利点を活用できます。

SELECT *
FROM large_table
WHERE (create_at, user_id, content_no) > (?, ?, ?)
ORDER BY create_at, user_id, content_no
LIMIT 1000;
Limit  (cost=0.56..1196.69 rows=1000 width=327) (actual time=3.204..11.393 rows=1000 loops=1)
-> Index Scan using th_large_table_pkey on large_table (cost=0.56..20122898.60 rows=16823319 width=327) (actual time=3.202..11.297 rows=1000 loops=1)
" Index Cond: (ROW(""create_at"", (user_id)::text, (content_no)::text) > ROW('2023-01-28 06:58:13'::create_at without time zone, '441997000'::text, '9070711'::text))"
Planning Time: 0.276 ms
Execution Time: 11.475 ms

フィルタリングによってデータを破棄することなく、インデックスを通じて直接データが取得されていることがわかります。

したがって、JdbcPagingItemReaderが実行するクエリがタプルを使用する場合、複合キーをソートキーとして使用しても、非常に迅速に処理を行うことができます。

では、コードに入りましょう。

PagingQueryProviderの修正

分析

前述のように、クエリ生成の責任はPagingQueryProviderにあります。私はPostgreSQLを使用しているため、PostgresPagingQueryProviderが選択され使用されています。

image 生成されるクエリは、group by句を含むかどうかによって異なります。

SqlPagingQueryUtilsbuildSortConditionsを調べると、問題のあるクエリがどのように生成されるかがわかります。

image

ネストされたforループ内で、ソートキーに基づいてクエリが生成される様子がわかります。

buildSortConditionsのカスタマイズ

クエリ生成の責任を持つコードを直接確認した後、このコードを修正して望ましい動作を実現することにしました。しかし、このコードを直接オーバーライドすることはできないため、PostgresOptimizingQueryProviderという新しいクラスを作成し、このクラス内でコードを再実装しました。

private String buildSortConditions(StringBuilder sql) {
Map<String, Order> sortKeys = getSortKeys();
sql.append("(");
sortKeys.keySet().forEach(key -> sql.append(key).append(", "));
sql.delete(sql.length() - 2, sql.length());
if (is(sortKeys, order -> order == Order.ASCENDING)) {
sql.append(") > (");
} else if (is(sortKeys, order -> order == Order.DESCENDING)) {
sql.append(") < (");
} else {
throw new IllegalStateException("Cannot mix ascending and descending sort keys"); // タプルの制限
}
sortKeys.keySet().forEach(key -> sql.append("?, "));
sql.delete(sql.length() - 2, sql.length());
sql.append(")");
return sql.toString();
}

テストコード

新しく実装した部分が正しく動作することを確認するために、テストコードを通じて検証しました。

@Test
@DisplayName("Offsetの代わりに生成されるWhere句は(create_at, user_id, content_no) > (?, ?, ?)です。")
void test() {
// given
PostgresOptimizingQueryProvider queryProvider = new PostgresOptimizingQueryProvider();
queryProvider.setSelectClause("*");
queryProvider.setFromClause("large_table");

Map<String, Order> parameterMap = new LinkedHashMap<>();
parameterMap.put("create_at", Order.ASCENDING);
parameterMap.put("user_id", Order.ASCENDING);
parameterMap.put("content_no", Order.ASCENDING);
queryProvider.setSortKeys(parameterMap);

// when
String firstQuery = queryProvider.generateFirstPageQuery(10);
String secondQuery = queryProvider.generateRemainingPagesQuery(10);

// then
assertThat(firstQuery).isEqualTo("SELECT * FROM large_table ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 10");
assertThat(secondQuery).isEqualTo("SELECT * FROM large_table WHERE (create_at, user_id, content_no) > (?, ?, ?) ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 10");
}

image

正常に実行されることを確認し、バッチを実行しました。

image

Guy: "終わったのか?"

Boy: "黙れ、そんなこと言ったらまた起きるって!"

しかし、out of rangeエラーが発生し、クエリが変更されたことが認識されていないことがわかりました。

image

クエリが変更されたからといって、パラメータの注入部分が自動的に認識されるわけではないようなので、再度デバッグしてパラメータの注入部分を見つけましょう。

JdbcOptimizedPagingItemReader

パラメータはJdbcPagingItemReaderによって直接作成され、JdbcPagingItemReadergetParameterListを繰り返してSQLに注入するパラメータの数が増えることがわかりました。

image

このメソッドをオーバーライドするだけで済むと思いましたが、残念ながらそれは不可能です。多くの考えの末、JdbcPagingItemReader全体をコピーし、getParameterList部分だけを修正しました。

JdbcOptimizedPagingItemReaderでは、getParameterListメソッドが次のようにオーバーライドされています:

private List<Object> getParameterList(Map<String, Object> values, Map<String, Object> sortKeyValue) {
// ...
// where句に設定する必要があるパラメータを増やさずに返します。
return new ArrayList<>(sortKeyValue.values());
}

sortKeyValueを追加する必要はないので、直接parameterListに追加して返します。

では、再度バッチを実行してみましょう。

最初のクエリはパラメータを必要とせずに実行されます、

2023-03-13T17:43:14.240+09:00 DEBUG 70125 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL query [SELECT * FROM large_table ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 2000]

次のクエリ実行は前のクエリからパラメータを受け取ります。

2023-03-13T17:43:14.253+09:00 DEBUG 70125 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT * FROM large_table WHERE (create_at, user_id, content_no) > (?, ?, ?) ORDER BY create_at ASC, user_id ASC, content_no ASC LIMIT 2000]

クエリは意図した通りに実行されました! 🎉

1000万件以上のレコードを持つページネーション処理では、以前は約30秒かかっていたクエリが0.1秒程度で実行されるようになり、約300倍のパフォーマンス向上を実現しました。

image

これで、データ量に関係なく、パフォーマンスの低下を心配することなくミリ秒単位でクエリを読み取ることができます。 😎

結論

この記事では、複合キーを持つ環境でSpring Batchを最適化する方法を紹介しました。しかし、この方法には欠点があります。それは、複合キーを構成するすべてのカラムが同じソート条件を持たなければならないことです。複合キーによって生成されるインデックス条件内でdescascが混在している場合、この問題を解決するために別のインデックスを使用する必要があります 😢

今日の内容を一行でまとめて記事を締めくくりましょう。

「複合キーの使用をできるだけ避け、ビジネスに関連しない代替キーを使用する。」

参考文献


Footnotes

  1. https://jojoldu.tistory.com/528

O2におけるデザインパターンを用いたコード生産性の向上

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

この記事では、O2プロジェクトの構造をデザインパターンを用いて改善し、より柔軟な管理を実現するプロセスについて説明します。

問題

開発に勤しんでいると、ある日突然Issueが提起されました。

image

Issueの内容を反映するのは難しくありませんでした。しかし、コードを掘り下げていくうちに、しばらく放置していた問題が浮上してきました。

image

以下は、以前に書かれたMarkdown構文変換コードの実装です。

警告

コードが長いため、一部抜粋しています。完全なコードはO2プラグインv1.1.1をご参照ください 🙏

export async function convertToChirpy(plugin: O2Plugin) {
try {
await backupOriginalNotes(plugin);
const markdownFiles = await renameMarkdownFile(plugin);
for (const file of markdownFiles) {
// 二重角括弧を削除
const title = file.name.replace('.md', '').replace(/\s/g, '-');
const contents = removeSquareBrackets(await plugin.app.vault.read(file));
// リソースリンクをjekyllリンクに変換
const resourceConvertedContents = convertResourceLink(plugin, title, contents);

// コールアウト
const result = convertCalloutSyntaxToChirpy(resourceConvertedContents);

await plugin.app.vault.modify(file, result);
}

await moveFilesToChirpy(plugin);
new Notice('Chirpy変換が完了しました。');
} catch (e) {
console.error(e);
new Notice('Chirpy変換に失敗しました。');
}
}

TypeScriptやObsidianの使用に不慣れだったため、全体のデザインよりも機能の実装に重点を置いていました。新しい機能を追加しようとすると、副作用を予測するのが難しく、コードの実装が開発者の意図を明確に伝えることができませんでした。

コードの流れをよりよく理解するために、現在のプロセスのグラフを作成しました。

機能を関数に分けたものの、コードは依然として手続き的に書かれており、コード行の順序が全体の動作に大きく影響していました。この状態で新しい機能を追加するには、全体の変換プロセスを壊さないように正確に実装する必要があります。新しい機能をどこに実装すればよいのか?その答えはおそらく「コードを見なければならない」でしょう。現在、ほとんどのコードが一つの大きなファイルに書かれているため、全体のコードを分析する必要があるのとほぼ同じです。オブジェクト指向の観点から言えば、単一責任の原則 (SRP) が適切に守られていないと言えます。

この状態は、どれだけ前向きに表現しても、メンテナンスが容易ではないように思えました。O2プラグインは個人的な使用のために作成されたものなので、「TSに不慣れだから」と合理化して、メンテナンスが難しいスパゲッティコードを生産することを正当化することはできませんでした。

Issueを解決する前に、まず構造を改善することにしました。

どのような構造を実装すべきか?

O2プラグインは、構文変換プラグインとして、ObsidianのMarkdown構文をさまざまな形式に変換できる必要があります。これは明確な要件です。

したがって、デザインは主に拡張性に焦点を当てるべきです。

プラットフォームロジックをモジュール化し、変換プロセスを抽象化してテンプレートのように実装する必要があります。これにより、異なるプラットフォームの構文をサポートする新しい機能を実装する際に、開発者は構文変換の小さな単位の実装に集中でき、全体のフローを再実装する必要がなくなります。

これに基づいて、デザイン要件は次のようになります:

  1. 文字列(Markdownファイルの内容)は必要に応じて順番に(または順番に)変換されるべきです。
  2. 特定の変換ロジックはスキップ可能であり、外部設定に基づいて動的に制御可能であるべきです。
  3. 新しい機能の実装は簡単であり、既存のコードに最小限の影響しか与えないべきです。

実行の順序があり、機能を追加する能力があるため、責任の連鎖パターンがこの目的に適しているように思えました。

デザインパターンの適用

プロセス->プロセス->プロセス->完了! : 責任の連鎖の要約

export interface Converter {
setNext(next: Converter): Converter;
convert(input: string): string;
}

export abstract class AbstractConverter implements Converter {
private next: Converter;

setNext(next: Converter): Converter {
this.next = next;
return next;
}

convert(input: string): string {
if (this.next) {
return this.next.convert(input);
}
return input;
}
}

Converterインターフェースは、convert(input)を通じて特定の文字列を変換する役割を果たします。setNextで次に処理するConverterを指定し、再びConverterを返すことで、メソッドチェーンを使用できます。

抽象化が行われたことで、以前は一つのファイルに実装されていた変換ロジックが、各機能に責任を持つ個々のConverter実装に分離されました。以下は、コールアウト構文変換ロジックを分離したCalloutConverterの例です。

export class CalloutConverter extends AbstractConverter {
convert(input: string): string {
const result = convertCalloutSyntaxToChirpy(input);
return super.convert(result);
}
}

function convertCalloutSyntaxToChirpy(content: string) {
function replacer(match: string, p1: string, p2: string) {
return `${p2}\n{: .prompt-${replaceKeyword(p1)}}`;
}

return content.replace(ObsidianRegex.CALLOUT, replacer);
}

現在、クラス間の関係は次のようになっています。

現在、各Converterに実装された最小単位の機能を組み合わせることで、順番に操作を行うチェーンが作成されます。これが、このパターンが責任の連鎖と呼ばれる理由です。

export async function convertToChirpy(plugin: O2Plugin) {
// ...
// 変換チェーンを作成
frontMatterConverter.setNext(bracketConverter)
.setNext(resourceLinkConverter)
.setNext(calloutConverter);

// 先頭のfrontMatterConverterに変換を依頼し、接続されたコンバータが順次動作します。
const result = frontMatterConverter.convert(await plugin.app.vault.read(file));
await plugin.app.vault.modify(file, result);
// ...
}

現在、ロジックが適切な責任に分離されているため、コードの読み取りが非常に簡単になりました。新しい機能を追加する必要がある場合は、必要なConverterを実装するだけで済みます。また、他のConverterがどのように動作するかを知る必要がなく、setNextを通じて新しい機能を追加できます。各Converterは独立して動作し、カプセル化の原則に従います。

最後に、すべてのテストが通過したことを確認し、PRを作成しました。

image

次のステップ

構造が大幅に改善されたものの、まだ一つの欠点が残っています。setNextでリンクされた構造では、正しく動作するためには最前のConverterを呼び出す必要があります。最前のConverterではなく、別のConverterを呼び出すと、意図した結果とは異なる結果になる可能性があります。例えば、NewConverterfrontMatterConverterの前に実装されているが、frontMatterConverter.convert(input)が変更されていない場合、NewConverterは適用されません。

これは開発者が注意を払う必要がある点であり、エラーの余地があるため、将来的に改善が必要な領域の一つです。例えば、Converterを含むContextのようなものを実装し、直接Converterを呼び出すことなく変換プロセスを実行する方法が考えられます。これは次のバージョンで実装する予定です。


2023-03-12 アップデート

PRのおかげで、同じ機能が継承ではなくコンポジションを使用して、より柔軟な構造で実行されました。

結論

この記事では、手続き的に書かれたモノリシックなファイルから、デザインパターンを通じて役割と責任を再分配し、よりオブジェクト指向でメンテナンスしやすいコードに改善するプロセスを説明しました。

情報

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

JdbcItemReaderでsortKeysを設定する際の注意点

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

PostgreSQLで大量のデータを取得する際に遭遇した問題について共有したいと思います。

問題

Spring BatchのJdbcPagingItemReaderを使用している際、以下のようにsortKeysを設定しました:

...
.selectClause("SELECT *")
.fromClause("FROM big_partitioned_table_" + yearMonth)
.sortKeys(Map.of(
"timestamp", Order.ASCENDING,
"mmsi", Order.ASCENDING,
"imo_no", Order.ASCENDING
)
)
...

現在のテーブルのインデックスはtimestampmmsiimo_noの複合インデックスとして設定されているため、データ取得時にインデックススキャンが行われると期待していました。しかし、実際にはSeqスキャンが発生しました。対象のテーブルには約2億件のレコードが含まれており、バッチ処理が完了する兆しが見えなかったため、最終的にバッチを強制終了する必要がありました。なぜインデックス条件でクエリを実行しているのにSeqスキャンが発生したのでしょうか?🤔

PostgreSQLでは、以下のような場合にSeqスキャンが発生します:

  • テーブルのデータ量が少ないため、オプティマイザがSeqスキャンの方が速いと判断した場合
  • クエリ対象のデータ量が多すぎる(テーブルの10%以上)場合、オプティマイザがインデックススキャンよりもSeqスキャンの方が効率的だと判断した場合
    • このような場合、limitを使用してデータ量を調整し、インデックススキャンを実行することができます

このケースでは、select *を使用していたため、大量のデータをクエリすることでSeqスキャンが発生する可能性がありました。しかし、chunk sizeのためにクエリはlimit付きで実行されていたため、インデックススキャンが連続して発生すると考えていました。

デバッグ

正確な原因を特定するために、実際に実行されているクエリを確認しましょう。YAML設定を少し変更することで、JdbcPagingItemReaderが実行するクエリを観察できます。

logging:
level.org.springframework.jdbc.core.JdbcTemplate: DEBUG

バッチプロセスを再実行して、クエリを直接観察しました。

SELECT * FROM big_partitioned_table_202301 ORDER BY imo_no ASC, mmsi ASC, timestamp ASC LIMIT 1000

order by句の順序が奇妙に見えたので、再度実行しました。

SELECT * FROM big_partitioned_table_202301 ORDER BY timestamp ASC, mmsi ASC, imo_no ASC LIMIT 1000

実行ごとにorder by条件の順序が変わっていることが明らかでした。

インデックススキャンを確実に行うためには、ソート条件を正しい順序で指定する必要があります。一般的なMapsortKeysに渡すと順序が保証されないため、SQLクエリが意図した通りに実行されません。

順序を維持するためには、LinkedHashMapを使用してsortKeysを作成することができます。

Map<String, Order> sortKeys = new LinkedHashMap<>();
sortKeys.put("timestamp", Order.ASCENDING);
sortKeys.put("mmsi", Order.ASCENDING);
sortKeys.put("imo_no", Order.ASCENDING);

この調整を行い、バッチを再実行したところ、ソート条件が正しい順序で指定されていることを確認できました。

SELECT * FROM big_partitioned_table_202301 ORDER BY timestamp ASC, mmsi ASC, imo_no ASC LIMIT 1000

結論

Seqスキャンがインデックススキャンの代わりに発生する問題は、アプリケーションのテストコードでは検証できないため、潜在的なバグに気づくことができませんでした。実際の運用環境でバッチ処理の大幅な遅延を観察して初めて、何かが間違っていることに気づきました。開発中には、Mapデータ構造によってソート条件の順序が変わる可能性を予想していませんでした。

幸いにも、大量のデータをクエリするためにインデックススキャンが発生しない場合、LIMITクエリでバッチ処理が大幅に遅くなるため、問題に気づきやすくなります。しかし、データ量が少なく、インデックススキャンとSeqスキャンの実行速度が似ている場合、問題に気づくまでに時間がかかるかもしれません。

この問題を事前に予測し対処する方法について、さらに検討が必要です。order by条件の順序が重要な場合が多いため、可能な限りHashMapではなくLinkedHashMapを使用することをお勧めします。