3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

生成AI×スナップショットテストで簡単かつ安全にリファクタリング!

Posted at

はじめに

2024年5月22日に、JAWS-UG CDK支部 #14 で以下を発表しました。

発表の終盤で、スナップショットテストと生成AIを組み合わせたリファクタリングを簡単に紹介したところ、想像以上に反響がありました。

本件について具体的に示した方がよいという意見をいただきました。そこで、本記事では生成 AI で簡単、かつスナップショットテストで安全にリファクタリングする手順をじっくり解説します。また、生成 AI については GPT-4o API と Github Copilot Chat の2パターンで、リファクタリングの例をご紹介します1

前提

想定読者

  • 生成 AI によるコードのリファクタリングに興味がある人
  • リファクタリングにおけるスナップショットテストの効果を知りたい人
  • IaC(Infrastructure as Code)においても拡張性・保守性の高いコードを書きたい人

リファクタリングとは

リファクタリングは、プログラムの動作を変えずに、コードの内部構造や設計を改善するプロセスです。

これにより、コードの可読性が向上し、保守が容易になります。プログラムが成長するにつれて、リファクタリングは開発効率とコード品質の向上に役立ちます。

スナップショットテストとは

スナップショットテストは、プログラミングにおいて変更前後の状態を比較し、意図しない変更がないことを確認するテスト手法です。このテストにより、コードの変更が予期せぬ状態になっていないかを検証できます。例えば、Jest などのツールを使用して、変更前後のスナップショットを比較することで、変更が正しく行われたかをテストします。

プログラムの変更前後でスナップショットテストに差分がないことを確認できれば、問題なくリファクタリングできていると言えます。

実行環境

JAWS-UG CDK 支部での発表と同じく、本記事では CDK8s (TypeScript) でサンプルコードをご紹介します。プロジェクト生成ツールの Projen を利用しています。ご参考までに、プロジェクト構成が定義された.projenrc.tsのサンプルを以下に示します2

.projenrc.ts
import { cdk8s } from 'projen';
import { UpdateSnapshot } from 'projen/lib/javascript/jest';
const project = new cdk8s.Cdk8sTypeScriptApp({
  cdk8sVersion: '2.3.33',
  cdk8sCliVersion: '2.198.117',
  typescriptVersion: '5.3.3',
  defaultReleaseBranch: 'main',
  name: 'cdk8s',
  projenrcTs: true,
  prettier: true,
  prettierOptions: {
    settings: {
      printWidth: 100,
      singleQuote: true,
    },
  },
  jestOptions: { updateSnapshot: UpdateSnapshot.NEVER },

  deps: ['@opencdk8s/cdk8s-argoworkflow'],
});
project.synth();

Projen のデフォルト設定では、$ npx projen test実行時にスナップショットテストで差分があったとしても、スナップショットが自動更新されてしまいます。スナップショットが自動更新されてしまうと、変更内容を把握できないため、注意が必要です。

そこで .projenrc.ts の設定で jestOptions: { updateSnapshot: UpdateSnapshot.NEVER } をオススメします。本設定により、テスト実行時にスナップショットが自動更新されなくなります。スナップショットテストの差分に問題がない場合は、$ npx projen test -u でスナップショットを更新できます。

リファクタリングで使用する生成 AI

今回、リファクタリングで使用する生成 AI は以下の 2 つです。

  1. GPT-4o API
  2. GitHub Copilot Chat v0.18.1

GPT-4o は、OpenAI が提供する自然言語処理モデルです。GitHub Copilot Chat は、GitHub Copilot のチャット機能で、AI にコード生成をリクエストできる機能です。

GitHub Copilot Chat 0.17 以降のバージョンでは、GitHub Copilot Chat でも GPT-4o がバックエンドで使用されるようになりました。詳細は以下のリンクを参照してください。

GitHub Copilot Chat and pull request summaries are now powered by GPT-4o - The GitHub Blog

以前の GPT-4 Turbo ベースだった「Github Copilot Chat」では、正直なところ GPT-4o API の方がリファクタリングしやすいと感じていました。Github Copilot Chat が GPT-4o ベースとなったことでかなり使いやすくなりましたので、記事の後半でご紹介します。

リファクタリング対象

リファクタリング対象のコードは以下の通りです3。宣言的に記述されているものの、やや冗長な表現になっています。

src/charts/extract.ts
import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

export class ExtractChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = {}) {
    super(scope, id, props);

    // define resources here
    new wf.ArgoWorkflowWorkflowTemplate(this, `execute-extract`, {
      metadata: {
        name: 'execute-extract',
        namespace: 'argo',
        labels: { type: 'extract' },
      },
      spec: {
        entrypoint: `execute-extract`,
        templates: [
          {
            name: `execute-extract`,
            retryStrategy: {
              limit: '5',
              retryPolicy: 'OnFailure',
              backoff: {
                duration: '10m',
                factor: 2.0,
                maxDuration: '24h',
              },
            },
            inputs: {
              parameters: [{ name: 'indexName' }, { name: 'secret' }],
            },
            container: {
              name: 'main',
              image: `xxx/extract:latest`,
              command: ['python'],
              args: ['-m', 'files_crawler', 'extract'],
              resources: {
                requests: { cpu: '500m', memory: '512Mi' },
                limits: { cpu: '1', memory: '2Gi' },
              },
              envFrom: [{ secretRef: { name: '{{inputs.parameters.secret}}' } }],
              env: [
                { name: 'ES_URL', value: 'http://xxx' },
                { name: 'ES_INDEX', value: '{{inputs.parameters.indexName}}' },
              ],
            },
          },
        ],
      },
    });

    new wf.ArgoWorkflowCronWorkflow(this, `cron-extract`, {
      metadata: {
        name: 'cron-extract',
        namespace: 'argo',
        labels: { type: 'extract' },
      },
      spec: {
        schedule: '20 0 * * SAT',
        timezone: 'Asia/Tokyo',
        workflowSpec: {
          entrypoint: 'extract',
          volumeClaimGC: { strategy: 'OnWorkflowCompletion' },
          ttlStrategy: {
            secondsAfterCompletion: 2592000,
            secondsAfterFailure: 2592000,
            secondsAfterSuccess: 2592000,
          },
          templates: [
            {
              name: 'extract',
              parallelism: 2,
              steps: [
                [
                  {
                    name: 'aa',
                    arguments: {
                      parameters: [
                        {
                          name: 'indexName',
                          value: 'aa',
                        },
                        {
                          name: 'secret',
                          value: 'secret-aa',
                        },
                      ],
                    },
                    templateRef: {
                      name: 'execute-extract',
                      template: 'execute-extract',
                    },
                  },
                  {
                    name: 'bb',
                    arguments: {
                      parameters: [
                        {
                          name: 'indexName',
                          value: 'bb',
                        },
                        {
                          name: 'secret',
                          value: 'secret-bb',
                        },
                      ],
                    },
                    templateRef: {
                      name: 'execute-extract',
                      template: 'execute-extract',
                    },
                  },
                  {
                    name: 'cc',
                    arguments: {
                      parameters: [
                        {
                          name: 'indexName',
                          value: 'cc',
                        },
                        {
                          name: 'secret',
                          value: 'secret-cc',
                        },
                      ],
                    },
                    templateRef: {
                      name: 'execute-extract',
                      template: 'execute-extract',
                    },
                  },
                  {
                    name: 'dd',
                    arguments: {
                      parameters: [
                        {
                          name: 'indexName',
                          value: 'dd',
                        },
                        {
                          name: 'secret',
                          value: 'secret-dd',
                        },
                      ],
                    },
                    templateRef: {
                      name: 'execute-extract',
                      template: 'execute-extract',
                    },
                  },
                ],
              ],
            },
          ],
        },
      },
    });
  }
}

正直なところ、この状態なら CDK8s ではなく、YAMLファイル(K8s マニフェスト)の方が読みやすいと思います。
特に steps 配下が冗長な表現になっているため、生成 AI にはこの辺りを直してほしいところですね・・・!

リファクタリングのフロー

大きく分けて、以下3つのフローでリファクタリングを進めます。

  1. スナップショットテストの実行(変更前スナップショットの取得)
  2. 生成 AI を活用したリファクタリング
  3. スナップショットテストの再実行

以下、各フローの手順を詳しく説明します。

1. スナップショットテストの実行(変更前スナップショットの取得)

まずは、リファクタリングで変更する前のスナップショットを取得します。以下はスナップショットテストのコード例です。

test/workflow.test.ts
import { Testing } from 'cdk8s';
import { ExtractChart } from '../src/charts/extract';

describe('Extract Workflow', () => {
  test('Snapshot Test', () => {
    const app = Testing.app();
    const chart = new ExtractChart(app, 'ExtractTest');
    const results = Testing.synth(chart);
    expect(results).toMatchSnapshot();
  });
});

上記のコードを使用して、npx projen test -uでスナップショットテストを実行します。
(初回でスナップショットがない場合、必ず失敗するため、-uオプションを指定しています。)

$ npx projen test -u
👾 test | jest --passWithNoTests --ci -u
 PASS  test/workflow.test.ts
  Extract Workflow
    ✓ Snapshot Test (17 ms)

 › 1 snapshot written.
------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |      100 |     100 |     100 |                   
 extract.ts |     100 |      100 |     100 |     100 |                   
------------|---------|----------|---------|---------|-------------------
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        3.339 s, estimated 4 s
Ran all test suites.

テスト後、test/__snapshots__配下に現断面のスナップショット(K8s マニフェスト)が出力されます。

snapshot_dir

スナップショットの出力例
test/__snapshots__/workflow.test.ts.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Extract Workflow Snapshot Test 1`] = `
[
  {
    "apiVersion": "argoproj.io/v1alpha1",
    "kind": "WorkflowTemplate",
    "metadata": {
      "labels": {
        "type": "extract",
      },
      "name": "execute-extract",
      "namespace": "argo",
    },
    "spec": {
      "entrypoint": "execute-extract",
      "templates": [
        {
          "container": {
            "args": [
              "-m",
              "files_crawler",
              "extract",
            ],
            "command": [
              "python",
            ],
            "env": [
              {
                "name": "ES_URL",
                "value": "http://xxx",
              },
              {
                "name": "ES_INDEX",
                "value": "{{inputs.parameters.indexName}}",
              },
            ],
            "envFrom": [
              {
                "secretRef": {
                  "name": "{{inputs.parameters.secret}}",
                },
              },
            ],
            "image": "xxx/extract:latest",
            "name": "main",
            "resources": {
              "limits": {
                "cpu": "1",
                "memory": "2Gi",
              },
              "requests": {
                "cpu": "500m",
                "memory": "512Mi",
              },
            },
          },
          "inputs": {
            "parameters": [
              {
                "name": "indexName",
              },
              {
                "name": "secret",
              },
            ],
          },
          "name": "execute-extract",
          "retryStrategy": {
            "backoff": {
              "duration": "10m",
              "factor": 2,
              "maxDuration": "24h",
            },
            "limit": "5",
            "retryPolicy": "OnFailure",
          },
        },
      ],
    },
  },
  {
    "apiVersion": "argoproj.io/v1alpha1",
    "kind": "CronWorkflow",
    "metadata": {
      "labels": {
        "type": "extract",
      },
      "name": "cron-extract",
      "namespace": "argo",
    },
    "spec": {
      "schedule": "20 0 * * SAT",
      "timezone": "Asia/Tokyo",
      "workflowSpec": {
        "entrypoint": "extract",
        "templates": [
          {
            "name": "extract",
            "parallelism": 2,
            "steps": [
              [
                {
                  "arguments": {
                    "parameters": [
                      {
                        "name": "indexName",
                        "value": "aa",
                      },
                      {
                        "name": "secret",
                        "value": "secret-aa",
                      },
                    ],
                  },
                  "name": "aa",
                  "templateRef": {
                    "name": "execute-extract",
                    "template": "execute-extract",
                  },
                },
                {
                  "arguments": {
                    "parameters": [
                      {
                        "name": "indexName",
                        "value": "bb",
                      },
                      {
                        "name": "secret",
                        "value": "secret-bb",
                      },
                    ],
                  },
                  "name": "bb",
                  "templateRef": {
                    "name": "execute-extract",
                    "template": "execute-extract",
                  },
                },
                {
                  "arguments": {
                    "parameters": [
                      {
                        "name": "indexName",
                        "value": "cc",
                      },
                      {
                        "name": "secret",
                        "value": "secret-cc",
                      },
                    ],
                  },
                  "name": "cc",
                  "templateRef": {
                    "name": "execute-extract",
                    "template": "execute-extract",
                  },
                },
                {
                  "arguments": {
                    "parameters": [
                      {
                        "name": "indexName",
                        "value": "dd",
                      },
                      {
                        "name": "secret",
                        "value": "secret-dd",
                      },
                    ],
                  },
                  "name": "dd",
                  "templateRef": {
                    "name": "execute-extract",
                    "template": "execute-extract",
                  },
                },
              ],
            ],
          },
        ],
        "ttlStrategy": {
          "secondsAfterCompletion": 2592000,
          "secondsAfterFailure": 2592000,
          "secondsAfterSuccess": 2592000,
        },
        "volumeClaimGC": {
          "strategy": "OnWorkflowCompletion",
        },
      },
    },
  },
]
`;

2. 生成 AI を活用したリファクタリング

GPT-4o API を使用して、前述のコードをリファクタリングします。以下のシンプルなプロンプトを送信します4

プロンプト

以下のコードを可読性と保守性に考慮してリファクタリングしてください。

import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

export class ExtractChart extends Chart {
  // リファクタリング対象のコードを記述※
}

リファクタリング対象のコードと同じなので省略

GPT-4o の回答

コードの可読性と保守性を向上させるため、以下のポイントに注意してリファクタリングを行います:

  1. 重複コードを関数に分けて再利用可能にする。
  2. 言語機能やライブラリを活用してコードを整理する。

以下にリファクタリング後のコードを示します:

import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

export class ExtractChart extends Chart {
  private static readonly INDEX_NAMES = [
    'aa', 'bb', 'cc', 'dd'
  ];

  constructor(scope: Construct, id: string, props: ChartProps = {}) {
    super(scope, id, props);

    this.createExecuteExtractTemplate();
    this.createCronExtractWorkflow();
  }

  private createExecuteExtractTemplate() {
    new wf.ArgoWorkflowWorkflowTemplate(this, 'execute-extract', {
      metadata: {
        name: 'execute-extract',
        namespace: 'argo',
        labels: { type: 'extract' },
      },
      spec: {
        entrypoint: 'execute-extract',
        templates: [
          {
            name: 'execute-extract',
            retryStrategy: {
              limit: '5',
              retryPolicy: 'OnFailure',
              backoff: {
                duration: '10m',
                factor: 2.0,
                maxDuration: '24h',
              },
            },
            inputs: {
              parameters: [
                { name: 'indexName' },
                { name: 'secret' }
              ],
            },
            container: {
              name: 'main',
              image: 'xxx/extract:latest',
              command: ['python'],
              args: ['-m', 'files_crawler', 'extract'],
              resources: {
                requests: { cpu: '500m', memory: '512Mi' },
                limits: { cpu: '1', memory: '2Gi' },
              },
              envFrom: [
                { secretRef: { name: '{{inputs.parameters.secret}}' } }
              ],
              env: [
                { name: 'ES_URL', value: 'http://xxx' },
                { name: 'ES_INDEX', value: '{{inputs.parameters.indexName}}' },
              ],
            },
          },
        ],
      },
    });
  }

  private createCronExtractWorkflow() {
    new wf.ArgoWorkflowCronWorkflow(this, 'cron-extract', {
      metadata: {
        name: 'cron-extract',
        namespace: 'argo',
        labels: { type: 'extract' },
      },
      spec: {
        schedule: '20 0 * * SAT',
        timezone: 'Asia/Tokyo',
        workflowSpec: {
          entrypoint: 'extract',
          volumeClaimGC: { strategy: 'OnWorkflowCompletion' },
          ttlStrategy: {
            secondsAfterCompletion: 2592000,
            secondsAfterFailure: 2592000,
            secondsAfterSuccess: 2592000,
          },
          templates: [
            {
              name: 'extract',
              parallelism: 2,
              steps: [this.createExtractionSteps()],
            },
          ],
        },
      },
    });
  }

  private createExtractionSteps() {
    return ExtractChart.INDEX_NAMES.map(indexName => ({
      name: indexName,
      arguments: {
        parameters: [
          { name: 'indexName', value: indexName },
          { name: 'secret', value: `secret-${indexName}` },
        ],
      },
      templateRef: {
        name: 'execute-extract',
        template: 'execute-extract',
      },
    }));
  }
}

変更点の説明

  1. Constants(const)でINDEX_NAMESを定義:複数の場所で使われているインデックス名を定数として定義し、再利用できるようにしました。これによって、インデックス名の管理が容易になります。
  2. 関数の抽出:
    • createExecuteExtractTemplate:ArgoWorkflowWorkflowTemplateの定義を行う関数。
    • createCronExtractWorkflow:ArgoWorkflowCronWorkflowの定義を行う関数。
    • createExtractionSteps:重複していたsteps配列内のオブジェクト生成部分を関数化。
      これにより、コードの再利用性と可読性が向上し、将来的なメンテナンスも容易になります。関数名もわかりやすくし、コメントを追加することなくコードの意図が理解しやすくなっています。

説明付きで分かりやすい提案ですね!INDEX_NAMESの定義やcreateExtractionStepsの関数化は、コードの可読性と保守性を向上させるために重要なポイントだと思います。追加で気になる点があれば、再度リクエストを送ることもできますが、今回はこのままリファクタリングを進めます。

3. スナップショットテストの再実行

コピー&ペーストで GPT-4o の提案を丸ごとsrc/charts/extract.tsに反映し、スナップショットテストを実行します。

スナップショットテスト実行結果
$ npx projen test
👾 test | jest --passWithNoTests --ci
 PASS  test/workflow.test.ts
  Extract Workflow
    ✓ Snapshot Test (8 ms)

------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |      100 |     100 |     100 |                   
 extract.ts |     100 |      100 |     100 |     100 |                   
------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        3.149 s, estimated 4 s
Ran all test suites.

スナップショットテストにエラーがなく、コードの変更前後で差分が発生していません。つまり、問題なく安全にリファクタリングできていると言えます。とても簡単ですね!もし、実行結果に差分がある場合は、再度リファクタリングを検討してください。

今回の例はシンプルなリファクタリングでしたが、GPT-4o からより抽象化された提案を得られることもあります。例えば、API の乱数設定により、同じプロンプトでも定数でコードを整理するような提案がありました。

GPT-4o による別の提案例

以下に、可読性と保守性を向上させるためにリファクタリングしたTypeScriptコードを示します。主な改善点は、再利用可能な部分を関数に分割し、定数を使用してコードを整理したことです。

import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

const NAMESPACE = 'argo';
const LABELS = { type: 'extract' };
const RETRY_STRATEGY = {
  limit: '5',
  retryPolicy: 'OnFailure',
  backoff: {
    duration: '10m',
    factor: 2.0,
    maxDuration: '24h',
  },
};
const CONTAINER_SPEC = {
  name: 'main',
  image: 'xxx/extract:latest',
  command: ['python'],
  args: ['-m', 'files_crawler', 'extract'],
  resources: {
    requests: { cpu: '500m', memory: '512Mi' },
    limits: { cpu: '1', memory: '2Gi' },
  },
  env: [
    { name: 'ES_URL', value: 'http://xxx' },
    { name: 'ES_INDEX', value: '{{inputs.parameters.indexName}}' },
  ],
};
const TTL_STRATEGY = {
  secondsAfterCompletion: 2592000,
  secondsAfterFailure: 2592000,
  secondsAfterSuccess: 2592000,
};

function createWorkflowTemplate(scope: Construct) {
  return new wf.ArgoWorkflowWorkflowTemplate(scope, 'execute-extract', {
    metadata: {
      name: 'execute-extract',
      namespace: NAMESPACE,
      labels: LABELS,
    },
    spec: {
      entrypoint: 'execute-extract',
      templates: [
        {
          name: 'execute-extract',
          retryStrategy: RETRY_STRATEGY,
          inputs: {
            parameters: [{ name: 'indexName' }, { name: 'secret' }],
          },
          container: {
            ...CONTAINER_SPEC,
            envFrom: [{ secretRef: { name: '{{inputs.parameters.secret}}' } }],
          },
        },
      ],
    },
  });
}

function createStep(name: string, indexName: string, secretName: string) {
  return {
    name,
    arguments: {
      parameters: [
        { name: 'indexName', value: indexName },
        { name: 'secret', value: secretName },
      ],
    },
    templateRef: {
      name: 'execute-extract',
      template: 'execute-extract',
    },
  };
}

function createCronWorkflow(scope: Construct) {
  return new wf.ArgoWorkflowCronWorkflow(scope, 'cron-extract', {
    metadata: {
      name: 'cron-extract',
      namespace: NAMESPACE,
      labels: LABELS,
    },
    spec: {
      schedule: '20 0 * * SAT',
      timezone: 'Asia/Tokyo',
      workflowSpec: {
        entrypoint: 'extract',
        volumeClaimGC: { strategy: 'OnWorkflowCompletion' },
        ttlStrategy: TTL_STRATEGY,
        templates: [
          {
            name: 'extract',
            parallelism: 2,
            steps: [
              [
                createStep('aa', 'aa', 'secret-aa'),
                createStep('bb', 'bb', 'secret-bb'),
                createStep('cc', 'cc', 'secret-cc'),
                createStep('dd', 'dd', 'secret-dd'),
              ],
            ],
          },
        ],
      },
    },
  });
}

export class ExtractChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = {}) {
    super(scope, id, props);
    createWorkflowTemplate(this);
    createCronWorkflow(this);
  }
}

このリファクタリングにより、コードの冗長性が削減され、保守性が向上しています。各テンプレートやパラメータ設定は関数として分けられ、必要に応じて再利用できるようになっています。


最終的には、コードの複雑さやリファクタリングの目的によって、適切な提案を選択することが重要です。

コーディング支援 AI「Github Copilot Chat」でリファクタリング

ここでは、コーディング支援 AI「GitHub Copilot Chat」を使用してリファクタリングします。前述の 2. 生成 AI を活用したリファクタリング以降が置き換わるイメージです。

Visual Studio Code で対象のコードを選択し、サイドバーのチャットからスラッシュコマンド /fix を使って、リファクタリングを指示します。以下の通り、Github Copilot Chat へのプロンプトは GPT-4o API に送信した内容と同じにしています5

プロンプト (/fix 使用)

@workspace /fix ExtractChartのクラスを可読性と保守性に配慮してリファクタリングしてください。

Github Copilot Chat の回答

リファクタリングの計画

  1. 定数の使用 : ハードコードされた値を定数に置き換えます。
  2. メソッドの分割 : リソースの定義を複数のメソッドに分割して、コードの可読性を向上させます。
  3. メソッド名の明確化 : メソッドの目的がわかりやすい名前に変更します。
  4. 共通コードの抽出 : 重複するコードを共通のメソッドに抽出します。
  5. インデントとフォーマットの統一 : コードの読みやすさを向上させるために、インデントとフォーマットを整えます。

リファクタリング後のコード

import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

const NAMESPACE = 'argo';
const LABELS = { type: 'extract' };
const IMAGE = 'xxx/extract:latest';
const SCHEDULE = '20 0 * * SAT';
const TIMEZONE = 'Asia/Tokyo';
const ES_URL = 'http://xxx';
const ENTRYPOINT = 'execute-extract';
const RETRY_LIMIT = '5';
const BACKOFF_DURATION = '10m';
const BACKOFF_FACTOR = 2.0;
const BACKOFF_MAX_DURATION = '24h';
const CPU_REQUEST = '500m';
const MEMORY_REQUEST = '512Mi';
const CPU_LIMIT = '1';
const MEMORY_LIMIT = '2Gi';
const TTL_SECONDS = 2592000;

export class ExtractChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = {}) {
    super(scope, id, props);
    this.createWorkflowTemplate();
    this.createCronWorkflow();
  }

  private createWorkflowTemplate() {
    new wf.ArgoWorkflowWorkflowTemplate(this, ENTRYPOINT, {
      metadata: this.createMetadata(ENTRYPOINT),
      spec: this.createWorkflowSpec(),
    });
  }

  private createCronWorkflow() {
    new wf.ArgoWorkflowCronWorkflow(this, 'cron-extract', {
      metadata: this.createMetadata('cron-extract'),
      spec: {
        schedule: SCHEDULE,
        timezone: TIMEZONE,
        workflowSpec: {
          entrypoint: 'extract',
          volumeClaimGC: { strategy: 'OnWorkflowCompletion' },
          ttlStrategy: this.createTtlStrategy(),
          templates: [this.createCronTemplate()],
        },
      },
    });
  }

  private createMetadata(name: string) {
    return {
      name,
      namespace: NAMESPACE,
      labels: LABELS,
    };
  }

  private createWorkflowSpec() {
    return {
      entrypoint: ENTRYPOINT,
      templates: [
        {
          name: ENTRYPOINT,
          retryStrategy: this.createRetryStrategy(),
          inputs: {
            parameters: [{ name: 'indexName' }, { name: 'secret' }],
          },
          container: this.createContainer(),
        },
      ],
    };
  }

  private createRetryStrategy() {
    return {
      limit: RETRY_LIMIT,
      retryPolicy: 'OnFailure',
      backoff: {
        duration: BACKOFF_DURATION,
        factor: BACKOFF_FACTOR,
        maxDuration: BACKOFF_MAX_DURATION,
      },
    };
  }

  private createContainer() {
    return {
      name: 'main',
      image: IMAGE,
      command: ['python'],
      args: ['-m', 'files_crawler', 'extract'],
      resources: {
        requests: { cpu: CPU_REQUEST, memory: MEMORY_REQUEST },
        limits: { cpu: CPU_LIMIT, memory: MEMORY_LIMIT },
      },
      envFrom: [{ secretRef: { name: '{{inputs.parameters.secret}}' } }],
      env: [
        { name: 'ES_URL', value: ES_URL },
        { name: 'ES_INDEX', value: '{{inputs.parameters.indexName}}' },
      ],
    };
  }

  private createTtlStrategy() {
    return {
      secondsAfterCompletion: TTL_SECONDS,
      secondsAfterFailure: TTL_SECONDS,
      secondsAfterSuccess: TTL_SECONDS,
    };
  }

  private createCronTemplate() {
    const steps = ['aa', 'bb', 'cc', 'dd'].map(name => this.createStep(name));
    return {
      name: 'extract',
      parallelism: 2,
      steps: [steps],
    };
  }

  private createStep(name: string) {
    return {
      name,
      arguments: {
        parameters: [
          { name: 'indexName', value: name },
          { name: 'secret', value: `secret-${name}` },
        ],
      },
      templateRef: {
        name: ENTRYPOINT,
        template: ENTRYPOINT,
      },
    };
  }
}

説明

  • 定数の使用 : ハードコードされた値を定数に置き換え、コードの再利用性とメンテナンス性を向上させました。
  • メソッドの分割と明確化 : コードの構造を明確にし、各部分の目的が理解しやすくなるようにメソッドを分割しました。
  • 共通コードの抽出 : 重複するコードをメソッドに抽出し、コードのDRY(Don't Repeat Yourself)原則に従いました。
  • インデントとフォーマットの統一 : コードの読みやすさを向上させるために、インデントとフォーマットを整えました。

これらの変更により、ExtractChartクラスはより読みやすく、保守しやすいものになりました。


素晴らしい提案ですね。定数の使用やメソッドの分割、共通コードの抽出など、コードの可読性と保守性を向上させるためのリファクタリングが行われています。この提案を採用して、リファクタリングを進めます。

Apply & スナップショットテストの再実行

Visual Studio Code 上で「Apply in Editor」をクリックし、Github Copilot Chat の上記提案を丸ごとsrc/charts/extract.tsに反映します。

applyInEditor

その後、スナップショットテストを実行します。

スナップショットテスト実行結果
$ npx projen test
👾 test | jest --passWithNoTests --ci
 PASS  test/workflow.test.ts
  Extract Workflow
    ✓ Snapshot Test (8 ms)

------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |      100 |     100 |     100 |                   
 extract.ts |     100 |      100 |     100 |     100 |                   
------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        3.307 s, estimated 4 s
Ran all test suites.

エラーがないため、問題なくリファクタリングできていることが確認できます。これだけコードの変更量が多い場合でも、一発で簡単にリファクタリングできるのはとても便利ですね!

なお、ご参考までに以前の Github Copilot Chat 回答も記載しておきます。悪くないのですが、GPT-4o ベースとなった現在の Github Copilot Chat に比べると、提案の質がやや低い印象です。

(ご参考)以前の Github Copilot Chat 回答

Plan

  1. 定数を使用してハードコードされた値を整理する。
  2. コンストラクタ内のリソース定義をメソッドに分割する。
  3. メソッド名をわかりやすくする。
  4. インデントとフォーマットを統一する。

Refactored Code

import * as wf from '@opencdk8s/cdk8s-argoworkflow';
import { Chart, ChartProps } from 'cdk8s';
import { Construct } from 'constructs';

const NAMESPACE = 'argo';
const LABELS = { type: 'extract' };
const ENTRYPOINT = 'execute-extract';
const IMAGE = 'xxx/extract:latest';
const SCHEDULE = '20 0 * * SAT';
const TIMEZONE = 'Asia/Tokyo';
const INDEX_NAME_PARAM = 'indexName';
const SECRET_PARAM = 'secret';
const ES_URL = 'http://xxx';

export class ExtractChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = {}) {
    super(scope, id, props);
    this.createWorkflowTemplate();
    this.createCronWorkflow();
  }

  private createWorkflowTemplate() {
    new wf.ArgoWorkflowWorkflowTemplate(this, 'execute-extract', {
      metadata: {
        name: ENTRYPOINT,
        namespace: NAMESPACE,
        labels: LABELS,
      },
      spec: {
        entrypoint: ENTRYPOINT,
        templates: [
          {
            name: ENTRYPOINT,
            retryStrategy: {
              limit: '5',
              retryPolicy: 'OnFailure',
              backoff: {
                duration: '10m',
                factor: 2.0,
                maxDuration: '24h',
              },
            },
            inputs: {
              parameters: [{ name: INDEX_NAME_PARAM }, { name: SECRET_PARAM }],
            },
            container: {
              name: 'main',
              image: IMAGE,
              command: ['python'],
              args: ['-m', 'files_crawler', 'extract'],
              resources: {
                requests: { cpu: '500m', memory: '512Mi' },
                limits: { cpu: '1', memory: '2Gi' },
              },
              envFrom: [{ secretRef: { name: `{{inputs.parameters.${SECRET_PARAM}}}` } }],
              env: [
                { name: 'ES_URL', value: ES_URL },
                { name: 'ES_INDEX', value: `{{inputs.parameters.${INDEX_NAME_PARAM}}}` },
              ],
            },
          },
        ],
      },
    });
  }

  private createCronWorkflow() {
    new wf.ArgoWorkflowCronWorkflow(this, 'cron-extract', {
      metadata: {
        name: 'cron-extract',
        namespace: NAMESPACE,
        labels: LABELS,
      },
      spec: {
        schedule: SCHEDULE,
        timezone: TIMEZONE,
        workflowSpec: {
          entrypoint: ENTRYPOINT,
        },
      },
    });
  }
}

Explanation

  • 定数の使用: ハードコードされた値を定数に置き換え、可読性と保守性を向上させました。
  • メソッドの分割: createWorkflowTemplate と createCronWorkflow メソッドに分割し、それぞれが単一の責任を持つようにしました。
  • インデントとフォーマット: インデントとフォーマットを統一し、コードの可読性を向上させました。

このリファクタリングにより、コードの可読性と保守性が向上し、将来的な変更が容易になります。


提案の観点は悪くなさそうですが、steps の定義がないなど、例示されたコードでは明らかに情報が不足しています。無論、この提案を Apply しても、スナップショットテストでエラーになります。色々とプロンプトを変えてみましたが、そのまま利用するのは難しく、工夫が必要そうでした。プロンプトで試行錯誤するよりも、この提案を元に手動でコード変更していく方がよいかもしれません。

リファクタリングではどの生成 AI を使うべき?

やはり、GPT-4o API を直接利用するよりも、コーディング支援に特化した「Github Copilot Chat」がオススメです。Github Copilot Chat では、直接 IDE 上のコードを参照できるため、単純にプロンプトで生じるコピー&ペーストの手間が減ります。加えて、GPT-4o ベースとなったことで、リファクタリングの質も十分実用的になったと感じました。以下ブログによると、GPT-4o へのアップグレードでユーザーの満足度は 60% 向上したとのことです。

このGPT-4oへのアップグレードにより、Copilotのユーザーは以下のメリットを享受することができます。

  1. 応答時間の短縮 – TTFT(time to first byte)を最大55%短縮
  2. より正確で信頼性の高いCopilot Chatの応答 – 私たちのテストでは、ユーザーの満足度が60%向上しました。

(出典)GitHub Copilot Chat and pull request summaries are now powered by GPT-4o - The GitHub Blog より機械翻訳

ただし、Github Copilot Chat ではライセンスが必要です。Github Copilot Chat を利用できない場合、お手元で利用できる生成 AI モデルを使って、前述のようなリファクタリングを試していただければと思います。

なお、実際の開発場面では、生成 AI の提案を基に自身の観点でコーディングすることが度々あります。試行錯誤しながらプロンプトを向上させていくことは大事ですが、コードにある程度慣れたら手動で直接リファクタリングすることもオススメします。

おわりに

本記事では、生成 AI とスナップショットテストを活用したリファクタリングの手法を紹介しました。生成 AI に簡単なプロンプトを送信するだけでリファクタリングの提案を受け取ることができます。また、スナップショットテストと組み合わせることで、簡単かつ安全にリファクタリングできることが分かりました。

スナップショットテストのように外部ツールで生成 AI の正確性を担保することは、ハルシネーションに対する一種の解答と言えるかもしれません。この考え方については他にも応用が利くと思いますので、ぜひ皆さんも色々試してみてください!

本記事がどなたかのお役になれば幸いです。

参考資料

  1. (免責事項)本記事は情報提供のみを目的として作成されたものであり、商品の勧誘を目的としたものではありません。本記事は、当社が信頼できると判断した各種データに基づき作成されておりますが、その正確性、確実性を保証するものではありません。また、本記事に記載された内容は予告なしに変更されることもあります。​

  2. Projen インストール済みのローカル環境に .projenrc.ts ファイルを反映してnpx projenを実行すれば、同様のプロジェクトが生成されます。

  3. cdk8s-argoworkflow でワークフローを定義していますが、本記事のテーマと逸れるため、詳細の説明は割愛します。

  4. 実態は、自社で利用しているチャットサービスから API 経由で GPT-4o にアクセスしています。

  5. Github Copilot Chat へのプロンプト送信方法については、IDE で GitHub Copilot に質問する - GitHub Docs を参照してください。

3
2
0

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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?