
ソースコードを整理して開発を高速化する
こんにちわ、リテールアプリ共創部のマッハチームの西田です
今回は筆者が開発速度の向上のために、日々自分が心がけていることをご紹介いたします
特に特殊なことではなく、昔から心がけてる基本的なことを紹介させていただきます。
現在では、70%から80%くらいはAIでコーディングしていますが、今でも変わらずに心がけています
心がけてること
- 極力単機能になるまで分解する
- 単純な機能を組み合わせ複雑な機能を作成する
個人的に、コードはキーボードを使ってぽちぽち書いてる時間よりも、読んでる時間の方が長くなりがちです。自分が書いたコードの確認、他の人の書いたコードのレビュー、不具合発生時の調査、改修の時のあたりをつける… etc。AIを使うようになって、この傾向はさらに強くなってるように感じます
また、コードを書く時間よりも、動作保証できるようになるまで、自動テストを作成したり、手動でUIを操作したり、curlを使ってAPIを動作確認してる時間の方が長くなることもしばしばあります
そのため、開発効率を上げるために、以下の点を重視して開発しています
- 小さい意味のある機能に分離する
- 動作保証されてるコードの塊を作る
小さい意味のある機能に分離する
人の短期的な記憶、ワーキングメモリーで記憶しておけるのは 3 〜 5個程度と言われています。そのため、覚えておかないといけないコンテキストが増えれば増えるほど、ワーキングメモリから溢れ、読み直しが発生したり、間違った情報で補完してしまったりします
ソースコードも複雑な処理がフラットに書かれていると、その後の処理を読み解く時に、コンテキストとして、それまで読んだコードを覚えておく必要が出てきます
例を挙げて説明します。例えば、以下のような処理を行うAPIがあるとします
- 別サービスのAPIを使ってデータを取得する、ただしすでにデータベースにデータがあれば、キャッシュからデータを取得する。キャッシュの有効期限無いならそのままキャッシュを返却し、有効期限切れ、もしくは、キャッシュが存在しない場合は、APIからデータを取得しキャッシュとして保存する
- ユーザーのステータスが退会済みであればエラーにする
- ユーザーの購入情報を取得する(キャッシュ処理は同様)
- 上記データを加工してAPIのレスポンスとして返す
これを一つの処理として書くと以下のような感じでしょうか
async function getUserHandler(userId: string) {
// キャッシュを確認
const cached = await db.get(`user:${userId}`);
let userData;
if (cached) {
if (cached.expiresIn < Date.now()) {
await db.delete(`user:${userId}`);
const response = await fetch(`https://api.example.com/users/${userId}`);
userData = await response.json();
await db.set(`user:${userId}`, { data: userData, expiresIn: Date.now() + 3600000 });
} else {
userData = cached.data;
}
} else {
// 外部APIを呼び出し
const response = await fetch(`https://api.example.com/users/${userId}`);
userData = await response.json();
// キャッシュに保存
await db.set(`user:${userId}`, { data: userData, expiresIn: Date.now() + 3600000 });
}
if (userData.status == 'inactive') {
throw new InvalidStatusError();
}
// 購買データを取得
const response = await fetch(`https://api.example.com/purchases/${userId}`);
const purchaseData = await response.json();
// データを加工してレスポンス用に整形
return {
id: userData.id,
fullName: `${userData.firstName} ${userData.lastName}`,
totalPurchases: purchaseData.total,
};
}
処理が複雑になり、読み解くのに時間がかかりそうです
処理をサブルーチンに分けて、意味ある名前につけてみます
async function fetchUserIfNeeded(userId: string) {
// キャッシュを確認
const cached = await db.get(`user:${userId}`);
if (cached) {
if (cached.expiresIn < Date.now()) {
// 有効期限切れ
await db.delete(`user:${userId}`);
const response = await fetch(`https://api.example.com/users/${userId}`);
const userData = await response.json();
await db.set(`user:${userId}`, { data: userData, expiresIn: Date.now() + 3600000 });
return userData;
} else {
// 有効期限内
return cached.data;
}
} else {
// キャッシュが存在しない
const response = await fetch(`https://api.example.com/users/${userId}`);
const userData = await response.json();
// キャッシュに保存
await db.set(`user:${userId}`, { data: userData, expiresIn: Date.now() + 3600000 });
return userData;
}
}
async function fetchPurchases(userId: string) {
const response = await fetch(`https://api.example.com/purchases/${userId}`);
return await response.json();
}
function validateUserStatus(user) {
if (user.status == 'inactive') {
throw new InvalidStatusError();
}
}
async function getUserHandler(userId: string) {
const userData = await fetchUserIfNeeded(userId);
validateUserStatus(userData);
const purchases = await fetchPurchases(userId);
// データを加工してレスポンス用に整形
return {
id: userData.id,
fullName: `${userData.firstName} ${userData.lastName}`,
totalPurchases: purchases.total,
};
}
こうすることで、 それぞれのサブルーチンはサブルーチンごとで短い処理を読んで機能を把握しやすくなります、メインのルーチン(この例で言う getUserHandler) は、全体の流れを俯瞰できるようになります
また、関数の名前を、その内容の処理がわかるよう命名しておくと、人が読んでも理解しやすく、AIのコンテキストを減らせる可能性も出てきます
動作保証されてるコードの塊を作る
前回のサンプルにあるキャッシュの機構は共通化できそうです。
この共通化したキャッシュを呼び出す機能を呼び出す側は、以下の関心(期待)を持って呼び出す考えられます
- キャッシュがあればキャッシュからデータを返却する
- キャッシュがなければ fetch し、DBにキャッシュする
- 有効期限が切れればキャッシュを更新する
これらの関心(期待)は、他の fetch する処理でも同じような関心がありそうです
共通化の例としてこんな感じでしょうか
interface WithCacheOptions<T> {
db: any;
key: string;
fetcher: () => Promise<T>;
ttl?: number;
}
export async function withCache<T>(options: WithCacheOptions<T>): Promise<T> {
const { db, key, fetcher, ttl = 3600000 } = options;
// キャッシュチェック
const cached = await db.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.data as T;
}
// 期限切れなら削除
if (cached) {
await db.delete(key);
}
// 新規取得
const data = await fetcher();
await db.set(key, {
data,
expiresAt: Date.now() + ttl,
});
return data;
}
呼び出し側もこの関数を使うようにコードを変更します。
async function fetchUserIfNeeded(userId: string): Promise<User> {
return withCache({
db,
key: `user:${userId}`,
fetcher: async () => {
const response = await fetch(`https://api.example.com/users/${userId}`);
return response.json();
},
ttl: 3600000, // 1時間
});
}
async function fetchPurchases(userId: string) {
const response = await fetch(`https://api.example.com/purchases/${userId}`);
return await response.json();
}
function validateUserStatus(user) {
if (user.status == 'inactive') {
throw new InvalidStatusError();
}
}
async function getUserHandler(userId: string) {
const userData = await fetchUserIfNeeded(userId);
validateUserStatus(userData);
const purchases = await fetchPurchases(userId);
// データを加工してレスポンス用に整形
return {
id: userData.id,
fullName: `${userData.firstName} ${userData.lastName}`,
totalPurchases: purchaseData.total,
};
}
元々のソースから必要な情報を読み取りやすくなったのでは無いでしょうか
また、キャッシュの処理は、それ単体で自動テストを作成できます。呼び出し側の関心をそのままテストケースにできます。
- キャッシュがあればキャッシュからデータを返却する
- キャッシュがなければ fetch し、DBにキャッシュする
- 有効期限が切れればキャッシュを更新する
そうしておくと、fetchした結果をキャッシュしておきたい他のケースでこの関数が再利用できます。キャッシュの有効期限の処理等は、この関数の単体テストで保証され、新しくテストを追加する必要がなくなります
こういった、動作保証されたコードの塊をいくつも作ることで、テストに使う時間を減らすことができ、結果、開発の高速化につながっていきます
最後に
今回は開発の高速化をテーマに、筆者が日頃心掛けてることを紹介させていただきました。開発効率がN倍といった派手な効果はないですが、こういったことの積み重ねで開発を高速化しています。この記事が誰かの役に立てば幸いです