エンジニアのはしがき

プログラミングの日々の知見を書き連ねているブログです

AI時代に息切れしながら追従してる自分のちょっとしたAI活用法

皆さんはAI活用できてますか? 私は、日々進化するAI周りの情勢についていくだけでもう必死です。 この記事ではそんな自分の個人的なAI活用法を一部ご紹介します。誰かの参考になれば幸いです。

コーディング系

開発要件を元に複数の機能的な実現手段をサジェストさせる

スクラム開発におけるPBI(プロダクトバックログアイテム)やそれに相当するようなテキスト情報があった場合、1度その内容を読ませた上で機能的な実現手段の別案を提案させています。

ただ、出力結果は読ませるテキスト情報の表現にかなり依存するので、提案内容の妥当性はかなり波があります。 メインでがっつり使っているというよりかは、自分では思いつけなかった選択肢をフォローしてもらう、という使い方をしています。

既にテスト観点が重複しているテストケースがないか教えてもらう

今開発しているリポジトリでは大量のテストコードが書かれているのですが、適切に管理されていないものも多く、どういうテスト観点のテストが実際存在しているのか把握するのが難しい状態になっていました。 そのためテスト観点と既存コードの整理をしていく必要があったのですが、短期的に整理のための工数を割く余裕がなく、とはいえ機能を足す上ではテストコードの追加は避けられないという事情がありました。 そこで、テスト観点的に既にコード上担保されていないかどうかをチェックしてもらうClaude Skillsを作り、新規のテストを設計する際に実行してテスト観点が重複するコードが新たに生まれることを防ぐようにしています。

ツールやライブラリを提案させる

課題解決のためにツール・ライブラリを導入したい!と思った時、AIにその課題を伝えてツールの候補を複数提案させて比較検討しています。 これまでの私の経験では利用するAIサービスによって提案してくるツールが結構違うことが多いので、ChatGPT, Gemini, Claude, Perplexityに同じ質問を投げて回答を横断して見て候補を絞ることが多いです。 ただ詳細な特徴まで掘り下げて知りたい場合は、AIに頼らずツールの公式サイトへ飛んで自分の目で確認するようにしています。

gitのコミットメッセージ自動生成

ステージングしてあるgit差分を読ませて、Conventional Commitsの形式でコミットメッセージを生成させるClaude Skillsを作っています。 いちいちclaudeコマンドを叩いてスラッシュコマンドを実行するのも面倒なので、↓のようなエイリアスを作っておいてcommitと実行するだけでコミットメッセージが生成されるようにしています。

alias commit='claude -p "/commit"'

運用系

エラーメッセージやスタックトレースから原因を調べてもらう

エラー発生時にAIに出力内容をコピペして原因と修正案を出させています。 あまり詳しくない技術領域でエラーが出た時は自力解決よりも早いことが多いのでよくやります。

ライブラリアップデート時にリリースノートから既存実装への影響を教えてもらう

導入しているライブラリのアップデート時には、アップデートによる既存実装への影響内容を把握する必要があるのですが、今までは人力でリリースノートに目を通してBreaking Changesがないかをチェックしていました。(これがなかなか時間がかかる...)

Claude Skillsでリリースノートのチェックと既存コードの照らし合わせをさせ、アップデートによる影響をレポートとして出力してもらうようにしたことで、影響内容の把握がかなり楽になりました。 アプデによって起こったエラーの原因調査でハマることも過去多かったので助かっています。

その他

Claude Skillsはskill-creatorで作成してもらう

Claude公式がSkillsを作成するためのSkillsとしてskill-creatorを提供してくれています。 https://github.com/anthropics/skills/tree/main/skills/skill-creator

どのようなSkillsを作りたいのか対話形式で質問に答えていくだけでSkillsが出来上がる優れものです。 実行時に冪等性を保つ方が良い処理は勝手にscriptを生成して実行するようにSkillsを組んでくれたりもするので、下手に自作するよりこちらを使った方が良いかも。

Jestの分かりにくい挙動たち

Jestはハマりやすかった

数年Jestを書いていますが、Jestのモック周りで頭を抱えることが多く意図通りに動かすのに苦労しました。

何が難しくさせているのかというと主に以下の理由から来ているのかなと思っています。

  1. モックを元実装に戻す場合モック手段ごとに書き分ける必要がある
  2. モック手段ごとにモックが適用されるタイミングが異なる

今回はそれぞれの難しいと感じるポイントに関するJestの仕様をまとめてみました。(特によく使うモック手段をピックアップしています)

仕様まとめ

モックを元実装に戻す方法

モック手段 モック範囲 モックを元実装に戻す方法 備考
jest.spyOn() Function/Property mockFn.mockRestore(), jest.restoreAllMocks()
jest.mock() Module jest.unmock()/jest.dontMock()jest.resetModules() → 再度import/require
jest.doMock() Module jest.unmock()/jest.dontMock()jest.resetModules() → 再度import/require
Manual Mock Module jest.unmock()/jest.dontMock()jest.resetModules() → 再度import/require __mocks__フォルダで定義したモック
Object.defineProperty() Property 予め元の値を保存した上で手動復元 グローバルオブジェクトのプロパティ変更時などに使用

特定のテストケース内でモックする場合、モックが不要になったらモック前の状態にリセットしてあげないと別テストケースにもモックが影響してしまい挙動が変わる可能性があります。 そういった他テストケースへの汚染を防止するため、これらのリセット処理を適切な場所(afterEach, テストケース末尾など)で実行してあげるのが重要です。

モックの実行タイミング

モック手段 適用タイミング 詳細
jest.spyOn() 実行時 テストコード実行時に既存オブジェクトのメソッド/プロパティをスパイ
jest.mock() テストを実行する環境により異なる(※) ファイルの最上部に自動移動され、importより前に実行
jest.doMock() 実行時 テストコード実行時。以降のrequire/importが影響を受ける
Manual Mock モジュール読み込み時 対象モジュールがrequire/importされる際に自動適用
Object.defineProperty() 実行時 テストコード実行時にプロパティを直接変更

jest.mock()は以下のように環境により動作が変わります

環境 jest.mock()
CommonJS import 前に実行(hoisting される)
ESM 実行時(hoisting されない)

jest.doMock()の注意点

特にハマったのはjest.doMock()の挙動でした。

既にimportしているモジュールに対してjest.doMock()によるモックを参照させるには、jest.doMock()以降でrequire/importする必要があります。(jest.mock()やManual Mockならモジュール読み込み前にモックしてくれるのでこういうことは起きません。)

例えば以下のようなテストコードは意図通りにモックされません。

// テストケースの実行より前にこのimportが実施される
import { sendEmail } from './emailService';

describe('メール送信テスト', () => {
  test('メール送信が成功することをテスト', () => {
    // この時点でdoMockを呼んでも、既にemailServiceはimportされているので効果がない
    jest.doMock('./emailService', () => ({
      sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
    }));
    
    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('mock-123'); // ❌ ファイル冒頭のimportされた実装側が使用されてしまいテストが失敗する
  });
});

このようなケースでjest.doMock()によるモックを適用させたい場合、以下のようにimportを追記してあげると意図通りに動作します。

import { sendEmail } from './emailService';

describe('メール送信テスト', () => {
  test('メール送信が成功することをテスト', async () => {
    jest.doMock('./emailService', () => ({
      sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
    }));
    
    // dynamic importでモジュールを読み込む。doMock後のimportなのでモックが適用される。
    const { sendEmail } = await import('./emailService');

    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('mock-123'); // ✅ テストが成功する
  });
});

jest.isolateModulesでモジュールへのモックの影響を閉じ込める

jest.isolateModules(fn)を使うと通常のテストファイルでのモジュール読み込みとは別に独立したスコープでのモジュールキャッシュを持たせることができます。 jest.mock()等のモジュールのモックとテストケースをjest.isolateModules(fn)で囲ってあげることで、モジュールのモックの影響をjest.isolateModules(fn)の内部に閉じ込めることができ、他のテストケースへのモック汚染を防止できます。

具体的には下記のように記述します。(awaitする必要があるのでこの例ではjest.isolateModules(fn)のasync版であるisolateModulesAsync(fn)を使っています)

describe('メール送信テスト', () => {
  test('isolateModulesを使用するテスト', async () => {
    await jest.isolateModulesAsync(async () => {
      // このモックはisolateModulesの内部にのみ影響する
      jest.doMock('./emailService', () => ({
        sendEmail: jest.fn(() => ({ success: true, messageId: 'mock-123' }))
      }));
      
      const { sendEmail } = await import('./emailService');
      const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
      expect(result.messageId).toBe('mock-123');
    });
  });

  test('実装側をそのまま使用するテスト', async () => {
    const { sendEmail } = await import('./emailService');

    // このテストケースは前のテストケースのモックの影響を受けない
    const result = sendEmail('user@example.com', 'テスト件名', 'テスト本文');
    expect(result.messageId).toBe('real-123');
  });
});

参考

jestjs.io

Clean Architecture読了しました

先日、Robert C. Martin氏の有名な著書 Clean Architecture を読み終えました。 良著というのは前々から聞いていたのですがEngineering Managerとしてのインプットに時間を割いていた都合で読む機会を逸しておりました。

翻訳版の言い回しが微妙に理解し辛かったりといった部分は多少あったのですが、言語やフレームワークに依存せずプログラミングに広く適用できる設計ノウハウがまとまっており今後のプログラミングに広範囲に適用できるという意味で非常に有用なインプットになりました。

ここからは個人的に印象深かった部分をピックアップしてまとめたり、感想を書き下してみます。

SOLID原則

「第Ⅲ部 設計の原則」では関数やクラス、データ構造の組み方を示す原則であるSOLID原則に関して説明されています。 読了前から概念はネットサーフィンで知ってはいましたが、改めて有用な原則だと思いました。

Single responsibility principle(SRP)

「モジュール(関数やデータをまとめた凝集性のあるもの)はたった1つのアクター(変更を求める人たちの総称)に対して責務を負うべきである」という原則です。「どのモジュールもたった1つのことだけを行うべき」という意味ではないので注意が必要です。

例えば、全く異なる性質の2つの機能がたまたま同じロジックを必要としていたので、2機能から同じロジックを参照するように実装してしまった、という事例はこの原則に違反していると言えます。 このような違反をすると、プロダクトが成長過程でそれぞれの機能で異なる追加要件が必要になった場合に同じロジックの実装を維持できなくなってしまい、2機能を跨いだ改修が必要になってしまうでしょう。

こういった事例は割と実務で違反しちゃってる実装を見るケースがあり、特に機能実装の初期段階で意識して実践すべき原則だなーと思います。

Open-closed principle(OCP)

「ソフトウェアの構成要素は拡張に対して開いていて、修正に対しては閉じていなければいけない」という原則です。

例えばポリモーフィズムを利用した実装はこの原則を満たす例と言えます。 上位のロジックはスーパークラスに依存させ、そのスーパークラスを継承したサブクラスを増やして機能拡張ができる構造にする、これにより上位のロジックは修正しなくても良い状態になるのでOCPを満たしていることになります。

OCPを実現する場合、上位のロジックの実装時に将来の拡張を見据えた適切な単位での抽象化が特に重要なポイントになりそうです。

Liskov substitution principle(LSP)

「S が T の派生型である場合、プログラムで T 型のオブジェクトが使われている箇所は S 型のオブジェクトで置換可能であるべき」という原則です。

例えば、TというスーパークラスとSというサブクラスの2つがあり、Tを参照するロジックが書かれていた場合、そのロジックのTをSに置き換えても「振る舞いが変わらない」場合は原則を満たしていると言えます。

継承を使うときは単にスーパークラスをオーバーライドしたメソッドを実装するだけではなく、「振る舞い」もスーパークラスと同じであるように実装しましょうね、ということを示唆している原則です。

Interface segregation principle(ISP)

「クライアントは使用しないインターフェースに依存するべきはない」という原則です。

これを満たすために、クライアントにとって必要最低限の単位になるよう適切にインターフェースを分けておく設計が必要になります。依存が少なければ将来あるインターフェースに変更があったとしても、修正箇所は小さく抑えることができるでしょう。

Dependency inversion principle(DIP)

この原則は「具象ではなく抽象に依存すべき」というものです。

特にこの原則の対象となるのはプログラムにおいて変化のしやすい具象の要素で、java.lang.stringといった安定した要素は除きます。抽象であるインターフェースに依存したロジックを組むことでそのロジックは未来の変更の影響を受けにくくなります。変化しやすい具象は抽象を介して取り扱うようにすることで、変更容易性を維持していきましょうというのがこの原則が言っているポイントです。

クリーンアーキテクチャ

例の有名な多重の円で表現された設計のアプローチです。(図はこちらを参照)

クリーンアーキテクチャでは以下の4つの要素で構成されており、上にある要素ほどより上位の方針とされています。

  • Entities
    • 企業のビジネスルールをカプセル化したもの
  • Use Cases
    • アプリケーション固有のビジネスルール
  • Interface Adapters
    • Entities, Use Cases向けのフォーマットをDB, Webなどの外部エージェントに便利なフォーマットに変換するもの
    • MVCアーキテクチャもこのレイヤーが保持する
  • Frameworks & Drivers
    • DB, Web Framework

そしてコードの依存はより上位の方針に向かっていなければいけない、というのがこのアーキテクチャのルールとされています。

このアーキテクチャではより変化しにくいものが上位の要素に配置され逆に変化の大きいものが下位の要素に配置される形になっていますが、実際よく修正することになるのって、Controller, Service, RepositoryといったInterface Adaptersのレイヤーと機能固有のロジックであるUse Casesのレイヤーが多いなという実感があったこともあり、納得感がありました。Use Casesを変更を受けてInterface Adapters側も変更をしなくちゃならない、というケースはよくあることだと思います。

また、Interface Adaptersのレイヤーの中にEntities, Use Casesが紛れ込んでいるコードはよく見かける気がしていて、Use Casesに当たるロジックをうまく維持しながらその両端にあるInterface Adaptersなロジックを修正する、みたいなことって結構今までやってきていたなという印象です。明確にUse Casesのロジックとして別コンポーネントに切り出すべきなんでしょうね。

今後のリファクタリングや機能追加時にはこのクリーンアーキテクチャの構造を適用できないか検討してみたいところです。

データベース/フレームワークは詳細

後半の章では、MySQLのようなRDBMSやSpringといったフレームワークは詳細であり、これらの詳細に強く依存しないように実装すべき、という主張がされています。

データベースへの依存はインターフェースを噛ませたりして抽象化しやすい仕組みではあるので同意なのですが、フレームワークに関しては実際問題特定のフレームワークに強く依存したコードにならざるを得ないケースが往々にしてあるだろうなという感想を持ちました。 特にフロントエンドの巷のフレームワークは記法が独特になりがちで強い依存は避けられないと思います。 (クリーンアーキテクチャのユースケースに当たるコードはフレームワークに依存しない記法にしやすいかもしれませんが)

結局、フレームワーク依存を避けた設計はフレームワーク採用による恩恵(生産性に大きく寄与する、可読性を維持しやすい、etc...)とのトレードオフなのだと思います。

iTerm2でよく叩く複数のコマンドを一括実行させてみる

macOSのターミナルで有名なiTerm2。

iterm2.com

実務でよくお世話になっているんですが、頻繁に叩く複数のコマンドを予めPythonスクリプトに記述しておいて一括実行させることもできます。 やってみて結構便利だったのでご紹介します。

Pythonスクリプトを新規作成する

まずはiTerm2のメニューからScripts > Manage > New Python Scriptを選択し、~/Library/Application Support/iTerm/Scripts配下に任意の名前でpyファイルを新規作成します。 ファイル作成前にテンプレのコード生成のためのウィザードが表示されますが、後で修正できるので任意のものを選択して問題ないです。

Pythonスクリプトを編集する

~/Library/Application Support/iTerm/Scripts配下に作成したpyファイルを開いて、iTerm2で実行する処理を記述していきます。

下記はタブを3つ開いて、それぞれのタブでコマンドを実行するスクリプトです。

import iterm2

num_tabs = 3

async def main(connection):
    app = await iterm2.async_get_app(connection)
    window = app.current_terminal_window

    if window is not None:
        await interrupt_sessions(window)

        await create_tabs(window, num_tabs)

    tabs = window.tabs
    for i, tab in enumerate(tabs[:3]):
        session = tab.current_session
        if session is not None:
            match i:
                case 0:
                    await tab.async_set_title("workspace/hogefuga: npm run")
                    await session.async_send_text(f'cd ~/workspace/hogefuga\n')
                    await session.async_send_text(f'npm start\n')
                case 1:
                    await tab.async_set_title("workspace/hogefuga/foobar: npm run")
                    await session.async_send_text(f'cd ~/workspace/hogefuga/foobar\n')
                    await session.async_send_text(f'npm start\n')
                case 2:
                    await tab.async_set_title("gradlew bootRun")
                    await session.async_send_text(f'cd ~/workspace/hogefuga\n')
                    await session.async_send_text(f'./gradlew bootRun\n')
        else:
            print("No current window")

async def interrupt_sessions(window):
    for tab in window.tabs:
        session = tab.current_session
    if session is not None:
        await interrupt_session(session)

async def interrupt_session(session):
    await session.async_send_text('\x03')

async def create_tabs(window, num_tabs):
    tabs = window.tabs
    for i in range(len(tabs), num_tabs):
        await window.async_create_tab()

iterm2.run_until_complete(main)

caseで分岐させてそれぞれのタブに対してsession.async_send_textを実行してコマンドを実行しています。 末尾の"\n"はエンターキーでのコマンド実行に相当します。

tab.async_set_titleではタブに表示される名前を固定化しています。デフォルトだとiTerm2はコマンドの実行状況によってタブ名が切り替わってしまうので見にくい場合は固定化するのがおすすめです。

なお、このスクリプトでは冒頭で実行中のコマンドを中断させるinterrupt_sessionを実行していますが、タブに対してCtrl+Cに当たる"\x03"を送信することで中断を実現しています。

Pythonスクリプトを実行する

さて、pyファイルが作成できたらiTerm2のメニューのScriptsに作成したファイルが新たに追加できるようになっているので実行してみましょう。

前のセクションで作成したpyファイルが実行され、別々のタブでコマンドが実行されるはずです。

半Vibe CodingでChrome拡張を作ってみた 続

先日CursorでほぼVibe CodingでChrome拡張を作りました。

tm-progapp.hatenablog.com

この拡張では右クリックメニューから拡張専用メニューを選択すると、任意のプロンプト+選択中のテキストをクリップボードにコピーしてAIサービスのプロンプト入力ページを新しいタブで開くことができるようになっています。 ただ、本来やりたかったのは拡張だけで任意のプロンプト+選択中のテキストをAIサービスにプロンプトを送信するところだったので、今回はそれを実現するためのリベンジです。

成果物

というわけでは今回はCursorに前回のリポジトリを修正してもらい、任意のプロンプト+選択中のテキストをOpenAI APIに送信して結果を画面表示するようにしてみました。

github.com

スクリーンショット

OpenAI API利用にあたってAPIキーでの認証が必要なので、まずオプションメニューで予め生成しておいたAPIキーを入力して保存します。 prefixとして付与するプロンプトには日本語訳をしてもらうよう指示を書いておきます。

任意のサイトの英文を選択し、専用の右クリックメニューを選択します。

こんな感じでOpenAIからのレスポンスが画面に表示されます。ちゃんと翻訳してくれてますね。

やってみての感想

8割方意図通りに仕様変更してくれた

「新しいタブでAIサービスを開くのではなく、OpenAI APIへリクエストして結果を画面表示してください」といった旨のプロンプトで仕様変更を指示したところ、既存のオプションメニューへのAPIキーの入力フォーム追加やOpen AI APIへのfetch処理をほぼ意図通り書いてくれました。 仕様変更によって不要になった処理の削除も特につまづくこともなくやってくれたのでなかなか優秀です。

CSSの微妙さは変わらず

人間が見たら明らかに違和感のあるボタンのpaddingの付け方をしたり、position: fixedで表示するDOMの位置が中途半端な位置だったり、前回同様に微妙でした。 正直人力で直したり改良した方が早かったです。

一貫性のある設計での出力は苦手そう

具体例としては、今回ReactのComponentは以下のように1つのComponentにつき1つのtsxファイルで管理するようにしていました。

https://github.com/TansanMilMil/zubora-gpt-extension-v2/blob/main/src/components/Popup.tsx

ただ、最初に生成してもらったコードには1つのtsxファイルに複数のComponentのレンダリングに関する処理が混じっていたので人力で直すことに。。

予めCursor RulesでReactの設計方針を定義しておくと改善するかも。

半Vibe CodingでChrome拡張を作ってみた

先日Cursor Meetup Tokyoがありましたが、現地参加者349人、オンライン参加者5818人とかなり熱量を感じたイベントでしたね。(当日connpassのサーバが一時落ちたのも印象的でした)

Cursor Meetup Tokyo - connpass

各セッションでのAI活用事例を聞きながら、私ももっとAIを活用したコーディングも試していくべきだろうなーという漠然とした思いから 半Vibe Coding(一部自分でもコードを書いたりコードの詳細な指示をしたのでそういう意味で"半"と書いてます)をやってみたので記事に起こしてみます。

今回やりたかったこと

抱えていた課題

  • ネットサーフィン中に読む英文をChatGPTのようなAIサービスに翻訳してもらいたいが、文章をコピペしてAIサービスの入力フォームにペーストして翻訳のためのプロンプトを書き足すでのがめんどくさいので効率化したい

具体的な実現方法

  • Chrome拡張で次の機能を実装する
    • 右クリックメニューに「AIサービスに質問する」みたいな項目を選べるようにし、選択したらAIサービスを新しいタブで開く
    • 新しいタブを開いたらAIサービスの入力フォームに選択中の文章を入力してEnterキーを押下する
    • AIサービス側の入力フォームの実装の都合上、拡張側から文字列を入力するのは難しそうだったのでプロンプトをクリップボードにコピーしておきすぐにペーストできる状態にする

ちなみにこの機能を作りたいと思った背景として、既にDeepLが提供するChrome拡張にテキスト選択した文字を右クリックメニューから翻訳してもらう機能があるんですが、ChatGPT等の方が翻訳の精度が高いことが多く最近あまり使わなくなったいう事情があったりします。。

実装にあたっての制約

  • CursorのAgentに実現したい機能を伝え、自分では"原則"コードを書かない
    • 完全にお遊びで書いたのであまりガチガチな制約にはしませんでした。
    • モデルは限定せずAutoを指定

成果物

github.com

README.mdもCursorくんに書いてもらったんですが、私のCursorくんはRulesで「ため口で話せ」と設定しているのでこんな感じにめっちゃ馴れ馴れしいREADMEです。

このドキュメントは"ずぼら"な開発者が Cursor(AI ペアプロ)で自動生成したものです。内容を 100%信じて地雷を踏んでも責任は取らんぞ! ついでに、このプログラム自体もほぼ Cursor で"vive coding"した、いわゆる AI 産のずぼらコードだ。人力成分は限りなく薄いぞ。

スクリーンショット

右クリックメニューから「XXXXでサクっと質問」が選択できるようになっている

選択すると新しいタブでAIサービスのサイトが開き、クリップボードにプロンプトがコピーされた状態になる

ここからは手動です。人間がクリップボードのプロンプトをペーストして質問します。

Chrome拡張の設定画面。事前にprefixとして付与したいベースのプロンプトを定義できます。また任意のAIサービスを動的に追加できるようにしています。

作ってみての反省

そもそもの話...作ってから思ったんですが、新しいタブでAIサービスを開くんじゃなくて単純にOpenAI API等を実行できるようにした方が便利でしたね(まぁ、また次回の改善点ということで...)

というわけで後日以下でリベンジしました

tm-progapp.hatenablog.com

半Vive Codingをやってみて

学びになったこと

Chrome拡張の土台となる実装を書くのは得意そう

前述した「具体的な実現方法」のようなプロンプトをCursorに指示してコードを書いてもらいましたが、最初はbackground.js, manifest.json, options.htmlなどとChrome拡張のベーシックなファイルはそつなく出力してくれました。

途中でjsからts(+Vite)に書き直してもらったんですが、ViteとChrome拡張に関するnpmパッケージの依存関係のコンフリクト問題(後述)以外はすんなり書いてくれたかなと思います。

アプリ名の命名相談にも乗ってもらえる

今回どういうアプリ名にするか結構悩んだので、Cursorに相談して決めました。 とにかくこのアプリで翻訳を楽にしたいんだよね、っていうニュアンスを伝えた結果、「ずぼらGPT」に決定。結構覚えやすくて良い。

CursorのRulesでフランクに返す人格を作っておくと反応が面白い

※実際のチャットの様子の例

Rulesにはため口で返してください程度しか指示してないんですが、なんか後半ツンデレな感じで返してくれるようになりました。何でだろう。

「エラーになった!!!」「動かなくなった!!!」とかおおよそエンジニアとは思えない表現を多用すると、「そんなふんわりとした表現じゃわかんねーよ」「またかよ」とド直球に返答してくれたりするので、そういう人間味感じるレスポンスが楽しかったです。(AI相手だからこそできるムーブですね)

あと、ビルド後のエラーを報告した際にChrome拡張ビルドしたら再読み込みしろよっていうのを何度も念押しされました。再読み込み、大事です。

大変だったこと

npmパッケージの依存関係をなかなか解消してくれなかった

当初はVite+CRXJSでビルドする構成を提案してくれていたんですが、CRXJSが対応するViteのバージョンが低く、Viteのバージョンを下げないと依存関係を解決できずエラーとなっていました。

この依存関係のエラーの原因解決のために10回前後Cursorとやり取りをしましたが結局解決できず、こちらから「そもそもCRXJS使わずにビルドする構成にしたら?」と提案して回避してもらいました。 ちょっと頑固な部分がある模様。

最初に書いてもらったCSSが微妙すぎる

拡張アイコンをクリックした設定メニューが表示されるようにしたんですが、文字が小さすぎて使い物にならなかったりスクロールバーがないのでコンテンツが見切れてしまったり、といった初歩的な問題が頻発しました。

スクリーンショットを取ってプロンプトに添付してあげると問題を理解してくれはするようですが、地道に何回もコミュニケーションしながらベターなスタイルに寄せていくことになりました。

リファクタリングを明示的に指示しないとクソデカメソッドや重複コードを平気で出力してくる

他所でも言われたりする話ですが、実務での拡張性や保守性を考慮できていない駆け出しエンジニアレベルのコードが出てくることが多々あるのでリファクタリングの指示は必須でした。

単一のファイルを予め指示してリファクタリングしてもらうとそれなりの品質には仕上がりました。

ビルドしてすぐ動くコードは出力してくれないので追加修正依頼が必要

開発の後半はビルドしたあと実際に出たエラーの文字をコピペして修正依頼をひたすら出していました。体感ですが20~30往復くらいはしたかもしれない。

意外とトータルの開発時間はかかった

結局、完成するまでトータルで5時間程度かかりました。 そのうち、2時間くらいはビルド後のエラー解消やnpmパッケージの依存関係の対処だったので、逆に言えばそこさえなければ早く作れたんだろうなとも思います。 この辺は工夫の余地がないか今後も試していきたいですね。

ここ最近のAIの使い方をまとめてみた

本業が忙しくめっきりブログ更新してなかったのですが、ネタが少しずつ溜まってきたので徐々に再開していこうと思います。

今回は最近自分がどうAIを活用をしているのかを書き連ねてみようと思います。

Perplexity

www.perplexity.ai

一番利用頻度高。

用途としてはプログラミングのある概念についての概要を知りたい時やちょっとしたサンプルコードを生成してもらったり、雑学について聞いてみたり、と幅広く調べものに使っています。 Google検索と併用する感じになりましたね。

回答時に参照したソースも提示してくれるので自分でソースを辿って妥当性をチェックすることもできるようになっている部分が好きです。

クラスやメソッドの命名規則に悩んだ時に壁打ちすると色々候補を出してくれるので悩む時間が減りました。

Claude

claude.ai

Perplexity, ChatGPTと併用してプログラミングに関する調べものやサンプルコード生成に使ってます。

Perplexityが怪しい回答をした場合のセカンドオピニオンの相手役。

ChatGPT

chatgpt.com

他のAIと比較して翻訳が得意なようで自作ゲームのローカライズやスラングの混じったような翻訳難易度の高い英文をぶん投げて翻訳してもらったりしてます。 DeepLよりも口語的な文章をうまく解釈してくれるので重宝してます。

あと、質問者に忖度したり気持ちに配慮したような回答をよく返してくれるなーっていう印象があります。 そりゃどっぷり漬かってしまう人も出てくるよなぁ。

GitHub Copilot

github.com

VSCode経由でAgentにコード書いてもらったり、エラーが出た時に原因を聞いたりしてます。 ただ本当に欲しい回答がもらえる確率は体感で4~6割くらい。もしかしたらプロンプトがよくないのかもしれないですが...。

1ファイルに限定してコードを書いてもらう程度なら良いですが、プロジェクト全体のコード記述となるとまだ実用レベルではないかなというのが正直な感想。 出力されたコードをリファクタせずに採用できる確率は低いので、慣れた言語なら自分で書いた方がまだ早かったりもする。

以前、AgentにSpringのコードを丸々Goに書き換えてもらったんですが、一部のコードのビジネスロジックが丸々デバッグ用の定数を返すような内容に勝手に書き換わっていたりしたので、なかなか手直しが大変でした。

NotebookLM

notebooklm.google.com

Webサイトをデータソースとして読み込ませると要約に加えてラジオ番組風の音声を生成して紹介してくれる。

単語の読み方が怪しい部分がちょこちょこあったりAI特有の話のクドさみたいな部分はあるけど、パーソナリティの言葉選びがそれっぽいし音声の抑揚も割とリアルで面白い。 仕事というよりホビーで遊んでます。

Gemini

gemini.google.com

ChatGPTの代用としてたまにきまぐれに使うくらい。 Googleカレンダーからデータを参照できるので、明日の予定を聞いたりできるところが強みみたい。

Javaプロジェクトを参照してClean ArchitectureのD(Distance)値を出力させてみた

最近Kindleで「Clean Architecture」を読んでいます。 ボブおじさんことRobert C.Martin氏の有名な著書ですね。

この書籍の14章では「SAP = Stable Abstractions Principle(安定度・抽象度等価の原則)」という原則が紹介されるのですが、そこでは依存性管理の指標の1つとしてInstability, Abstractness, Distanceというパラメータが提示されています。 これらのパラメータはそれぞれ実際のソースコードを参照して計算式を使って算出することが可能なものになっています。

そこで、実際に手元にあるJavaプロジェクトのコードを参照して、これらのパラメータを算出・出力して適切な依存性管理ができているのかを分析してみると面白いのではと思い試してみました。

前提の話

まず、Instability, Abstractness, Distanceの定義について書籍の内容をまとめてみます。

  • Instability(I)
    • 算出方法:ファン・アウト ÷ (ファン・イン + ファン・アウト)
      • ※ファン・イン=コンポーネント内のクラスに依存している外部コンポーネント数。
      • ※ファン・アウト=コンポーネント内における外部クラスに依存しているクラス数。
      • 値は0~1の範囲をとる
        • 0は最も安定しているコンポーネントであることを示す
  • Abstractness(A)
    • 算出方法:A = Na ÷ Nc
      • ※Nc = コンポーネント内のクラスの総数
      • ※Na = コンポーネント内のabstract classとinterface
      • 値は0~1の範囲をとる
        • 0であればコンポーネントにabstract class, interfaceが一切含まれないことを示す
  • Distance(D)
    • 算出方法: D = | A + I - 1 |
    • 値は0~1の範囲をとる
      • 0であればコンポーネントが主系列(※後述)上にあることを示す

そしてこれらのパラメータの関係性を図にしたものが以下です。

縦軸がA、横軸がIになっており、斜めのラインが「主系列」と定義されており この主系列に近いほどコンポーネントにとって理想的な場所と言われています。

Dは0に近いほど主系列に近い...つまり依存性管理においてコンポーネントが望ましい状態ということを示しています。 逆に1に近いほど主系列から遠く、依存性管理においてコンポーネントが望ましくない状態であると言えるでしょう。

やりたいこと

さて前提の説明はここまでにして、今回の目的と実現したいことは以下です。

目的

  • 既存のJavaプロジェクトのI, A, Dの値を分析し、依存関係の改善が必要なコンポーネントはどの辺に存在するのかをあぶり出してみる
    • コードをどこからリファクタリングしていくのか考える時の助けになるかもしれない

実現したいこと

  • 既存のJavaプロジェクトの特定パッケージにおける依存関係をチェックし、I, A, Dの値をそれぞれ計算する
  • 計算したI, A, DについてCSVファイルに出力する

実際のソースコード

では分析したいJavaプロジェクトに追記をしていきます。今回はGradleでプロジェクト管理しているケースの実装となります。

なお今回はPerplexityくんにソースコード案を出してもらって手直しをしました。やりたいことが定まっててかつ小規模な実装をするときにAIは便利ですね~。

※余談ですがJDpendという分析ライブラリでも同様のことができるようなのですが、2020/7からメンテがされておらず今回は使用を断念...。 github.com

build.gradle.kts へ追記する

...
dependencies {
    implementation("io.github.classgraph:classgraph:4.8.172")
}
...
tasks.register<JavaExec>("runCleanArchitectureMetrics") {
    group = "application"
    description = "Runs the CleanArchitectureMetrics main class with specified arguments"

    // ここでsetする値は適宜完全修飾名に書き換えてください
    mainClass.set("your.package.CleanArchitectureMetrics")
    classpath = sourceSets["main"].runtimeClasspath

    args = providers.gradleProperty("appArgs").orNull?.split(" ") ?: emptyList()
}
...

CleanArchitectureMetrics.java を任意の場所に新規作成

package com.birdseyeapi.metrics;

import io.github.classgraph.*;
import java.io.FileWriter;
import java.io.IOException;
import java.util.*;

public class CleanArchitectureMetrics {

    public static void main(String[] args) throws IOException {
        if (args.length < 2) {
            throw new IllegalArgumentException(
                    "Invalid arguments. Usage: java CleanArchitectureMetrics <package> <output.csv>");
        }
        final String targetPackage = args[0];
        final String outputCsv = args[1];

        final Map<String, ClassInfo> classInfoMap = new HashMap<>();
        final Map<String, Set<String>> efferentMap = new HashMap<>();
        final Map<String, Set<String>> afferentMap = new HashMap<>();

        try (ScanResult scanResult = new ClassGraph()
                .enableAllInfo()
                .enableInterClassDependencies()
                .acceptPackages(targetPackage)
                .scan()) {

            for (final ClassInfo classInfo : scanResult.getAllClasses()) {
                classInfoMap.put(classInfo.getName(), classInfo);

                final Set<String> efferents = new HashSet<>();
                for (final ClassInfo dependency : classInfo.getClassDependencies()) {
                    if (!dependency.getPackageName().startsWith(targetPackage))
                        continue;
                    efferents.add(dependency.getName());
                }
                efferentMap.put(classInfo.getName(), efferents);

                for (final String dep : efferents) {
                    afferentMap.computeIfAbsent(dep, k -> new HashSet<>()).add(classInfo.getName());
                }
            }
        }

        final Map<String, List<ClassInfo>> packageClassMap = new HashMap<>();
        for (final ClassInfo ci : classInfoMap.values()) {
            packageClassMap.computeIfAbsent(ci.getPackageName(), k -> new ArrayList<>()).add(ci);
        }

        try (final FileWriter writer = new FileWriter(outputCsv)) {
            writer.write("ClassName,Abstractness,Instability,Distance\n");
            for (final String pkg : packageClassMap.keySet()) {
                final List<ClassInfo> classes = packageClassMap.get(pkg);
                final int total = classes.size();
                int abstractCount = 0;
                int interfaceCount = 0;

                for (final ClassInfo ci : classes) {
                    if (ci.isAbstract())
                        abstractCount++;
                    if (ci.isInterface())
                        interfaceCount++;
                }
                final double A = (double) (abstractCount + interfaceCount) / total;

                int efferent = 0;
                int afferent = 0;
                for (final ClassInfo ci : classes) {
                    final Set<String> eff = efferentMap.getOrDefault(ci.getName(), Collections.emptySet());
                    efferent += eff.size();
                    final Set<String> aff = afferentMap.getOrDefault(ci.getName(), Collections.emptySet());
                    afferent += aff.size();
                }
                final double I = (efferent + afferent) == 0 ? 0 : (double) efferent / (efferent + afferent);
                final double D = Math.abs(A + I - 1);

                for (final ClassInfo ci : classes) {
                    writer.write(String.format("%s,%.3f,%.3f,%.3f\n", ci.getName(), A, I, D));
                }
            }
        }
        System.out.println("CSV出力完了: " + outputCsv);
    }
}

コードの実行

計算対象とするパッケージ名と出力するCSVファイル名をargsに指定してあげると、CSVが出力されるはずです。

./gradlew runCleanArchitectureMetrics -PappArgs="com.example.yourpackage result.csv" --stacktrace

実行結果

さて出力されたCSVの中身を見てみましょう。

例えばこの結果を見ると、Distanceが最も1に近いSummarizeNewsはコンポーネントとして望ましくない状態とみなせるかもしれません。 Abstractnessを上げる(抽象化する)方向でリファクタリングする、という方向性は考えられそうですね。

このような形で、依存関係の設計上の問題をあぶり出す手段の1つとしてDistanceを活用してみても良いかもしれません。

「エンジニアリングマネージャーのしごと」を読了した

最近Engineering Manager(EM)というロールで日々開発を推進するようになりました。 ロールを任命された当初は右も左も分からない状態でしたが、上司から薦められた「エンジニアリングマネージャーのしごと」という書籍を読んだことで いくつか実務上の学びに繋がった点があったので記事として起こしてみました!

www.oreilly.co.jp

良かった点

具体的なアクションや方針についての記載がある

書籍の「はじめに」の章に記載があるように、仕事をこなすためのスキルについて記載があるのが特徴です。

マネージャーが直面する業務マネジメント、人材マネジメントの場面において「どう判断し行動するか」「どのように問題を咀嚼し理解に落とし込むのか」について比較的平易に書いてくれています。 マネジメント何も分からない、の状態から読んでも、読了後にはある程度の行動指針を自分の中に置いて行動できるようになりそうです。

マネージャー未経験者でも読める内容

本書はプログラマからエンジニアリングマネージャーにロールチェンジしたような方々を主にターゲット書かれています。 それ故か巷でよくある技術書を読むのに近い感覚で読むことができました。

マネジメントの分野における概念や取り得るアクションを不必要な修飾なく説明してくれており、 ポジショントーク的なエピソードも少ないことからそう感じたのかもしれません。

疑問に感じた点

16章 現代の職場環境

16章ではダイバーシティ、インクルージョンについての考え方が語られていますが、 アメリカの統計をベースに話が展開されているため日本とは少々事情が違いそうなのでその点を踏まえて読む必要がありそうでした。

ダイバーシティを推進することは革新的なイノベーションにつながる、だからマネージャーはダイバーシティを考えた行動をとっていくべきだというのが大筋の著者の意見のようです。

ただ、単なる1マネージャーが企業のダイバーシティを先導していけるのかというと、そのようなことができるのは実質的な権限を持つ上位のごく限られたマネージャーだろうと思います。 採用する従業員の性別・人種・社会的マイノリティについての方針をリードするのは、本書がターゲットとするような新米マネージャーにはかなり荷の重い内容でしょう。 この章に記載のあるアクションに関しては、役員レベルから検討し全社的に取り組んでいった方がうまく機能するのではと思いました。(各社員がダイバーシティについての価値を理解すること自体には意味があると思います)

実務に生かせた内容

3章 人間と関わる 委譲

3章では自分のタスクを相手の習熟度に合わせて委譲の度合いを調整し、委譲するという内容が記載されています。 今まで何となく抽象的に感じていた委譲について体系的にまとめてくれており頭の整理ができました。

委譲においてやってはいけないことの1つとして「ほかの人のやり方が自分と同じであることを期待すること」という記載があり、この点はかなり共感しました。 マネージャーは委譲するタスクの「説明責任」を負っているのであって「プロセス」ではないという記述が印象的でしたね。 ある程度社会人経験を積んでおり成功体験のある人ほど、プロセスに口を出したくなることって少なくはないと思うのですが マネージャーとして生きていく上では相手を信頼してプロセスは任せるというのが大事。

この辺はエンジアリングマネージャーに限らず、全業種のマネージャー業の人が理解すべき内容だなーと強く感じます(かつてのマイクロマネジメントな上司を思い出しながら…)

12章 情報の証券取引所 必要十分な情報共有するには

センシティブな情報(給与情報、メンバーのパフォーマンス、組織変革、人員削減、など)について、どれくらいの粒度で情報を公開するのが適切か、を注意深く考えて発言する(もしくは何も発言しない)ことの重要性について書かれているのが12章です。

私は新卒で入社した会社で部下の人事異動についても検討する機会があったのですが、上司から異動については現場には非公開にしておけと言われることがよくありました。 その理由を当時の私は「現場が揉める、混乱することを避けたいのだろう」と何となく直感的に理解しており、本書によってその答え合わせができました。

本書では記載のない話ですがこういった情報の取り扱いの重要性を考えると、性格的にマネージャーに向くタイプ・向かないタイプもおそらくある程度パターン化できるのだろうなと感じます。

13章 コントロールを手放す LモードとRモード

この章で人間の脳はデュアルCPUで常にどちらか一方しか稼働できないものとしてモデル化されて説明されています。 それぞれのCPUには別の特徴があり、以下のようにLモード・Rモードと定義されていました。

  • Lモード:命令を順次実行する。遅くて線形。LはLiner(線形)のこと。
  • Rモード:パターン検索とマッチングを非同期実行する。無関係に見えるものの中から関係性を見つけ出せるがコントロールはできない。RはRichのこと。

Lモードは個々の詳細の実行に必要、またRモードは新しいアイデアを思いついてイノベーションを起こすために必要。 両方のモードを併用することが重要とされていました。 Rモードは業務後にお風呂に入っていたらふと悩んでいたタスクの解決策が浮かぶ、というアレのことです。

Rモードを機能させるためにあえて「何もしない」スケジュールを設定し、思考する時間をとりましょうというアドバイスがありこれは最近試しています。 コントロールできない、というのが厄介ですが、以前よりかは俯瞰して物事を整理したり考えを抽出できているのかなという感じです。

あとがき

本書はマネージャーが遭遇するであろうタスクを網羅的に解説してくれている一方で、さらにもう1段深いレベルの技術に関しては記載がありません。 例えば1on1をスムーズに機能させるための話し方や、議論の際の適切なファシリテーションの方法については別の参考書を読んでいく必要がありそうです。

MySQL(InnoDB)のUndo Logs, history listとは何ぞや?

先日、MySQL(InnoDB)でトランザクションを張ったまま大量のクエリを発行することによりhistory listが肥大化し障害に繋がるケースがあることを知り、個人的に調査した記録を残そうと思います。

Undo Logとは

まず、history listを理解する前にUndo Logを理解する必要があります。

MySQL :: MySQL 9.0 Reference Manual :: 17.6.6 Undo Logs

Undo Logは単一の読み取り/書き込みのトランザクションに関するUndo Logレコードの集まりのことを指します。

Undo Logはトランザクションにおける変更のログを情報として保持するものです。ロールバックの用途であるためにRedo用の情報は含んでいません。 consistent readが実行された際はUndo Logレコードからデータが参照されます。

"consistent read"は以前以下の記事にて取り上げてます。

tm-progapp.hatenablog.com

history listとは

MySQL :: MySQL 9.0 Reference Manual :: 17.8.9 Purge Configuration

MySQL :: MySQL 9.0 Reference Manual :: MySQL Glossary

InnoDBはDELETE文が発行されても即時には物理削除をしません。 MultiVersion Concurrency Control(MVCC)の管理上不要になったり、ロールバックするために必要ではなくなった場合に物理削除されるようになっており、これはpurgeと呼ばれています。

MVCCについては以前以下の記事にて取り上げてます。

tm-progapp.hatenablog.com

InnoDBは定期的にpurgeをするようになっており、purge時にはdelete markerが付けられたレコードが物理削除されますが、そのようなレコードを含んだトランザクションのリストが"history list"です。

history listがinnodb_max_purge_lag の値より大きくなった場合、各DMLは遅延します。

history listを確認する

以下のコマンドを実行するとTRANSACTIONSのセクションにてHistory list lengthが確認できます。

mysql> SHOW ENGINE INNODB STATUS;

...
------------
TRANSACTIONS
------------
Trx id counter 1174188
Purge done for trx's n:o < 1174188 undo n:o < 0 state: running but idle
History list length 33
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421192588579624, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 421192588578816, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 421192588578008, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 421192588577200, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 421192588576392, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
...

何故長時間のトランザクションがhistory listの肥大化を招くのか

公式ドキュメントに以下の記載がありました。

The History list length is typically a low value, usually less than a few thousand, but a write-heavy workload or long running transactions can cause it to increase, even for transactions that are read only. The reason that a long running transaction can cause the History list length to increase is that under a consistent read transaction isolation level such as REPEATABLE READ, a transaction must return the same result as when the read view for that transaction was created. https://dev.mysql.com/doc/refman/9.0/en/innodb-purge-configuration.html#idm45552486500384

つまり、Transaction Isolation LevelがREPEATABLE READの場合はconsistent readを実現する(常に同じ取得結果を返す)必要があるが、そのためにはhistory listに変更のログを保持し続ける必要がある。 そのため、トランザクションが張り続けられたまま大量のクエリが発行されるとhistory listの肥大化がDBサーバの負荷となり得るようです。

バックエンドでDBアクセスを行うロジックを書く場合、こういった点も考慮が必要という学びでした。