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

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

· 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", "{% raw %}{{ .Destination }}{% endraw %}", "{% raw %}{{ .Source }}{% endraw %}", "{% raw %}{{ .Target }}{% endraw %}"]

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 = {% raw %}{{ .name }}{% endraw %}
email = {% raw %}{{ .email }}{% endraw %}

これらの中括弧はローカル環境で定義された変数で埋められます。デフォルトの変数リストは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でのみ実行
{% raw %}{{- if eq .chezmoi.os "darwin" -}}{% endraw %}
#!/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[@]}

{% raw %}{{ end -}}{% endraw %}

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

[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