保守・理解しやすいコードを書きたい！ 〜VSCode拡張機能で循環的複雑度と戦う〜

# 概要

みなさん、保守・理解しやすいコードを書きたいですよね。

以前読んだ[良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方](https://www.amazon.co.jp/%E8%89%AF%E3%81%84%E3%82%B3%E3%83%BC%E3%83%89-%E6%82%AA%E3%81%84%E3%82%B3%E3%83%BC%E3%83%89%E3%81%A7%E5%AD%A6%E3%81%B6%E8%A8%AD%E8%A8%88%E5%85%A5%E9%96%80-%E2%80%95%E4%BF%9D%E5%AE%88%E3%81%97%E3%82%84%E3%81%99%E3%81%84-%E6%88%90%E9%95%B7%E3%81%97%E7%B6%9A%E3%81%91%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E6%9B%B8%E3%81%8D%E6%96%B9-%E4%BB%99%E5%A1%B2/dp/4297127830/ref=sr_1_1?adgrpid=138533859594&dib=eyJ2IjoiMSJ9.Clgp--oQ4Cc5XLhcj_VXb9_ESPSKZhbmpFv2vMWyc6dErYnOhdgR3gMW2JHz_kOuSuvgMTEVoThcp0iLir-HYJtUSshOfCrRela4ra7GhOR6rWb2-QVZPHqWWc7YwlGD5kC-wwXZj88QdEoA4N-tsayuwN-xo3ArSVyZ0MXFCk8RgCaYPnJLiX4vg4tkFPhNmQUhO7aqmm2AGTKmD84t1R68UKz920SQ4dqUR8cZ_uiHGYtv2WeOhKe4iRHlYAlfRxkPpnb1R87ZBX2VVYCxizZZh2Vnfm47JryFJ4OJiT4.ec2tnuCXAoxz2Zh9TyrWX-HvhM7Z9uBQbafuV6EJGeQ&dib_tag=se&gclid=CjwKCAiArLyuBhA7EiwA-qo80HUYk3SxSiubI9HibpRFos2AQ8ntHhBqL4ooGo7kQ2vEDejMOJV11BoCo8IQAvD_BwE&hvadid=665591899821&hvdev=c&hvlocphy=9053340&hvnetw=g&hvqmt=e&hvrand=8459683280508643493&hvtargid=kwd-1657035072382&hydadcr=27493_14701051&jp-ad-ap=0&keywords=%E8%89%AF%E3%81%84%E3%82%B3%E3%83%BC%E3%83%89+%E6%82%AA%E3%81%84%E3%82%B3%E3%83%BC%E3%83%89%E3%81%A7%E5%AD%A6%E3%81%B6%E8%A8%AD%E8%A8%88%E5%85%A5%E9%96%80&qid=1708135023&sr=8-1)という書籍の中で
循環的複雑度について記述がありました。

どうやらVSCodeの拡張機能に循環的複雑度を測定できるものがあるようなので、
紹介していきたいと思います。

# この記事で言う「保守・理解しやすいコード」とは

この記事では関数・メソッドについて記述します。
一般的に理解しやすいコードとは以下のような例が挙げられると思います。

- 関数名から実行内容が予測できる
- ネストが浅い
- アーリーリターンしている
- 要約変数を適切に使用している
- <b>循環的複雑度が低い（←これなに？）</b>

今回はその中でも、<b>循環的複雑度が低いコード</b>を保守・理解しやすいコードとして紹介します。

# 循環的複雑度とは

プログラムの複雑度を測る指標です。ソースコードから、線形的に独立した経路の数を直接数えます。

ざっくりと、「判定の数+1」で求めることができます。

例えば、以下のサンプルコードのfunc()関数の循環的複雑度は
forが1つ、ifが1つ、else ifが1つなので、3+1で4となります。

```ts
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% |

> 参考: [循環的複雑度](https://jp.mathworks.com/discovery/cyclomatic-complexity.html)

ちなみに [githubで最もやべー関数を発掘する](https://qiita.com/AngryMane/items/2be9016f82653c8cc07f#2-%E6%9C%AC%E5%BD%93%E3%81%AB%E3%82%84%E3%81%B9%E3%83%BC%E9%96%A2%E6%95%B0%E3%81%AB%E7%B5%9E%E3%82%8A%E8%BE%BC%E3%82%80)という記事では、循環的複雑度が高い関数が紹介されています。
ものによってはリンク切れしてしまっていますが、最も複雑度が高いのはnode（JavaScript）のjo関数で<font color="red">**5505**</font>だそうです。想像もつかない...


# どのようにすれば循環的複雑度を低く抑えられるのか？

計算方法から考えると、forやifによる分岐を減らしていくことが必要となります。
そのために、分岐の入るロジックを別関数として切り出し、<b>１つの関数でやる事を絞り、分離する</b>ことを理想として目指していきます。

とはいえ、いちいち複雑度の計算なんてしていられないですね。
そこで役に立つのが次のVSCode拡張機能です。

# Code Metrics （VSCode拡張機能）

https://github.com/kisstkondoros/codemetrics?tab=readme-ov-file

この拡張機能は、TypeScriptやJavaScriptの関数・メソッドに循環的複雑度を表示してくれます。
Vuejsのscriptタグ内やReactのTSXファイルにも利用可能です。
以下がメッセージの例です。

- 何もかもがクールだ！
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/f0f5bf7e-7f46-217e-bea9-a83beb56fca9.png" />

- そろそろ何とかしなければ...
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/f14c61d8-6980-cd5d-1e4c-b4651bd9ae0e.png" />

- 冗談ですよね...?
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/640aff71-727b-f546-9ec7-4b0656f2c9ae.png" />


# 使用例

それでは既存のコードをリファクタし、循環的複雑度を下げてみましょう。
既存のメソッドの複雑度は27。<b><font color="red">***血塗られた地獄***</font></b> だそうです。
<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/837ec971-f9a2-9fcf-e8ec-18ecf9c5f418.png" />

以下のメソッドでやっていることは大まかに
1. ファイル形式のバリデーション
2. ファイルメタデータのバリデーション
3. バリデーション失敗時の処理
4. S3への動画ファイルアップロードとprogressを取得
5. アップロード完了時の処理

です。
また、メソッド内で関数定義をしているので、その辺りを分離するだけでもかなり複雑度を下げられそうですね。

※APIのエンドポイント等はダミーに差し替えています。

<details><summary>
<strike>地獄の扉</strike> 元々のコード</summary>

```ts
  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
            });
          }
        }
      );
    });
  }
```
</details>



## 修正後

複雑度が8まで下がりました！まだまだ修正できそうな箇所はありますが、
ひとまずメソッドを小さく分離し、それぞれが何をしているのかが見やすくなったと思います。

<img width="500px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/313dddfb-b3fa-2d7d-dac9-4d8f90f0b74b.png" />

### uploadAndGetAssetId（複雑度8）

こちらは本体のメソッドです。
修正前に比べて、下記の流れが読み取りやすくなっています。

1. ファイル形式のバリデーション
2. ファイルメタデータのバリデーション
3. バリデーション失敗時の処理
4. S3への動画ファイルアップロードとprogressを取得
5. アップロード完了時の処理

<details><summary>ソースコード</summary>

```ts
  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;
  }
```
</details>

### validateFiles（複雑度6）

次にこちらです。
FileListを受け取り、フロントエンドおよびAPIでバリデーションした後に、エラー（もしあれば）を付与したUploadFile[]を返します。

<details><summary>ソースコード</summary>

```ts
  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;
  }
```
</details>

### validateFilesByApi（判定・分岐なし）

こちらは上記のvalidateFilesから呼ばれている、バリデーションAPIを叩いてエラーを追加する処理です。

<details><summary>ソースコード</summary>

```ts
  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;
  }
```
</details>

### uploadFileByIndex（複雑度15）

こちらはS3にファイルをアップロードし、その間progressを取得、完了後にcompleteUploadを実行します。
引数をまとめて型定義しても良さそう。

<details><summary>ソースコード</summary>

```ts
  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);
        }
      }
    );
  }
```
</details>

### completeUpload（複雑度4）

最後は、uploadFileByIndexにてアップロード完了時に実行する関数です。
アップロード完了APIを叩きます。

<details><summary>ソースコード</summary>

```ts
  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;
    }
  }
```
</details>


# まとめ

保守・理解しやすいコードを書くための指標の1つとして、循環的複雑度を意識することで、
自然と関数・メソッドを適切な粒度で分離することができます。

拡張機能はとても便利なので、VSCode民は是非使ってみてください！