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

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を使用することをお勧めします。

[O2] Obsidianプラグインの開発

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

概要

ObsidianはMarkdownファイル間のリンクを通じてグラフビューを提供し、情報の保存とナビゲーションが便利です。しかし、これを実現するために、Obsidianは独自のシンタックスを元のMarkdownシンタックスに加えて強制します。これにより、他のプラットフォームでObsidianのMarkdownドキュメントを読む際に互換性の問題が生じることがあります。

現在、私はJekyllブログを使用して投稿しているため、Obsidianで書いた後、ブログ公開のためにシンタックスを手動で調整する必要があります。具体的なワークフローは以下の通りです:

  • ファイルリンクに[[ ]]を使用する(これはObsidianの独自シンタックス)
  • 画像ファイルを含む添付ファイルのパスをリセットする
  • title.mdyyyy-MM-dd-title.mdにリネームする
  • コールアウトシンタックス

image レイヤー境界を越える二重矢印は手動での介入が必要です。

ObsidianとJekyllを併用しているため、このシンタックス変換プロセスと添付ファイルのコピーを自動化する必要がありました。

ObsidianはNotionとは異なり、コミュニティプラグインを通じて機能拡張が可能なので、自分でプラグインを作成してみることにしました。公式ドキュメントを確認したところ、ObsidianはNodeJSをベースにしたプラグイン開発をガイドしていることがわかりました。言語の選択肢は限られていましたが、TypeScriptに興味があったので、NodeJS/TS環境をセットアップして学習を始めました。

実装プロセス

ネーミング

まず、開発の最も重要な部分に取り組みました。

思ったよりも時間はかからず、プラグインの説明「ObsidianのシンタックスをJekyllに変換する」を書いているうちに突然「O2」というプロジェクト名が思い浮かびました。

image

変換の準備

適切な名前が決まったので、次にどのファイルをどのように変換するかを決定しました。

ブログ投稿のワークフローは以下の通りです:

  1. readyというフォルダに下書きを書く。
  2. 原稿が完成したら、添付ファイルを含むファイルをJekyllプロジェクトにコピーし、その過程でObsidianシンタックスをJekyllシンタックスに適切に変換する。
  3. 原稿をreadyフォルダからpublishedに移動し、公開済みであることを示す。

このワークフローをそのままプログラムすることにしました。ただし、VScodeで開いているJekyllプロジェクトのオリジナルファイルを編集する代わりに、プラグインワークスペース内でコピーを作成して変更し、オリジナルファイルを変更せずにJekyllシンタックスに変換することにしました。

このステップを簡単にまとめると:

  1. /readyから原稿A.mdをコピーして/publishedに移動し、/published/A.mdを変更しない。
  2. /ready/A.mdのタイトルとシンタックスを変換する。
  3. /ready/yyyy-MM-dd-A.mdをJekyll公開用のパスに移動する。

では、実装を始めましょう。

オリジナルファイルのコピー

// readyフォルダ内のMarkdownファイルのみを取得
function getFilesInReady(plugin: O2Plugin): TFile[] {
return this.app.vault.getMarkdownFiles()
.filter((file: TFile) => file.path.startsWith(plugin.settings.readyDir))
}

// ファイルをpublishedフォルダにコピー
async function copyToPublishedDirectory(plugin: O2Plugin) {
const readyFiles = getFilesInReady.call(this, plugin)
readyFiles.forEach((file: TFile) => {
return this.app.vault.copy(file, file.path.replace(plugin.settings.readyDir, plugin.settings.publishedDir))
})
}

/readyフォルダ内のMarkdownファイルを取得し、file.pathpublishedDirに置き換えることで、簡単にコピーができます。

添付ファイルのコピーとパスのリセット

function convertResourceLink(plugin: O2Plugin, title: string, contents: string) {
const absolutePath = this.app.vault.adapter.getBasePath()
const resourcePath = `${plugin.settings.jekyllResourcePath}/${title}`
fs.mkdirSync(resourcePath, {recursive: true})

const relativeResourcePath = plugin.settings.jekyllRelativeResourcePath

// resourceDir/image.pngをassets/img/<title>/image.pngにコピーする前に変更
extractImageName(contents)?.forEach((resourceName) => {
fs.copyFile(
`${absolutePath}/${plugin.settings.resourceDir}/${resourceName}`,
`${resourcePath}/${resourceName}`,
(err) => {
if (err) {
new Notice(err.message)
}
}
)
})
// シンタックス変換
return contents.replace(ObsidianRegex.IMAGE_LINK, `![image](/${relativeResourcePath}/${title}/$1)`)
}

添付ファイルはボルト外に移動する必要があり、これはObsidianのデフォルトAPIでは実現できません。そのため、fsを使用して直接ファイルシステムにアクセスする必要があります。

情報

ファイルシステムへの直接アクセスはモバイルでの使用が難しいことを意味するため、Obsidianの公式ドキュメントでは、そのような場合にmanifest.jsonisDesktopOnlytrueに指定することをガイドしています。

MarkdownファイルをJekyllプロジェクトに移動する前に、Obsidianの画像リンクシンタックスを解析して画像ファイル名を特定し、それらをJekyllのresourceフォルダに移動してMarkdownデフォルトの画像リンクが正しく変換されるようにします。これにより、添付ファイルが見つかるようになります。

コールアウトシンタックスの変換

Obsidianのコールアウト

> [!NOTE] callout title
> callout contents

サポートされるキーワード: tip, info, note, warning, danger, errorなど。

Jekyll chirpyのコールアウト

> callout contents
{: .promt-info}

サポートされるキーワード: tip, info, warning, danger

シンタックスが異なるため、この部分を置換するために正規表現を使用し、リプレーサーを実装する必要があります。

export function convertCalloutSyntaxToChirpy(content: string) {
function replacer(match: string, p1: string, p2: string) {
if (p1.toLowerCase() === 'note') {
p1 = 'info'
}
if (p1.toLowerCase() === 'error') {
p1 = 'danger'
}
return `${p2}\n{: .prompt-${p1.toLowerCase()}}`
}

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

Jekyllでサポートされていないキーワードは、類似の役割を持つ他のキーワードに変換されます。

完了したファイルの移動

現在使用しているJekyllベースのブログには、投稿を公開するために特定のパスに配置する必要があります。Jekyllプロジェクトの場所はクライアントごとに異なる可能性があるため、カスタムパスの処理が必要です。設定タブを通じてこれを設定することにし、以下のような入力フォームを作成しました。

image

すべての変換が完了したら、Jekyllの_postパスにファイルを移動することで変換プロセスが完了します。

async function moveFilesToChirpy(plugin: O2Plugin) {
// ボルト外のファイルを移動するためには絶対パスが必要
const absolutePath = this.app.vault.adapter.getBasePath()
const sourceFolderPath = `${absolutePath}/${plugin.settings.readyDir}`
const targetFolderPath = plugin.settings.targetPath()

fs.readdir(sourceFolderPath, (err, files) => {
if (err) throw err

files.forEach((filename) => {
const sourceFilePath = path.join(sourceFolderPath, filename)
const targetFilePath = path.join(targetFolderPath, filename)

fs.rename(sourceFilePath, targetFilePath, (err) => {
if (err) {
console.error(err)
new Notice(err.message)
throw err
}
})
})
})
}

正規表現

export namespace ObsidianRegex {
export const IMAGE_LINK = /!\[\[(.*?)]]/g
export const DOCUMENT_LINK = /(?<!!)\[\[(.*?)]]/g
export const CALLOUT = /> \[!(NOTE|WARNING|ERROR|TIP|INFO|DANGER)].*?\n(>.*)/ig
}

Obsidian独自の特別なシンタックスは、正規表現を使用して解析しました。グループを使用することで、特定の部分を抽出して変換することができ、プロセスが便利になりました。

コミュニティプラグインリリースのためのPR作成

最後に、コミュニティプラグインリポジトリにプラグインを登録するために、PRを作成して締めくくります。コミュニティガイドラインに従わないとPRが拒否される可能性があるため、Obsidianはプラグイン開発時に注意すべき点をガイドしているので、これらのガイドラインにできるだけ従うことが重要です。

image

過去のPRに基づくと、マージには約2〜4週間かかるようです。後でフィードバックがあれば、必要な調整を行い、マージを待ちます。

結論

「これは簡単な作業で、3日で終わるだろう」と思っていましたが、海外旅行中にプラグインを実装しようとしたため、リリースPRの作成を含めて約1週間かかりました😂

image JUnitを開発したKent BeckとErich Gammaも飛行機でこんな風にコーディングしていたのだろうか...

JavaやKotlinからTypeScriptに切り替えるのは難しく、慣れていなかったため、書いているコードがベストプラクティスかどうか自信がありませんでした。しかし、このおかげでasync-awaitのようなJSシンタックスを詳しく掘り下げることができ、新しい技術スタックを自分のレパートリーに追加することができました。これは誇らしい気持ちです。また、新しいトピックを書く機会も得られました。

最も良い点は、ブログ投稿にほとんど手作業が必要なくなったことです!プラグインでシンタックスを変換した後、スペルチェックを行ってGitHubにプッシュするだけです。もちろん、まだ多くのバグがありますが...

今後は、プラグインのアンチパターンを排除し、モジュールをよりクリーンにするためにTypeScriptの学習を続けていく予定です。

同じようなジレンマに直面している方は、プロジェクトに貢献したり、他の方法で協力して一緒に構築するのも良いでしょう!いつでも歓迎です😄

情報

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

次のステップ 🤔

  • 軽微なバグの修正
  • 脚注シンタックスのサポート
  • 画像リサイズシンタックスのサポート
  • 変換中にエラーが発生した場合のロールバック処理の実装
  • 他のモジュールを追加するための処理の抽象化

リリース 🚀

約6日間のコードレビューの後、PRがマージされました。プラグインは現在、Obsidianコミュニティプラグインリポジトリで利用可能です。🎉

image

参考

ローカルCLIを使ったGoogle Kubernetes Engineの管理

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

概要

GoogleのCloud Shellを通じてウェブからどこでもkubectlを実行できるのは非常に便利ですが、簡単なクエリコマンドのためにウェブアクセスと認証を行う手間がかかるという欠点があります。この記事では、ローカルCLIを使ってGoogle Cloud Kubernetesを迅速に管理する方法を紹介します。

目次

GCP CLIのインストール

まず、GCP CLIをインストールする必要があります。適切なオペレーティングシステムを確認し、インストールするためにgcp-cliリンクを参照してください。

接続

インストールが完了したら、以下のコマンドを使用して認証プロセスを進めます。

gcloud init

GCP Kubernetes Engineにアクセスし、クラスターの接続情報を取得する必要があります。

GKE-connect

gke-cluster-connect-2

コマンドラインアクセス用のコマンドをコピーし、ターミナルで実行します。

gcloud container clusters get-credentials sv-dev-cluster --zone asia-northeast3-a --project {projectId}
Fetching cluster endpoint and auth data.
CRITICAL: ACTION REQUIRED: gke-gcloud-auth-plugin, which is needed for continued use of kubectl, was not found or is not executable. Install gke-gcloud-auth-plugin for use with kubectl by following https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke
kubeconfig entry generated for sv-dev-cluster.

プラグインのインストール

現在使用しているKubernetesのバージョンがv1.26未満の場合、gke-gcloud-auth-pluginのインストールを要求するエラーが発生することがあります。以下のコマンドを使用してプラグインをインストールします。

情報

v1.26以前では、クライアントとGoogle Kubernetes Engine間の認証を管理するためのクライアント固有のコードが既存のkubectlおよびカスタムKubernetesクライアントに含まれていました。v1.26以降、このコードはOSS kubectlに含まれなくなりました。GKEユーザーは、GKE固有のトークンを生成するために別の認証プラグインをダウンロードして使用する必要があります。新しいバイナリであるgke-gcloud-auth-pluginは、Kubernetes Client-goユーザー認証情報プラグインメカニズムを使用してkubectl認証をGKE用に拡張します。このプラグインはすでにkubectlでサポートされているため、v1.26が提供される前にこの新しいメカニズムに切り替えることができます。 - Google

gcloud components install gke-gcloud-auth-plugin
Your current Google Cloud CLI version is: 408.0.1
Installing components from version: 408.0.1

┌────────────────────────────────────────────┐
│ These components will be installed. │
├────────────────────────┬─────────┬─────────┤
│ Name │ Version │ Size │
├────────────────────────┼─────────┼─────────┤
│ gke-gcloud-auth-plugin │ 0.4.0 │ 7.1 MiB │
└────────────────────────┴─────────┴─────────┘

For the latest full release notes, please visit:
https://cloud.google.com/sdk/release_notes

Do you want to continue (Y/n)? y

╔════════════════════════════════════════════════════════════╗
╠═ Creating update staging area ═╣
╠════════════════════════════════════════════════════════════╣
╠═ Installing: gke-gcloud-auth-plugin ═╣
╠════════════════════════════════════════════════════════════╣
╠═ Installing: gke-gcloud-auth-plugin ═╣
╠════════════════════════════════════════════════════════════╣
╠═ Creating backup and activating new installation ═╣
╚════════════════════════════════════════════════════════════╝

Performing post processing steps...done.

Update done!

接続コマンドを再実行すると、エラーメッセージなしでクラスターが接続されることが確認できます。

gcloud container clusters get-credentials sv-dev-cluster --zone asia-northeast3-a --project {projectId}
Fetching cluster endpoint and auth data.
kubeconfig entry generated for sv-dev-cluster.

接続が成功すると、Docker Desktopにも変化が見られます。具体的には、Kubernetesタブに新しい情報が表示されます。

1.png

その後、kubectlを使用してローカルで直接GKEリソースを確認することもできます。

kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
my-application 1/1 1 1 20d

結論

GKEリソースをローカルで効率的に管理する方法を簡単に紹介しました。ローカルでkubectlを使用することで、オートコンプリートなどの拡張機能が利用でき、Kubernetesの管理が非常に便利になります。GKEの使用が初めての方は、ぜひ試してみてください。

参考

k8s-plugin

テスト実行の高速化、Springコンテキストのモッキング

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

概要

プロジェクトごとにテストコードを書くことは一般的になっています。プロジェクトが成長するにつれて、テストの数も増え、全体のテスト実行時間が長くなります。特にSpringフレームワークに基づいたプロジェクトでは、Spring Beanコンテキストの読み込みによってテスト実行が大幅に遅くなることがあります。この記事では、この問題に対処する方法を紹介します。

すべてのテストをユニットテストとして書く

テストは速くなければなりません。テストが速ければ速いほど、頻繁に実行することに躊躇しなくなります。すべてのテストを一度に実行するのに10分かかる場合、フィードバックは10分後にしか得られません。

Springでテストを高速化するためには、@SpringBootTestを使用しないことが重要です。すべてのBeanを読み込むと、必要なBeanを読み込む時間が圧倒的に長くなり、ビジネスロジックをテストするコードの実行時間よりも長くなります。

@SpringBootTest
class SpringApplicationTest {

@Test
void main() {
}
}

上記のコードは、Springアプリケーションを実行するための基本的なテストコードです。@SpringBootTestによって構成されたすべてのBeanが読み込まれます。では、テストに必要なBeanだけをどのように注入するのでしょうか?

アノテーションやMockitoの活用

特定のアノテーションを使用することで、関連するテストに必要なBeanだけが自動的に読み込まれます。これにより、コンテキスト読み込みを通じてすべてのBeanを読み込むのではなく、本当に必要なBeanだけを読み込むことで、テスト実行時間を最小限に抑えることができます。

いくつかのアノテーションを簡単に見てみましょう。

  • @WebMvcTest: Web MVC関連のBeanのみを読み込みます。
  • @WebFluxTest: Web Flux関連のBeanのみを読み込みます。WebTestClientを使用できます。
  • @DataJpaTest: JPAリポジトリ関連のBeanのみを読み込みます。
  • @WithMockUser: Spring Securityを使用する場合、偽のユーザーを作成し、不要な認証プロセスをスキップします。

さらに、Mockitoを使用することで、複雑な依存関係を簡単に解決してテストを書くことができます。これらの2つの概念を適切に活用することで、ほとんどのユニットテストはそれほど難しくありません。

警告

過度なモッキングが必要な場合、依存関係の設計に問題がある可能性が高いです。モッキングの多用には注意が必要です。

SpringApplicationはどうする?

SpringApplicationを実行するには、SpringApplication.run()を実行する必要があります。このメソッドの実行を確認するためにすべてのSpringコンテキストを非効率的に読み込むのではなく、コンテキスト読み込みが発生するSpringApplicationをモックし、run()が呼び出されるかどうかだけを確認することができます。

class DemoApplicationTests {  

@Test
void main() {
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
when(SpringApplication.run(DemoApplication.class)).thenReturn(null);

DemoApplication.main(new String[]{});

springApplication.verify(
() -> SpringApplication.run(DemoApplication.class), only()
);
}
}
}

結論

ロバート・C・マーティンの『Clean Code』の第9章では「FIRST原則」について議論しています。

この記事で述べたように、最初の文字FはFast(速い)を意味します。テストの速さの重要性を再度強調し、次の引用で締めくくります。

テストは十分に速くなければならない。 - ロバート・C・マーティン

参考

WebFluxでURLパラメータとしてDate型を使用する方法

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

概要

LocalDateTimeのような時間形式をURLパラメータとして使用する場合、デフォルトの形式と一致しないと、以下のようなエラーメッセージが表示されることがあります。

Exception: Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime';

特定の形式に変換を許可するためには、どのような設定が必要でしょうか?この記事では、その変換方法を探ります。

内容

まず、簡単なサンプルを作成してみましょう。

public record Event(
String name,
LocalDateTime time
) {
}

これは、イベントの名前と発生時間を含むシンプルなオブジェクトで、recordを使用して作成されています。

@RestController
public class EventController {

@GetMapping("/event")
public Mono<Event> helloEvent(Event event) {
return Mono.just(event);
}

}

ハンドラーは従来のコントローラーモデルを使用して作成されています。

ヒント

Spring WebFluxでは、ルータ関数を使用してリクエストを管理できますが、この記事では@RestControllerを使用することに焦点を当てています。

テストコードを書いてみましょう。

@WebFluxTest
class EventControllerTest {

@Autowired
private WebTestClient webTestClient;

@Test
void helloEvent() {
webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("Spring")
.jsonPath("$.time").isEqualTo("2021-08-01T12:00:00");
}

}

image1

テストコードを実行すると、以下のリクエストがシミュレートされます。

$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00
HTTP/1.1 200 OK
Content-Length: 44
Content-Type: application/stream+json

{
"name": "Spring",
"time": "2021-08-01T12:00:00"
}

リクエストがデフォルトの形式で行われると、正常なレスポンスが返されます。しかし、リクエスト形式が変更された場合はどうでしょうか?

image2

image3

$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00:00Z
HTTP/1.1 500 Internal Server Error
Content-Length: 131
Content-Type: application/stream+json

{
"error": "Internal Server Error",
"path": "/event",
"requestId": "ecc1792e-3",
"status": 500,
"timestamp": "2022-11-28T10:04:52.784+00:00"
}

上記のように、特定の形式でレスポンスを受け取るためには追加の設定が必要です。

1. @DateTimeFormat

最も簡単な解決策は、変換したいフィールドにアノテーションを追加することです。変換したい形式を定義することで、希望の形式でリクエストを行うことができます。

public record Event(
String name,

@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
LocalDateTime time
) {
}

再度テストを実行すると、正常に通過することが確認できます。

情報

リクエスト形式を変更しても、レスポンス形式は変更されません。レスポンス形式の変更は@JsonFormatなどのアノテーションを使用して設定できますが、この記事では取り上げません。

これは簡単な解決策ですが、必ずしも最良の方法ではありません。変換が必要なフィールドが多い場合、手動でアノテーションを追加するのは非常に面倒で、アノテーションをうっかり忘れるとバグの原因になります。ArchUnit1のようなテストライブラリを使用してチェックすることも可能ですが、コードの理解に必要な労力が増えます。

2. WebFluxConfigurer

WebFluxConfigurerを実装し、フォーマッタを登録することで、各LocalDateTimeフィールドにアノテーションを追加する必要がなくなります。

Eventから@DateTimeFormatを削除し、以下のように設定を行います。

@Configuration
public class WebFluxConfig implements WebFluxConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
registrar.setUseIsoFormat(true);
registrar.registerFormatters(registry);
}
}
危険

@EnableWebFluxを使用すると、マッパーが上書きされ、アプリケーションが意図した通りに動作しなくなる可能性があります。2

再度テストを実行すると、アノテーションなしで正常に通過することが確認できます。

image4

特定のフィールドに異なる形式を適用する

これは簡単です。フィールドに直接@DateTimeFormatを追加する方法が優先されるため、希望するフィールドに@DateTimeFormatを追加することができます。

public record Event(
String name,

LocalDateTime time,

@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH")
LocalDateTime anotherTime
) {
}
    @Test
void helloEvent() {
webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00Z&anotherTime=2021-08-01T12")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("Spring")
.jsonPath("$.time").isEqualTo("2021-08-01T12:00:00")
.jsonPath("$.anotherTime").isEqualTo("2021-08-01T12:00:00");
}

image5

ヒント

URIが長くなる場合、UriComponentsBuilderを使用するのが良いアプローチです。

String uri = UriComponentsBuilder.fromUriString("/event")
.queryParam("name", "Spring")
.queryParam("time", "2021-08-01T12:00:00Z")
.queryParam("anotherTime", "2021-08-01T12")
.build()
.toUriString();

結論

WebFluxConfigurerを使用することで、グローバルに一貫した形式を適用できます。異なるクラスにまたがる複数のフィールドが特定の形式を必要とする場合、WebFluxConfigurerを使用する方が、各フィールドに@DateTimeFormatを適用するよりもはるかに簡単です。状況に応じて適切な方法を選択してください。

  • @DateTimeFormat: 適用が簡単。グローバル設定よりも優先され、特定のフィールドに異なる形式を適用できます。
  • WebFluxConfigurer: 適用が比較的複雑ですが、一貫した設定が必要な大規模プロジェクトに有利です。@DateTimeFormatと比較して、アノテーションの追加忘れなどの人的エラーを防ぐことができます。
情報

すべてのサンプルコードはGitHubで確認できます。

参考

Footnotes

  1. ArchUnit

  2. LocalDateTime is representing in array format

ZonedDateTimeを使用する際の注意点 - Object.equals vs Assertions.isEqualTo

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

概要

Javaには時間を表現するためのオブジェクトがいくつか存在します。この記事では、その中でも最も情報量が多いZonedDateTimeを使用した時間の比較方法について説明します。

異なるが同じ時間?

まず、簡単なテストコードを書いて、何か特異な点がないか確認してみましょう。

ZonedDateTime seoulZonedTime = ZonedDateTime.parse("2021-10-10T10:00:00+09:00[Asia/Seoul]");
ZonedDateTime utcTime = ZonedDateTime.parse("2021-10-10T01:00:00Z[UTC]");

assertThat(seoulZonedTime.equals(utcTime)).isFalse();
assertThat(seoulZonedTime).isEqualTo(utcTime);

このコードはテストに合格します。equalsfalseを返す一方で、isEqualToは合格します。なぜでしょうか?

実際には、上記のコードにおける2つのZonedDateTimeオブジェクトは同じ時間を表しています。しかし、ZonedDateTimeは内部的にLocalDateTimeZoneOffset、およびZoneIdを含んでいるため、equalsを使用して比較すると、絶対時間ではなくオブジェクトの値が同じかどうかをチェックします。

そのため、equalsfalseを返します。

image1 ZonedDateTime#equals

しかし、isEqualToは時間オブジェクトの操作に関して異なる動作をするようです。

実際、ZonedDateTimeを比較する際、isEqualToZonedDateTimeequalsを呼び出すのではなく、ChronoZonedDateTimeByInstantComparator#compareを呼び出します。

image2

image3 Comparator#compareが呼び出される。

内部実装を見ると、toEpochSecond()を使用して秒に変換することで比較が行われていることがわかります。つまり、equalsを通じてオブジェクトを比較するのではなく、compareを通じて絶対時間を比較しています。

これに基づいて、ZonedDateTimeの比較を以下のようにまとめることができます:

equals : オブジェクトを比較

isEqualTo : 絶対時間を比較

したがって、ZonedDateTimeを間接的に含むオブジェクトを比較する場合、equalsが呼び出されるため、ZonedDateTimeの絶対値に基づいて比較したい場合は、オブジェクト内でequalsメソッドをオーバーライドする必要があります。

public record Event(
String name,
ZonedDateTime eventDateTime
) {
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Event event = (Event) o;
return Objects.equals(name, event.name)
&& Objects.equals(eventDateTime.toEpochSecond(), event.eventDateTime.toEpochSecond());
}

@Override
public int hashCode() {
return Objects.hash(name, eventDateTime.toEpochSecond());
}
}
@Test
void equals() {
ZonedDateTime time1 = ZonedDateTime.parse("2021-10-10T10:00:00+09:00[Asia/Seoul]");
ZonedDateTime time2 = ZonedDateTime.parse("2021-10-10T01:00:00Z[UTC]");

Event event1 = new Event("event", time1);
Event event2 = new Event("event", time2);

assertThat(event1).isEqualTo(event2); // pass
}

結論

  • ZonedDateTime間でequalsが呼び出される際に絶対時間を比較したい場合は、toEpochSecond()などを使用して変換する必要があります。
  • テストコードや類似のシナリオでisEqualToを使用して直接ZonedDateTimeを比較する場合、equalsは呼び出されず、内部変換が行われるため、別途変換する必要はありません。
  • オブジェクト内にZonedDateTimeが含まれている場合、必要に応じてオブジェクトのequalsメソッドをオーバーライドする必要があります。

DockerでJenkinsを運用する

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

概要

この記事では、Dockerを使用してJenkinsをインストールおよび運用する方法について説明します。

目次

インストール

Docker

docker run --name jenkins-docker -d -p 8080:8080 -p 50000:50000 -v /home/jenkins:/var/jenkins_home -u root jenkins/jenkins:lts 

ホストマシン上にJenkinsデータを永続化するためにボリュームをマウントします。TeamCityとは異なり、Jenkinsはすべての設定をファイルで管理します。マウントを設定することで、認証情報やデータ管理が非常に便利になるため、必ず設定してください。一般的なターゲットパスは/home/jenkinsまたは/var/lib/jenkinsです。

この記事の目的のために、/home/jenkinsパスが作成されていると仮定します。

認証

マスターとノードの両方に対するセキュリティとアクセス制御を確保するために、'jenkins'という名前のユーザーを作成し、以下の手順に従います。

ユーザーアクセス権の設定

chown -R jenkins /var/lib/jenkins

SSHキーの管理

キーがない場合は、ssh-keygenを使用してプライベートキーとパブリックキーを生成します。

パスの入力を求められたら、/home/jenkins/.ssh/id_rsaと入力して、キーが/home/jenkins/.sshに作成されるようにします。

GitLab

GitLabの個人設定にはSSH設定タブがあります。そこにパブリックキーを追加します。

パイプラインでGitを選択すると、リポジトリパスの入力フィールドが表示されます。git@~で始まるSSHパスを入力すると赤いエラーが表示されます。これを解決するために、資格情報を作成します。SSH資格情報を選択して作成し、ID値には有用な値を入力することをお勧めします。

ノード設定

ノードはJenkinsの役割を効率的に分散する方法です。

ノードと通信するために、マスターでssh-keygenを使用してキーを生成します。既に使用しているキーがある場合は、それを再利用できます。

image

  • ID: この値はJenkinsが内部でSSHキーを識別するために使用され、Jenkinsfileで資格情報を使用しやすくするため、意味のある値を設定するのがベストです。設定しない場合はUUID値が生成されます。
  • Username: Linuxユーザー。通常、'jenkins'がユーザーとして使用されるため、'jenkins'と入力します。これを入力しないと、キーエラーが発生する可能性があるので注意してください

Dockerアクセス権限

dockerグループが存在しない場合は、作成します。通常、Dockerをインストールすると自動的に作成されます。

sudo groupadd docker

以下のコマンドを実行して、JenkinsユーザーにDockerを実行する権限を付与します。

sudo gpasswd -a jenkins docker
# Adding user jenkins to group docker
sudo chmod 666 /var/run/docker.sock

変更を適用するためにDockerデーモンを再起動します。

systemctl restart docker

これでdocker psコマンドを実行できるようになります。

再起動

Jenkinsのバージョンを更新したり、プラグインをインストール、削除、更新したりすると、Jenkinsが再起動します。しかし、Dockerで管理している場合、コンテナがダウンし、Jenkinsが起動できなくなります。再起動を有効にするには、コンテナに再起動ポリシーを設定する必要があります。

docker update --restart=always jenkins-docker

これにより、jenkins-dockerコンテナは常に実行状態に保たれます。

注意

プラグインを更新する際には、現在運用中のJenkinsのバージョンと互換性があるかどうかを慎重に確認してください。Jenkinsとプラグインのバージョンが一致しないと、パイプラインの失敗につながることがよくあります。

参考

Managing Jenkins with Docker

より直感的な 'diff' を実現する、Difftastic

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

概要

Difftasticは、git diffの使用をより便利にするためのツールです。ターミナルで頻繁にgit diffコマンドを使用する人にとって非常に役立ちます。

使用方法

brew install difftastic

グローバル設定:

git config --global diff.external difft

これで、git diffコマンドを使用すると、以前よりもはるかに直感的な差分結果を確認できます。

image

参考

有効なDocker環境が見つかりませんでした

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

概要

Macをアップデートした後、Dockerが正常に動作しなくなり、再インストールする必要がありました。しかし、テストを実行するとコンテナが正常に動作しないエラーに遭遇しました。

調べてみると、/var/run/docker.sockが正しく設定されていないことが原因でした。ここでは、この問題を解決する方法を共有します。

説明

この問題はDocker Desktopバージョン4.13.0で発生します。

デフォルトでは、Dockerはホスト上に/var/run/docker.sockシンボリックリンクを作成せず、代わりにdocker-desktop CLIコンテキストを使用します。 (参照: https://docs.docker.com/desktop/release-notes/)

現在のDockerコンテキストはdocker context lsコマンドで確認できます。以下のように表示されます:

NAME                TYPE                DESCRIPTION                               DOCKER ENDPOINT                                KUBERNETES ENDPOINT                                 ORCHESTRATOR
default moby Current DOCKER_HOST based configuration unix:///var/run/docker.sock https://kubernetes.docker.internal:6443 (default) swarm
desktop-linux * moby unix:///Users/<USER>/.docker/run/docker.sock

問題を解決するには、デフォルトのコンテキストを設定するか、unix:///Users/<USER>/.docker/run/docker.sockに接続します。

解決方法

以下のコマンドを実行してデフォルトのコンテキストに切り替え、Dockerが正常に動作するか確認してください:

docker context use default

問題が解決しない場合は、以下のコマンドでシンボリックリンクを手動で作成して解決できます:

sudo ln -svf /Users/<USER>/.docker/run/docker.sock /var/run/docker.sock

参考