160
176

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-02-21

概要

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

以前読んだ良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方という書籍の中で
循環的複雑度について記述がありました。

どうやら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。血塗られた地獄 だそうです。

以下のメソッドでやっていることは大まかに

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

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

※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)

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

  1. ファイル形式のバリデーション
  2. ファイルメタデータのバリデーション
  3. バリデーション失敗時の処理
  4. S3への動画ファイルアップロードとprogressを取得
  5. アップロード完了時の処理
ソースコード
  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民は是非使ってみてください!

160
176
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
160
176

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?