POLプロダクト Advent Calendar 2020 の19日目担当、プロダクト部でエンジニア/エンジニア広報をしている @sho-kanamaru です!
エンジニア広報するにあたってTwitter運用し始めました!こちらでございます。
POLのこと、技術に関することをどんどん発信していくのでぜひぜひフォローお願いします!
昨日はAWSの申し子ゾネスさん @takahiro-yamada のAmazon Managed Service for Prometheusの記事でした!ぜひ読んでみてください!
今回は本日実施した弊社のエンジニアイベント「オンラインモブプロ会 #2」の実況をしていきたいなと思っております。
モブプロとは?
モブプロとはモブプログラミングの略称です。「モブ」は群衆という意味で、複数人で同時にプログラミングを行います。1台のパソコンをモニターに接続して、1人がドライバーとしてプログラムの編集作業を行い、他のメンバーがナビゲーターとなり、ドライバーや他のメンバーとコミュニケーションをとりながら設計・実装を進めていく方法です。
このモブプロをオンラインでやってみよう!ということで、本日弊社が運営している「オンラインモブプロ会 #2」を開催しました!
事前準備
今回はVS CodeのRemote SSHという機能を使って、サーバー上のコードを直接いじります。
基本的にsshが繋がる環境ならssh先のファイルを開くことができます!Dockerコンテナ内のファイルも開くことは可能なようです!
Remote SSHのインストール、接続方法に関しては以下の記事がわかりやすいので参考にしてみてください。
https://blog.serverworks.co.jp/tech/2020/02/20/vscode-remote-ssh/
テーマ
ISUCONのソースコードをモブプログラミングで触れてみよう
ISUCONとは
お題となるWebサービスを、決められたレギュレーションの中で限界まで高速化を図るチューニングバトル
今回はISUCON 9の予選問題を用いて、モブプロでチューニングしていきます。
今回課題となったアプリケーションは、椅子を売りたい人/買いたい人をつなげるフリマアプリ「ISUCARI」です。
ソースコードはこちら
https://github.com/pol-inc/isucon9-qualify
モブプロ時の決まり事
-
30分ごとにドライバーの担当を交代しよう
前回は事前準備が足りず、ドライバーが固定されてしまっていたので、今回は全員が均等にコードを書く時間を設けました。 -
わからないことは何でも質問しよう
モブプロは全員が納得して進める必要があるため、少しでもわからないことがあったら何でも質問できる雰囲気を作ることを心がけました。
イベント当日
今回は1チーム3〜4人で、2チームに分かれてモブプロを進めました。
N+1問題の解決
新着商品一覧 items
ごとにuser情報とcategory情報のクエリを叩いているわかりやすいN+1問題がありました。
const [rows,] = await db.query(
"SELECT * FROM `items` WHERE `status` IN (?,?) ORDER BY `created_at` DESC, `id` DESC LIMIT ?",
[
ItemStatusOnSale,
ItemStatusSoldOut,
ItemsPerPage + 1,
],
);
for (const row of rows) {
items.push(row as Item);
}
for (const item of items) {
// N+1発生
const seller = await getUserSimpleByID(db, item.seller_id);
if (seller === null) {
replyError(reply, "seller not found", 404)
await db.release();
return;
}
// N+1発生
const category = await getCategoryByID(db, item.category_id);
if (category === null) {
replyError(reply, "category not found", 404)
await db.release();
return;
}
}
ここで、商品一覧を取得するSQLでuser情報とcategory情報をジョインすることで、N+1問題を解決しました。
const [rows,] = await db.query(
'SELECT
*
FROM
`items` LEFT JOIN users ON (
items.seller_id = users.id
) LEFT JOIN categories ON (
items.category_id = categories.id
)
WHERE
`items.status` IN (?,?)
ORDER BY
`items.created_at` DESC,
`items.id` DESC
LIMIT ?',
[
ItemStatusOnSale,
ItemStatusSoldOut,
ItemsPerPage + 1,
],
);
今回は元のAPIの仕様のままResponseを返すようにしていたため、このあとゴリゴリとmapしていますが、そちらは省略させていただきます。
共通部分の切り出し
- 新着商品一覧取得
GET /new_items.json
- カテゴリごとの新着商品一覧取得
GET /new_items/%d.json
- ユーザごと新着商品一覧取得のAPIがあり、
GET /users/%d.json
の3つのAPIがあるのですが、中身の処理がほとんど同じで、全てで上記のようなN+1問題が発生していました。そのため、こちらの共通部分を切り出し、N+1問題を解決すると同時にリファクタリングをすることで可読性を高くしました。
元々のコードは
// 新着商品一覧取得
// クエリパラメータ(itemId, createdAt)の有無で条件分岐
if (itemId > 0 && createdAt > 0) {
const [rows,] = await db.query(
"SELECT * FROM `items` WHERE `status` IN (?,?) AND (`created_at` < ? OR (`created_at` <= ? AND `id` < ?)) ORDER BY `created_at` DESC, `id` DESC LIMIT ?",
[
ItemStatusOnSale,
ItemStatusSoldOut,
new Date(createdAt),
new Date(createdAt),
itemId,
ItemsPerPage + 1,
],
);
for (const row of rows) {
items.push(row as Item);
}
} else {
const [rows,] = await db.query(
"SELECT * FROM `items` WHERE `status` IN (?,?) ORDER BY `created_at` DESC, `id` DESC LIMIT ?",
[
ItemStatusOnSale,
ItemStatusSoldOut,
ItemsPerPage + 1,
],
);
for (const row of rows) {
items.push(row as Item);
}
}
// カテゴリごとの新着商品一覧取得
if (itemID > 0 && createdAt > 0) {
const [rows] = await db.query(
"SELECT * FROM `items` WHERE `status` IN (?,?) AND category_id IN (?) AND (`created_at` < ? OR (`created_at` <= ? AND `id` < ?)) ORDER BY `created_at` DESC, `id` DESC LIMIT ?",
[
ItemStatusOnSale,
ItemStatusSoldOut,
categoryIDs,
new Date(createdAt),
new Date(createdAt),
itemID,
ItemsPerPage + 1,
]
);
for (const row of rows) {
items.push(row as Item);
}
} else {
const [rows] = await db.query(
"SELECT * FROM `items` WHERE `status` IN (?,?) AND category_id IN (?) ORDER BY `created_at` DESC, `id` DESC LIMIT ?",
[
ItemStatusOnSale,
ItemStatusSoldOut,
categoryIDs,
ItemsPerPage + 1,
]
);
for (const row of rows) {
items.push(row as Item);
}
}
のようになっていたため、
- クエリパラメータによる条件分岐をなくす
- 上記3つのAPIで使用できる汎用的なクエリを作る
ことを意識して、クエリのビルダーメソッドを作成しました。
const builderQuery = (targetStatus: Array<string>, itemsPerPage: number, createdAt?: number, itemId?: number) => {
// SELECT句、FROM句は共通
let baseQuery: string =
'SELECT
items.id,
items.seller_id,
users.account_name,
users.num_sell_items,
items.status,
items.name,
items.price,
items.image_name,
items.category_id,
categories.parent_id,
categories.category_name,
items.created_at
FROM
items LEFT JOIN users ON (
items.seller_id = users.id
) LEFT JOIN categories ON (
items.category_id = categories.id
)';
const params = [];
// targetStatusの有無によってWHERE句を作成
let targetConditionStr: string = ''
if (targetStatus.length > 0) {
targetConditionStr += 'AND items.status IN (?)';
params.push(targetStatus)
}
// クエリパラメータの有無によってWHERE句を作成
let queryConditionStr: string = '';
if ((createdAt && createdAt > 0) && (itemId && itemId > 0)) {
queryConditionStr = 'AND (items.created_at < ? OR (items.created_at <= ? AND items.id < ?))';
params.push(new Date(createdAt));
params.push(new Date(createdAt));
params.push(itemId);
}
const orderByStr: string = 'items.created_at DESC, items.id DESC';
const limitStr: string = '?';
params.push(itemsPerPage + 1)
return {
query: `${baseQuery} WHERE 1=1 ${targetConditionStr} ${queryConditionStr} ORDER BY ${orderByStr} LIMIT ${limitStr}`,
params
}
}
このように書くことになって、上記の元々のコードが以下のように簡潔に書けるようになります。
const { query, params } = builderQuery([ItemStatusOnSale, ItemStatusSoldOut], ItemsPerPage, createdAt, itemId);
const [rows,] = await db.query(selectQuery, params);
時間の関係で、新着商品一覧取得のリファクタしかできず、カテゴリごとの新着商品一覧取得、ユーザごと新着商品一覧取得でも使用するためにはもう少しカスタマイズしてあげる必要がありますが、概ねこのメソッドを利用することでスッキリしたコードが書けるようになりました。
外部キー制約の追加
DDLを確認してみると外部キー制約を貼っていなかったので、seller_id, buyer_id, category_idに外部キー制約を追加しました。
DROP TABLE IF EXISTS `items`;
CREATE TABLE `items` (
`id` bigint NOT NULL AUTO_INCREMENT PRIMARY KEY,
`seller_id` bigint NOT NULL,
`buyer_id` bigint NOT NULL DEFAULT 0,
`status` enum('on_sale', 'trading', 'sold_out', 'stop', 'cancel') NOT NULL,
`name` varchar(191) NOT NULL,
`price` int unsigned NOT NULL,
`description` text NOT NULL,
`image_name` varchar(191) NOT NULL,
`category_id` int unsigned NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category_id (`category_id`),
FOREIGN KEY (`seller_id`)
REFERENCES `users` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY (`buyer_id`)
REFERENCES `users` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY (`category_id`)
REFERENCES `categories` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4;
ここでタイムアップ。
あっという間の3時間でした。
最初は環境構築もあったのでコーディングしていた時間は2時間半くらいだったかと。
さいごに
今回はオンラインモブプロ会にて、N+1問題の解決やリファクタリングに挑戦しました。
もう1つのチームでは、ORクエリを使用している部分をUNIONクエリを使った形式に書き換えていたり、並列化して速度を向上したりと、N+1問題以外にも挑戦していました。
モブプロは他の人がどういった思考でコーディングをしているのかがわかるので非常に勉強になりますし、便利なキーバインドを知れるのもメリットの1つかと思います。今回はvscodeでの一括編集を使いこなせるようになったのが大きな収穫です笑
オンラインモブプロ会は定期的に開催していく予定なので、ぜひぜひどなたでもお気軽にご参加ください!お待ちしております!
明日20日目は、はじめちゃん(@HHajimeW)です!お楽しみに!