概要
みなさん、保守・理解しやすいコードを書きたいですよね。
以前読んだ良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方という書籍の中で
循環的複雑度について記述がありました。
どうやらVSCodeの拡張機能に循環的複雑度を測定できるものがあるようなので、
紹介していきたいと思います。
この記事で言う「保守・理解しやすいコード」とは
この記事では関数・メソッドについて記述します。
一般的に理解しやすいコードとは以下のような例が挙げられると思います。
- 関数名から実行内容が予測できる
- ネストが浅い
- アーリーリターンしている
- 要約変数を適切に使用している
- 循環的複雑度が低い(←これなに?)
今回はその中でも、循環的複雑度が低いコードを保守・理解しやすいコードとして紹介します。
循環的複雑度とは
プログラムの複雑度を測る指標です。ソースコードから、線形的に独立した経路の数を直接数えます。
ざっくりと、「判定の数+1」で求めることができます。
例えば、以下のサンプルコードのfunc()関数の循環的複雑度は
forが1つ、ifが1つ、else ifが1つなので、3+1で4となります。
const ary = [1,2,3];
const confition: "a" | "b" | "c" = "a";
const func = () => {
for (const num of ary) { // +1
if (confition === "a") { // +1
console.log("a");
} else if (confition === "b") { // +1
console.log("b");
} else {
console.log("c");
}
}
};
循環的複雑度の数値と、その評価は以下のようになります。
可能な限り10以下、多くても15くらいに抑えたいですね。
個人的に15を超えてくると、コードが追いにくくなる印象があります。
循環的複雑度 | 複雑さの状態 | バグ混入確率 |
---|---|---|
10以下 | 非常に良い構造 | 25% |
30以下 | 構造的なリスクあり | 40% |
50以下 | テスト不可能 | 70% |
75以下 | 以下なる変更も誤修正を生む | 98% |
参考: 循環的複雑度
ちなみに githubで最もやべー関数を発掘するという記事では、循環的複雑度が高い関数が紹介されています。
ものによってはリンク切れしてしまっていますが、最も複雑度が高いのはnode(JavaScript)のjo関数で5505だそうです。想像もつかない...
どのようにすれば循環的複雑度を低く抑えられるのか?
計算方法から考えると、forやifによる分岐を減らしていくことが必要となります。
そのために、分岐の入るロジックを別関数として切り出し、1つの関数でやる事を絞り、分離することを理想として目指していきます。
とはいえ、いちいち複雑度の計算なんてしていられないですね。
そこで役に立つのが次のVSCode拡張機能です。
Code Metrics (VSCode拡張機能)
この拡張機能は、TypeScriptやJavaScriptの関数・メソッドに循環的複雑度を表示してくれます。
Vuejsのscriptタグ内やReactのTSXファイルにも利用可能です。
以下がメッセージの例です。
- 何もかもがクールだ!
- そろそろ何とかしなければ...
- 冗談ですよね...?
使用例
それでは既存のコードをリファクタし、循環的複雑度を下げてみましょう。
既存のメソッドの複雑度は27。血塗られた地獄 だそうです。
以下のメソッドでやっていることは大まかに
- ファイル形式のバリデーション
- ファイルメタデータのバリデーション
- バリデーション失敗時の処理
- S3への動画ファイルアップロードとprogressを取得
- アップロード完了時の処理
です。
また、メソッド内で関数定義をしているので、その辺りを分離するだけでもかなり複雑度を下げられそうですね。
※APIのエンドポイント等はダミーに差し替えています。
地獄の扉 元々のコード
async uploadAndGetAssetId(input: {
fileList: FileList;
videoAssetTypeId: VideoAssetTypeID;
onValidation: FilesCallback;
onProgress: OnProgress;
onError: OnError;
}): Promise<{
cancelUpload: CancelUpload;
videoAssetId?: number;
}> {
const files: UploadFile[] = [];
const processIdMap: Map<number, string> = new Map();
const uploader = new WanoS3Client({
workerJSPath: "/hogehoge/s3-webworker.js",
maxParallelFileNum: 3,
enableMD5: true,
chunkSize: 5,
maxRetryCount: 3,
retryIntervalSec: 5
});
return new Promise<{
cancelUpload: CancelUpload;
videoAssetId?: number;
}>((resolve, reject) => {
const responce: {
cancelUpload: CancelUpload;
videoAssetId?: number;
} = {
cancelUpload: (i: number) => {
const pid = processIdMap.get(i);
if (pid) uploader.cancel(pid);
},
videoAssetId: undefined
};
const uploadByFileIndex = async (i: number) => {
try {
const f = files[i].file;
const r: AxiosResponse<{
video_asset_id: number;
bucket: string;
key: string;
upload_presign_url: string;
}> = await axios.post("/api/hogehoge/video_assets/new", {
file_name: f.name,
video_asset_type_id: input.videoAssetTypeId
});
const s3URI = r.data;
responce.videoAssetId = s3URI.video_asset_id;
if (f) {
const pid = uploader.upload({
stsUrl: `/api/hogehoge/video_assets/${r.data.video_asset_id}/sts`,
stsApiHeaders: { "Content-Type": "application/json" },
file: f,
videoAssetId: s3URI.video_asset_id,
s3Bucket: s3URI.bucket,
s3Key: s3URI.key,
onProgress: (state: ProgressCallback) => {
input.onProgress(i, state);
},
onComplete: async () => {
try {
await axios.post(
`/api/hogehoge/video_assets/upload_completed`,
{
video_asset_id: r.data.video_asset_id
}
);
} catch (err) {
console.error(err);
reject(err);
}
input.onProgress(i, {
progress: 1,
remainingSec: 0,
file: files[i].file
});
responce.videoAssetId = s3URI.video_asset_id;
resolve(responce);
},
onError: (error: any) => {
reject(error);
},
onAddWaitQueue: () => {
return null;
}
});
processIdMap.set(i, pid);
}
} catch (err) {
console.error(err);
reject(err);
}
};
for (let i = 0; i < input.fileList.length; i++) {
const file = input.fileList.item(i);
if (file) {
files.push({
file: file,
progress: 0,
remainingSec: 0,
errors: []
});
if (file?.type != "video/mp4" && file.type != "video/quicktime") {
files[i].errors.push(
"サポートされていないファイル形式です。mp4(h264)、mov(ProRes)のみご利用頂けます。"
);
}
}
}
this.validateFileMetadata(input.fileList, input.videoAssetTypeId).then(
res => {
const uploadFilesWithErrors = this.addValidateErrorsToFile(
files,
res
);
try {
input.onValidation(uploadFilesWithErrors);
} catch (err) {
console.error(err);
input.onError(err);
}
const uploadCounter = this.uploadFilesAndGetTimes(
uploadFilesWithErrors,
uploadByFileIndex
);
if (uploadCounter == 0) {
resolve({
cancelUpload: (i: number) => {
const pid = processIdMap.get(i);
if (pid) uploader.cancel(pid);
},
videoAssetId: undefined
});
}
}
);
});
}
修正後
複雑度が8まで下がりました!まだまだ修正できそうな箇所はありますが、
ひとまずメソッドを小さく分離し、それぞれが何をしているのかが見やすくなったと思います。
uploadAndGetAssetId(複雑度8)
こちらは本体のメソッドです。
修正前に比べて、下記の流れが読み取りやすくなっています。
- ファイル形式のバリデーション
- ファイルメタデータのバリデーション
- バリデーション失敗時の処理
- S3への動画ファイルアップロードとprogressを取得
- アップロード完了時の処理
ソースコード
async uploadAndGetAssetId(input: {
fileList: FileList;
videoAssetTypeId: VideoAssetTypeID;
onValidation: FilesCallback;
onProgress: OnProgress;
onError: OnError;
}): Promise<{
cancelUpload: CancelUpload;
videoAssetId?: number | undefined;
}> {
const processIdMap: Map<number, string> = new Map();
const uploader = new WanoS3Client({
workerJSPath: "/hogehoge/s3-webworker.js",
maxParallelFileNum: 3,
enableMD5: true,
chunkSize: 5,
maxRetryCount: 3,
retryIntervalSec: 5
});
let result: {
cancelUpload: CancelUpload;
videoAssetId?: number;
} = {
cancelUpload: (i: number) => {
const pid = processIdMap.get(i);
if (pid) uploader.cancel(pid);
},
videoAssetId: undefined
};
const uploadFilesAddedAllValidateErrors = await this.validateFiles(
input.fileList
);
try {
input.onValidation(uploadFilesAddedAllValidateErrors);
} catch (err) {
console.error(err);
input.onError(err);
}
for (let i = 0; i < input.fileList.length; i++) {
{
const resp = await this.uploadFileByIndex(
i,
uploadFilesAddedAllValidateErrors,
input.videoAssetTypeId,
uploader,
processIdMap,
input.onProgress,
input.onError
);
if (i === 0) result = resp;
}
}
return result;
}
validateFiles(複雑度6)
次にこちらです。
FileListを受け取り、フロントエンドおよびAPIでバリデーションした後に、エラー(もしあれば)を付与したUploadFile[]を返します。
ソースコード
private async validateFiles(files: FileList): Promise<UploadFile[]> {
const uploadFilesAddedFrontendErrors: UploadFile[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (file) {
uploadFilesAddedFrontendErrors.push({
file: file,
progress: 0,
remainingSec: 0,
errors: []
});
if (file?.type !== "video/mp4" && file.type !== "video/quicktime") {
uploadFilesAddedFrontendErrors[i].errors.push(
"サポートされていないファイル形式です。mp4(h264)、mov(ProRes)のみご利用頂けます。"
);
}
}
}
// APIによるバリデーション
const uploadFilesAddedAllValidateErrors = await this.validateFilesByApi(
files,
uploadFilesAddedFrontendErrors
);
return uploadFilesAddedAllValidateErrors;
}
validateFilesByApi(判定・分岐なし)
こちらは上記のvalidateFilesから呼ばれている、バリデーションAPIを叩いてエラーを追加する処理です。
ソースコード
async validateFilesByApi(
files: FileList,
uploadFiles: UploadFile[]
): Promise<UploadFile[]> {
const res = await this.validateFileMetadata(
files,
VideoAssetTypeID.karaoke_original
);
const uploadFilesAddedApiValidateErrors = this.addAValidateErrorsToFile(
uploadFiles,
res
);
return uploadFilesAddedApiValidateErrors;
}
uploadFileByIndex(複雑度15)
こちらはS3にファイルをアップロードし、その間progressを取得、完了後にcompleteUploadを実行します。
引数をまとめて型定義しても良さそう。
ソースコード
private async uploadFileByIndex(
index: number,
files: UploadFile[],
videoAssetTypeId: number,
uploader: WanoS3Client,
processIdMap: Map<number, string>,
onProgress: OnProgress,
onError: OnError
): Promise<{ cancelUpload: CancelUpload; videoAssetId?: number }> {
const response: {
cancelUpload: CancelUpload;
videoAssetId?: number;
} = {
cancelUpload: (i: number) => {
const pid = processIdMap.get(i);
if (pid) uploader.cancel(pid);
},
videoAssetId: undefined
};
return new Promise<{ cancelUpload: CancelUpload; videoAssetId?: number }>(
async (resolve, reject) => {
try {
const f = files[index].file;
const r: AxiosResponse<{
video_asset_id: number;
bucket: string;
key: string;
upload_presign_url: string;
}> = await axios.post("/api/hogehoge/video_assets/new", {
file_name: f.name,
video_asset_type_id: videoAssetTypeId
});
const s3URI = r.data;
response.videoAssetId = s3URI.video_asset_id;
if (f) {
const pid = uploader.upload({
stsUrl: `/api/hogehoge/video_assets/${r.data.video_asset_id}/sts`,
stsApiHeaders: { "Content-Type": "application/json" },
file: f,
videoAssetId: s3URI.video_asset_id,
s3Bucket: s3URI.bucket,
s3Key: s3URI.key,
onProgress: (state: ProgressCallback) => {
onProgress(index, state);
},
onComplete: async () => {
await this.completeUpload(r.data.video_asset_id);
onProgress(index, {
progress: 1,
remainingSec: 0,
file: files[index].file
});
response.videoAssetId = s3URI.video_asset_id;
resolve(response);
},
onError: (error: any) => {
reject(error);
},
onAddWaitQueue: () => {
return null;
}
});
processIdMap.set(index, pid);
}
return response;
} catch (error) {
onError(error);
reject(error);
}
}
);
}
completeUpload(複雑度4)
最後は、uploadFileByIndexにてアップロード完了時に実行する関数です。
アップロード完了APIを叩きます。
ソースコード
private async completeUpload(videoAssetId: number): Promise<void> {
try {
await axios.post(`/api/hogehoge/video_assets/upload_completed`, {
video_asset_id: videoAssetId
});
} catch (err) {
console.error(err);
throw err;
}
}
まとめ
保守・理解しやすいコードを書くための指標の1つとして、循環的複雑度を意識することで、
自然と関数・メソッドを適切な粒度で分離することができます。
拡張機能はとても便利なので、VSCode民は是非使ってみてください!