コネヒト開発者ブログ

コネヒト開発者ブログ

既存プロダクトのCakePHPのアップグレード戦略

既存プロダクトのCakePHPのアップグレード戦略

こんにちは。サーバーサイドエンジニアをやっている西中です。

花粉症に悩まされているので最近空気清浄機を購入しました。こころなしか症状が緩和している気がしています。

前回はCakePHP4.3にアップグレードする際に躓きがちなphpunitの変更ポイントをいくつか紹介させていただきました。

実はこのCakePHPのアップグレード対応は段階的に行っていました。

CakePHP段階的なアップグレード対応

私が携わっているこのプロダクトは2018年11月にリリースされました。 リリースした時点ではCakePHPのバージョンは3.6でした。

いきなりCakePHP3.xからCakePHP4に上げてしまうとアップグレード対応の差分が大きくなってしまい、対応に時間がかかってしまうという問題があるため、段階的にアップグレード対応しようという判断になりました。

少し話が逸れてしまいますが、弊社では各開発チームごとにスクラムを組んでアジャイル開発を行っています。アジャイル開発と言っても実際の運用はチームごとに異なりますが、当プロダクトでは1スプリントの中で何度もリリースすることがあります。

f:id:satoshie:20220323174103p:plain:w300
GitHubフローにおけるブランチ開発

また、弊社ではGitHubフローに沿って開発を行っています。 このアップグレード対応という「保守対応」と、アウトカムを支えるための「施策運用対応」を並行で進めることになるため、ブランチ運用としてはアップグレード対応用のFeatureブランチとそれぞれの試作用のFeatureブランチが必要になってきます。

施策運用対応のためのブランチは都度都度mainブランチにマージされていくため、保守対応のためのブランチとの差分が増えていき、定期的にmainブランチを取り込みアップグレード版に合わせた形に都度都度修正する必要が出てきます。 (最新のパッチを保守対応用ブランチに適用させていくバックポート対応のイメージです)

この都度都度修正の対応が大きめの施策になればなるほどCakePHPのバージョンの差異に合わせた修正の規模が大きくなってしまう問題もあり、その分工数が余計にかかってしまいます。

これらの事情から、保守対応ブランチをmainブランチへマージするまでの時間を短くするために、あえて段階的にアップグレード対応を行うということになったのです。

また、以前CakeFestで紹介されたスライド(CakePHP - The Road Ahead)でも、2.xから3.0.0にアップグレードしたときに変更量が多くて大変だったということが述べられています。

実際にサービス提供しているプロダクトの場合、安全に倒すためにも段階的なリリースを計画するのが良さそうですね。

CakePHP3.6から3.10へ

まず、CakePHPのバージョンを3.6からCakePHP3.xの最新のバージョンである3.10にアップグレードしました。 実はこの3.6から3.10にアップグレードする際の変更量が一番多かったのではないのかと思っています。

一番大きな影響が受けたのがテストのFixture周りです。

今までは Model クラスをテストケース内で使用する際には TestCase のメンバ変数内で実際の DBに格納されているテーブル名に合わせてModel名を以下のように Snake Caseで記述していましたが、CakePHP3.6以降ではModel名をUpper Camel Caseで記述する必要があります。

TestCase::$fixtures にてアンダースコアー形式のフィクスチャー名を使用することは非推奨です。 代わりにキャメルケース形式の名前を使用してください。例えば、 app.FooBar や plugin.MyPlugin.FooBar です。 3.7 移行ガイド - 3.10 より引用

public $fixtures = [
    'app.cities',
    'app.countries',
    'app.country_languages',
];
public $fixtures = [
    'app.Cities',
    'app.Countries',
    'app.CountryLanguages',
];

ロジックの変更対応ではないので、一つ一つ対応していけば良いのですが、テストケースの数が多ければその分対応する場所も多くなってしまいます。

変更量が多いということはテストファイルによって、ある程度テストの網羅性が担保されているとも考えられるので、この変更は喜んで進めていきましょう。

さいごに

どこの会社・プロダクトでも保守対応は置いてけぼりになりがちになってしまい、フレームワークのバージョンが置いていかれてしまうことが多いと思います。 セキュリティパッチが当てられたりと、フレームワーク側で対応が進められている中で、古いバージョンのまま放置しておくとセキュリティリスクも上がってしまいます。

アウトカムのリリーススケジュールと並行して計画的にバージョンアップを行えるようにしていきたいですね!

あわせて読みたい

SageMakerとStep Functionsを用いた機械学習パイプラインで構築した検閲システム(前編)

皆さん,こんにちは!機械学習エンジニアの柏木(@asteriam)です.

今回はタイトルにもあるようにモデルの学習からデプロイまで一気通貫した機械学習パイプラインをSageMakerとStep Functionsで構築し,新しく検閲システムを開発したお話になります.

こちらのエントリーで紹介されている機械学習を用いた検閲システムの技術的な内容になります.
※ 検閲システムの細かい要件や内容については本エントリーでは多くは触れないのでご了承下さい.

tech.connehito.com

はじめに

今回のエントリーは内容が盛り沢山になっているので,前編と後編の2つに分けて紹介することにします.

  • 前編:SageMaker TrainingJobを用いたモデル学習を行い,SageMaker Experimentsに蓄積された実験結果をS3に保存するまでの話
  • 後編:SageMakerのリソースを用いてモデルのデプロイ(サービングシステムの構築)をStep Functionsのフローに組み込んだ話
    • モデル学習後の一連の流れで,推論を行うためにモデルのデプロイやエンドポイントの作成をStep Functionsで実装した内容になります.

本エントリーはSageMakerとStep Functionsで機械学習パイプラインを構築しようと考えている人や独自の推論処理をSageMakerで動かしたい人向けの内容になります.

これらの内容に関する事例やテックブログは世の中にあまりなく,トライ・アンド・エラーを繰り返すことが多かったので,今後同じようなことを実装しようと考えている人の一助になればと思います.

ソニー創業者の井深大さんも以下のような名言を残されており,今回のプロジェクトは改めてSageMakerとStep Functionsの理解を深めることができ自分自身大きな経験となりました.

トライ・アンド・エラーを繰り返すことが、「経験」「蓄積」になる。独自のノウハウはそうやってできていく。


目次


アーキテクチャー概要

今回実装したシステムのアーキテクチャー概略図は以下のようになります.本エントリーで紹介するのはAWS Step Functionsで組んだ機械学習パイプラインの部分になります.

MLチームではレコメンドシステムもStep Functionsでパイプラインを組んでおり,今回も既に経験&知見があるStep Functionsを使って機械学習パイプラインを作成することにしました.

f:id:connehito-mkashiwagi:20220324154739p:plain
検閲システムのアーキテクチャー概略図

Step Functionsによるパイプラインを実行すると,データ抽出・前処理・モデルの学習・実験結果の保存といった処理が行われ,最終的に推論を行うためのモデルのデプロイが行われます.

デプロイされたサービングシステムはML API(ECS: 実行環境はFargate)からエンドポイントをinvokeされることで処理が走り,結果をML APIに返し,その結果をClientに返す流れになります.(ML APIはClientからリクエストを受けます)

SageMakerのCreateProcessingJob / CreateTrainingJobを使ってデータ抽出・前処理・モデル学習/評価・実験結果の保存まで行っており,モデルを含んだ推論コンテナのデプロイとエンドポイントの作成はSageMakerのCreateModel / CreateEndpointConfig / CreateEndpointを組み合わせて実施しています.

参考までに今回作成したStep Functionsのグラフインスペクターは以下のようなものになります.

f:id:connehito-mkashiwagi:20220324155429p:plain
Step Functionsのグラフインスペクター

今回作成したStep Functionsの処理と対応するSageMakerの処理の対応表は以下になります.

No. ステップ名 SageMakerのアクション 処理内容
1 Dataset-Extracting-Step CreateProcessingJob BigQueryからデータを取得
2 Dataset-Creating-Step CreateProcessingJob 学習と評価用のデータセット作成
3 Model-Training-Step CreateTrainingJob モデル作成
4 Experiments-Saving-Step CreateProcessingJob 実験結果の保存
5 Model-Creating-Step CreateModel 推論コンテナの設定とモデルの作成
6 EndpointConfig-Step CreateEndpointConfig エンドポイントの設定
7 Endpoint-Creating-Step CreateEndpoint エンドポイントの作成とモデルのデプロイ

次からのパートでは,モデル学習時に使用したTrainingJobの話とSageMaker Experimentsに蓄積された実験結果をS3に保存する話に焦点を当てています.

モデル学習にSageMaker TrainingJobを選択した理由

今回構築するパイプラインでは,以下の要素を含んだ方法で実現したいと考えていました.

  • 実験の再現性を担保するために,SageMaker Experimentsに実験結果を保存したい
  • 学習スクリプトはSDKなどAWS特有の記述を意識せずシンプルに作成したいので,面倒な設定はStep Functionsの定義に押し込めたい

一方で,上記を実現する方法としては2パターンあるかなと思います.

  1. ProcessingJobを使い,学習用スクリプト(train.py)とSageMaker SDKを用いたラップ用のスクリプトを使う方法
  2. TrainingJobを用いて,学習用スクリプト(train.py)を使う方法

1つ目の方法に関しては,以前のエントリーで実施した内容で,以前紹介したのはSageMaker Studioから実行した方法ですが,このコードをスクリプト化し,Step FunctionsのProcessingJobで実行する方法になります.こちらはもう少し説明すると,学習用スクリプト(train.py)を用意し,SageMaker SDKのEstimatorクラスを使い用意した学習用スクリプトをラップしたスクリプトを別途用意する必要があります.この場合は,ラップしたスクリプト内部or環境変数として設定用の変数を複数入れてやる必要があるので,複雑になってしまうかなと思います.また,SageMaker SDKのお作法を理解して実装する必要があります.

2つ目の方法は,SageMaker SDKのEstimatorクラスの設定をStep FunctionsのTrainingJobが担う方法です.こちらは設定をStep Functionsの定義に押し込めることができるので,ラップ用のスクリプトを別途用意する必要はなく,学習用スクリプトのみを用意するだけで大丈夫です.こちらの方がコードが複雑にならず,Step Functionsの定義を管理すれば良いです.

今回は2つ目の方法を採用し実装することにしました.(前提として独自のカスタムコンテナイメージを用いる想定です)

※ これらとは別に全てをコード管理して,SageMaker SDKやStep Functions SDKを使ったworkflowを構築する方法もあります.
参考: Amazon SageMaker Processing と AWS Step Functions Data Science SDK で機械学習ワークフローを構築する

SageMaker TrainingJobを用いたモデル学習

それでは実際の設定を見ていきますが,学習用スクリプト(train.py)については具体的な処理は載せることはできないので,実装イメージを載せておきます.

公式のサンプルコードも参考になると思うので,参考下さい.
参考: amazon-sagemaker-examples/advanced_functionality/scikit_bring_your_own

# train.py
import argparse
import os

def main(params):
    # パラメータの受け取り
    max_length = params.max_length
    learning_rate = params.learning_rate
    epochs = params.epochs
    batch_size = params.batch_size

    # 以下にモデル学習に必要な処理を記述する(実際は色々とコードがあるが今回は省略)
    model_path_prefix = '/opt/ml/model/'
    model_path = os.path.join(model_path_prefix, 'bert_model.h5')
    create_model(
        X_train, y_train, X_valid, y_valid,
        learning_rate, epochs, batch_size,
        model_path
    )  # モデル作成を行う関数: train/validデータやハイパーパラメータなどを引数に渡す
    ...


if __name__ == "__main__":
    # コマンドライン引数をパースする
    parser = argparse.ArgumentParser()

    # モデルのハイパーパラメータ引数
    parser.add_argument(
        "--max_length",
        type=int,
        default=512,
        help="The maximum length of a sentence to use as input"
    )

    parser.add_argument(
        "--learning_rate",
        type=float,
        default=3e-5,
        help="Learning rate when model is created"
    )

    parser.add_argument(
        "--epochs",
        type=int,
        default=5,
        help="Number of epochs when model is created"
    )

    parser.add_argument(
        "--batch_size",
        type=int,
        default=12,
        help="Number of batch size when model is created"
    )

    params, _ = parser.parse_known_args()
    main(params)

学習したモデルは '/opt/ml/model/' 配下に格納され,このファイルが後述するStep Functionsの定義で指定したファイルパスに同期されます.ここはモデルデプロイ時にも関係してくるので,パス設定は重要になります.

このコードに関する実行権限をDockerfileで与える必要があるので,ここは以前のエントリーの「カスタムコンテナで実行するための準備」の部分を参考にして頂ければと思います.

また,今回はGPU環境での学習になるので,カスタムコンテナイメージを使ってSageMaker TrainingJobを動かす方法は手前味噌ですが,Step Functionsで自作Dockerfileを使ってSageMakerのGPUマシンを動かす方法を参考下さい.

Step Functionsの定義設定 - モデルの学習を行う処理

次にStep Functionsの定義設定でTrainingJobの設定部分だけを取り出して説明していきます.

"Model-Training-Step": {
    "Comment": "モデル作成処理",
    "Type": "Task",
    "Resource": "arn:aws:states:::sagemaker:createTrainingJob.sync",
    "Parameters": {
      "RoleArn": "arn:aws:iam::<アカウントID>:role/StepFunctions_SageMakerAPIExecutionRole",
      "TrainingJobName.$": "States.Format('{}-{}', $$.Execution.Name, $$.State.Name)",
      "AlgorithmSpecification": {
        "EnableSageMakerMetricsTimeSeries": true,
        "MetricDefinitions": [
          {
            "Name": "Train Loss",
            "Regex": "train_loss: (.*?);"
          },
          {
            "Name": "Validation Loss",
            "Regex": "val_loss: (.*?);"
          },
          {
            "Name": "Train Metrics",
            "Regex": "train_accuracy: (.*?);"
          },
          {
            "Name": "Validation Metrics",
            "Regex": "val_accuracy: (.*?);"
          }
        ],
        "TrainingImage": "<アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/sample:latest-gpu",
        "TrainingInputMode": "File"
      },
      "EnableInterContainerTrafficEncryption": true,
      "EnableManagedSpotTraining": true,
      "Environment": {
        "PYTHON_ENV": "prod",
        "SAGEMAKER_PROGRAM": "/opt/program/train.py"
      },
      "ExperimentConfig": {
        "ExperimentName": "prod-sample-experiment",
        "TrialName": "training-job",
        "TrialComponentDisplayName.$": "States.Format('{}', $$.Execution.Name)"
      },
      "HyperParameters": {
        "max_length": "512",
        "learning_rate": "3e-5",
        "epochs": "5",
        "batch_size": "12"
      },
      "CheckpointConfig": {
        "LocalPath": "/opt/ml/checkpoints/",
        "S3Uri": "s3://sample-prod-ml-data/workplace/model/checkpoints/"
      },
      "InputDataConfig": [
        {
          "ChannelName": "train",
          "DataSource": {
            "S3DataSource": {
              "S3DataDistributionType": "ShardedByS3Key",
              "S3DataType": "S3Prefix",
              "S3Uri": "s3://sample-prod-ml-data/workplace"
            }
          },
          "InputMode": "File"
        }
      ],
      "OutputDataConfig": {
        "S3OutputPath": "s3://sample-prod-ml-data/workplace/model/"
      },
      "ResourceConfig": {
        "InstanceCount": 1,
        "InstanceType": "ml.g4dn.xlarge",
        "VolumeSizeInGB": 10
      },
      "StoppingCondition": {
        "MaxRuntimeInSeconds": 86400,
        "MaxWaitTimeInSeconds": 86400
      }
    },
    "Catch": [
      {
        "ErrorEquals": [
          "States.ALL"
        ],
        "Next": "NotifySlackFailure"
      }
    ],
    "Next": "Experiments-Saving-Step"
}
  • RoleArn: S3, ECRにアクセスでき,SageMakerとStep Functionsのポリシーを持ったロールを指定する必要があります.エラーが発生した場合は適宜必要なポリシーをアタッチして下さい.
  • MetricDefinitions: 学習時に出力しているログから正規表現を用いて結果をExperimentsに保存することができます.必要な評価指標をログ出力しておき,ここで取れるようにしておきます.
  • TrainingImage: ECRに登録したdocker imageのURIを指定します.今回はGPU版のimageを用意してそれを使用しています.
  • EnableManagedSpotTraining: trueを設定することでスポットインスタンスを使った学習が可能になります.ただし,CheckpointConfigを設定していないと状況次第で学習が停止し,また最初から始まってしまうので,注意が必要です.
  • Environment: 環境変数を指定することができます.今回大事なのは,SAGEMAKER_PROGRAMの変数でここで指定したパスのスクリプトが実行されることになります.
  • ExperimentConfig: SageMaker Experimentsに結果を保存する設定を行います.ExperimentNameTrialNameは事前に作成しておく必要があります.(TrialNameとTrialComponentDisplayNameに関しては指定しない場合,自動的に適当な値が付与されますが,管理する上で把握しておく必要があります)今回はSageMaker Studioで事前に作成していますが,CreateExperimentCreateTrialを使うことでStep Functionsの処理の1つとして実行することができます.
  • HyperParameters: 学習時に使うハイパーパラメータや実験結果として残しておきたい値を入れておくことで保存されます.
  • OutputDataConfig: 学習済みモデルを保存する場所になります.コンテナ内の’/opt/ml/model/'に保存されたモデルファイルがmodel.tar.gzとして圧縮された形で設定したパスに保存されます.これをモデルデプロイ時のモデルパスに指定する必要があります.

ResultPathはTrainingJobの出力結果を後続の処理で使用したいので,nullの設定はしていません.その他の設定値はCreateTrainingJobを参考下さい.

CloudWatch Logsの結果を見ると,学習が実施できていることがわかります.これでTrainingJobを用いた学習を実施することができました.

f:id:connehito-mkashiwagi:20220324160848p:plain
モデル学習時のログ

SageMaker Experimentsの結果をS3に保存

学習後の実験結果はSageMaker Experimentsに保存されており,UI上だとSageMaker Studioからしか確認することができません.他のビジネス指標などと比較したい場合に毎回SageMaker Studioを見に行ったりすることは大変ですし,ダッシュボードなどで同時に見れることが望ましいです.コネヒトではBIツールとしてredashを使っているので,結果をcsvでS3に保存しておくとAthena経由でredash上で確認することができます.

モデル学習のステップの後に,実験結果の保存を行うステップを入れて対応しています.

upload_experiments.pyというスクリプト内で,SageMaker SDKを使用してsagemaker.analytics.ExperimentAnalyticsから記録した実験結果にアクセスして,必要な情報をデータフレームに整理して結果をcsvとしてS3にアップロードする流れになります.

SageMaker SDKを使用して実験結果を取得してみると,モデルの学習に要した時間が取得できなかったため上述したTrainingJobの出力結果をStep Functionsのステップで環境変数として渡すことで工夫しています.スクリプト内にos.environ['TRAINING_START_TIME']os.environ['TRAINING_END_TIME']のような形で変数を受け取り終了時刻から開始時刻を引くことで経過時間を算出しています.この計算した値や学習した日付などの情報も合わせてデータフレームに記録するようにしています.

以下がTrainingJobの出力結果(不要な部分は一部削除しています)です.

{
  "TrainingJobName": "4fc2550d-d694-3a8c-607a-368bbdd2a97d-Model-Training-Step",
  "ModelArtifacts": {
    "S3ModelArtifacts": "s3://sample-prod-ml-data/workplace/model/4fc2550d-d694-3a8c-607a-368bbdd2a97d-Model-Training-Step/output/model.tar.gz"
  },
  "TrainingJobStatus": "Completed",
  "HyperParameters": {
    "batch_size": "12",
    "epochs": "5",
    "learning_rate": "3e-5",
    "max_length": "512"
  },
  "InputDataConfig": [
    {
      "ChannelName": "train",
      "DataSource": {
        "S3DataSource": {
          "S3DataType": "S3_PREFIX",
          "S3Uri": "s3://sample-prod-ml-data/workplace/",
          "S3DataDistributionType": "SHARDED_BY_S3_KEY"
        }
      },
      "CompressionType": "NONE",
      "RecordWrapperType": "NONE"
    }
  ],
  "OutputDataConfig": {
    "S3OutputPath": "s3://sample-prod-ml-data/workplace/model/"
  },
  "CreationTime": 1644856394459,
  "TrainingStartTime": 1644856580969,
  "TrainingEndTime": 1644873275331,
  "LastModifiedTime": 1644873275331,
  "SecondaryStatusTransitions": [
    {
      "Status": "Starting",
      "StartTime": 1644856394459,
      "EndTime": 1644856580969,
      "StatusMessage": "Preparing the instances for training"
    },
    {
      "Status": "Downloading",
      "StartTime": 1644856580969,
      "EndTime": 1644856654265,
      "StatusMessage": "Downloading input data"
    },
    {
      "Status": "Training",
      "StartTime": 1644856654265,
      "EndTime": 1644873137479,
      "StatusMessage": "Training image download completed. Training in progress."
    },
    {
      "Status": "Uploading",
      "StartTime": 1644873137479,
      "EndTime": 1644873275331,
      "StatusMessage": "Uploading generated training model"
    },
    {
      "Status": "Completed",
      "StartTime": 1644873275331,
      "EndTime": 1644873275331,
      "StatusMessage": "Training job completed"
    }
  ]
}

Step Functionsの定義設定 - 実験結果の保存を行う処理

Step Functionsの定義設定は以下のようになっており,この処理はProcessingJobを使用しています.

"Experiments-Saving-Step": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sagemaker:createProcessingJob.sync",
  "Parameters": {
    "AppSpecification": {
      "ImageUri": "<アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/sample:latest-cpu",
      "ContainerEntrypoint": [
        "python3",
        "/opt/program/upload_experiments.py"
      ]
    },
    "Environment": {
      "PYTHON_ENV": "prod",
      "AWS_DEFAULT_REGION": "ap-northeast-1",
      "EXPERIMENT_NAME": "prod-sample-experiment",
      "TRIALS_NAME": "training-job",
      "TRIAL_COMPONENT_DISPLAY_NAME.$": "States.Format('{}', $$.Execution.Name)",
      "TRAINING_START_TIME.$": "States.Format('{}', $.TrainingStartTime)",
      "TRAINING_END_TIME.$": "States.Format('{}', $.TrainingEndTime)"
    },
    "ProcessingResources": {
      "ClusterConfig": {
        "InstanceCount": 1,
        "InstanceType": "ml.t3.medium",
        "VolumeSizeInGB": 5
      }
    },
    "RoleArn": "arn:aws:iam::<アカウントID>:role/StepFunctions_SageMakerAPIExecutionRole",
    "ProcessingJobName.$": "States.Format('{}-{}', $$.Execution.Name, $$.State.Name)"
  },
  "Catch": [
    {
      "ErrorEquals": [
        "States.ALL"
      ],
      "Next": "NotifySlackFailure"
    }
  ],
  "ResultPath": null,
  "Next": "Model-Creating-Step"
}
  • Environment: 環境変数に1つ前のTrainingJob(モデル学習ステップ)の出力結果であるTRAINING_START_TIMETRAINING_END_TIMEを参照して使用しています.これが先ほど説明した部分になります.

保存したcsvをデータフレームで表示する以下のような形になります.Trainingtimeとdatetimeが追加した部分になります.

f:id:connehito-mkashiwagi:20220324161052p:plain
S3に保存した実験結果のcsvファイル

おわりに

本エントリーの前編はモデル学習とその実験結果の保存に焦点を当てて紹介しました.Step FunctionsのSageMaker TrainingJobを使用したパイプライン構築を行った事例はほとんどないと思っているので,参考になれば嬉しいです.ちなみにSageMaker StudioのJupyter Notebookを使った手動実行の事例はいくつか存在していますし,公式のサンプルノートブックも多数あります.

今回の紹介した部分はMLOpsでいうところの「実験管理」や「パイプライン構築」にあたり,再現性や継続的な学習(Continuous Training)に繋がる部分になると思っています.
例えばパイプラインは,EventBridgeを使うことで定期的にモデルの更新を実施することが可能になりますし,モニタリングしている指標の変化を検知し,それをトリガーにしてモデルの更新を行うなどの方法も考えられます.
また,SageMaker Experimentsに実験結果を保存していくことでチームで結果を共有することができ,どういったパラメータでオフラインの評価指標がどうだったかなど知見として残し再現性を担保できるようになったのは大きな前進かなと思います.

一方で,TrainingJobを使う点において少し辛い点を書くと以下が挙げられます.

  • デバッグがしんどい
  • 実行時間がそれなりにかかる

動作確認するためにStep Functionsで処理を組んで実行すると起動するまでに時間がかかるのと,コード変更が入った時に毎回ECRにイメージをpushしてから再実行となるので,デバッグするのに一苦労かかります.そもそもエラーが分かりづらいという部分もありますが...笑

後編では,作成したモデルのデプロイと推論を行うためのエンドポイントのデプロイの部分について紹介します.

最後に,コネヒトではプロダクトを成長させたいMLエンジニアを募集しています!!(切実に募集しています!)
もっと話を聞いてみたい方や,少しでも興味を持たれた方は,ぜひ一度カジュアルにお話させてもらえると嬉しいです.(僕宛@asteriamにTwitterDM経由でご連絡いただいてもOKです!)

www.wantedly.com

コネヒトの機械学習プロジェクトにおける構想フェーズ・PoCフェーズの進め方

みなさんこんにちは。機械学習チームのたかぱい(@takapy0210)です。

最近はワールドトリガーというアニメにハマっておりまして、2022年から第3期の放映が始まっております。

内容はよくあるバトルアニメですが、チームで戦略を練って戦うところがユニークでとても面白いです。(個々の力だけだと到底叶わない相手に対して戦略で勝つ、という展開もあり、戦略の大事さを改めて痛感しました)

さて本日は、コネヒトの機械学習プロジェクトがどのように推進され、開発・実装フェーズに移行していくのかについて、1つの事例を交えながらご紹介できればと思います。(※あくまで1つの事例なので、全てがこのように進むわけではありません)


目次


今回のプロジェクト概要

コネヒトではママリというコミュニティアプリを運営しており、1ヶ月で約130万件のQAが投稿されています。

この投稿を全て目視チェックするのは現実的に不可能なため、目視チェックする前段で機械学習モデルによるチェックを挟むことで、荒らしのような投稿やガイドラインに違反するような投稿のみを、人間がチェックする運用となっております。
(詳細は以前のブログでも紹介しているので、興味のある方はこちらもご覧ください)

今回のプロジェクトは、この検閲モデルを特定のユーザークラスタに適応し運用させることで、CS(カスタマーサクセス)チームの抱える課題を解決できるのではないか、ということでスタートしています。

機械学習プロジェクト全体の流れ

機械学習プロジェクトには、大きく分けて以下4つのフェーズがあると考えています。

  1. 構想フェーズ
  2. PoCフェーズ
  3. 実装フェーズ
  4. 運用フェーズ

f:id:taxa_program:20220323152010p:plain

本エントリでは、構想フェーズとPoCフェーズに焦点を当てながらお話ししていこうと思います。

それぞれのフェーズを簡単に説明すると、構想フェーズではプロジェクトによって解くべき課題を特定し、ビジネス要件を明文化していきます。ここでは、十分に投資対効果が見込まれるテーマを見極めることが重要だと思います。

PoCフェーズでは、構想フェーズで立てたテーマが技術的に実現可能かどうかを、機械学習モデルのモックアップを構築して検証していきます。

上記のフェーズで特に大切だと感じている点について、プロジェクトの事例を交えながら深ぼってお話ししていこうと思います。

  • 構想フェーズ:ビジネス要件を明確にし、共通認識をつくる
  • PoCフェーズ:定性チェックと定量チェックの両方を実施する

構想フェーズ:ビジネス要件を明確にし、共通認識をつくる

今回は以下のようなテンプレートに沿って、CSチームと共に要件を明文化していきました。

※ 冒頭でもお話した通り「既に本番で運用している検閲モデルを特定のユーザークラスタでも扱えるよう適応させる」というプロジェクトなので、本来であれば議論すべきである「機械学習で扱えるテーマかどうか」について、今回は議論していません。

# 提案施策の概要(3行くらいで)
- hoge
- fuga
- piyo

## 関連する部門や人
- hoge

## 要求分解
- As is:今何が起きているか(今(まで)、こうだ(った)よね、みたいな話)
    - hoge
- Issue:その状況をどう捉えているか、何が課題か(これって問題だよね、みたいな話)
    - hoge
- To be:あるべき姿・なりたい姿 / どんなアプローチで解決するのか(なので〜というアプローチをして〜のような状態になりたい、みたいな話)
    - hoge

# アプローチの具体(As is → To beになるための具体的な行動)

## (To beに対して)なぜそのアプローチ・解決策なのか?(意図・裏付け・仮説)
- hoge

## 今回は具体的に何をしようとしているのか?(今回のスコープ)
- hoge

## どんな効果を期待しているのか?(ROI的な話)
- hoge

上記の項目を全て埋めることができれば、自然と全体像が見えてくるようになると思います。

中でも個人的には「どんな効果を期待しているのか?(ROI的な話)」の部分が重要だと思っているので、次項でもう少し詳しくお話しします。

どんな効果を期待しているのか?(ROI的な話)

ここで考えることは、ざっくり言うと「今までは〇〇だったものが、この施策をやることで×××になる」といったことになります。

今回のプロジェクトでは具体的に以下のようなことについて議論し明文化しました。

- コスト削減
    - 定量評価:検閲件数が半分削減できたときにxx万円/月カット
- サービス品質向上
    - 定量評価:人間が目視検査しなくて良いものはすぐに投稿されるので、回答率の向上や回答がつくまでの時間が短縮できる
- サービス品質維持
    - 定性評価:検閲モデルを導入したあとでもコミュニティの品質は担保したい
- etc...

このように、定量的な数値に関しても関係者間で共有認識をとっておくことで、この後のPoCフェーズがスムーズにいくと思います。

今回の例では「検閲件数が半分に削減できれば月のオペレーション時間がxx時間ほど削減できる」ということが試算できているので、「コミュニティの品質を維持しながら、検閲件数が従来の半分に削減できる機械学習モデル」を開発すれば良いことになり、PoCフェーズのゴールもある程度明確にすることができます。
(最高のモデルを開発すべく奮闘し、気がついたらずっとPoCやっている・・・みたいなことも防げます😇 )

ビジネス要件が明文化され、ビジネスインパクトが大きいと判断できれば、次のPoCフェーズへ移行します。

PoCフェーズ:定量チェックと定性チェックの両方を実施する

今回の機械学習モデルは「ガイドラインに違反しているか否か」を判別するシンプルな2値分類タスクです。

このようなタスクで用いられる評価指標としてはAccuracyやRecall、Precisionなどが挙げられ、これらの指標を用いて構築した機械学習モデルの性能を定量的に評価していきます。

ある程度形になってきたら、実際の運用を想定すべく、ある一定期間のデータをモデルで推論したものを、CSチームに定性的に評価してもらいます。

ここでチェックしてもらう目的は以下の2点です。

  • コミュニティ運営の視点から、ガイドラインに著しく違反しているものが正しく推論できているか
  • モデルの閾値*1をどの程度にすれば、期待する成果( = コミュニティの品質を維持しつつ、検閲件数を従来の半分にする)を実現できそうか

定性チェックの必要性

定量的な評価のタイミングでは、例えば「Recallが80%(今回だと、違反と推論したデータの中に真の違反データがどれくれい含まれているか)」という値は計算することができますが、この数値だけで本来の要件であった「コミュニティの品質を維持しつつ、検閲件数を従来の半分にする」が満たせるかどうか判断するのは難しいです。

「取りこぼしている20%にはどのようなデータが含まれているのか?」「20%のうち漏らしたくないデータを漏れなく検閲するには、どのような方法が考えられるか?」といった部分を議論できるように、CSチームにチェックしてもらいつつビジネス要件との差分を徐々に詰めていきます。

この議論により、「機械学習モデルをアップデートして精度を上げれば解消できそうな問題」なのか、それとも「モデルのアップデートでは解消できない問題なので、後処理などを工夫する必要がある」のか、といった勘所を掴むこともできます。

上記のようなモデル構築→定量チェック→定性チェックを繰り返しながら、当初の要件を満たせるところまで、検証を続けていきます。

今回はベースラインモデル(ver1)を作成してCSチームに定性チェックをお願いしたところ、検閲漏れの投稿(人間の目視チェックを行いたいが、モデルでは"問題なし"と推論されたデータ)がいくつかあったため、そこを解消できるようにモデルをアップデート(ver2)しました。

ver2のモデルでは定性チェックも問題なかったため、実装フェーズに移行し、2022/03/23現在では無事に運用できています。

f:id:taxa_program:20220323152907p:plain
実際にCSチームに定性チェックをお願いした時のやりとり

で、今回の施策の効果はどうだったの?

運用を開始してまもなく1ヶ月ほど経ちますが、当初の期待通り、xx万円/月のコスト削減に寄与できています。

また、1つの質問に対する平均回答数も0.3ほど向上しており、コミュニティにとっても良い影響を及ぼすことができました。

We are hiring!!

コネヒトでは、プロダクトを成長させたいMLエンジニアを募集しています!!(切実に募集しています!)

  • ライフイベント、ライフスタイルの課題解決をするサービスに興味がある方
  • 機械学習の社会実装、プロダクト開発に興味のある方

是非お話できれば嬉しいです!

カジュアル面談では答えられる範囲でなんでも答えます!(特に準備はいりません!)

自分のTwitter宛てにDM送っていただいてもOKですし、下記リンクからお気軽にご連絡お待ちしています!

www.wantedly.com 大規模データを活用してサービスの成長にコミットする機械学習エンジニア募集! by コネヒト株式会社

*1:今回のモデルは違反確率を出力するものになっているので、閾値を決める必要があります

PHPStanを0.11から1.4へメジャーアップデートした際の知見

こんにちは!webエンジニアの高谷です。

弊社ではCakePHPなどの社内のプロジェクトで使われているフレームワークやライブラリのアップデートを定期的に行っています。

その一環でママリのアプリ内で使用されているwebviewのCakePHPを3.8から4.0にアップデートした際に、使用しているPHPStanのバージョンが0.11とかなり古めだったのでこちらも1.4にメジャーアップデートしました。

今回はPHPStanを中心にアップデートした際の変更点をいくつかピックアップしていきたいと思います。

はじめに

PHPStan1.0のリリース

PHPStanは最初のリリース(2016年7月)から長らく0系でしたが去年の2021年11月に1.0がリリースされました。

1.0では静的検査をする際の新しいレベルの登場や破壊的変更がいくつかありますが詳しくは公式サイトからご覧ください。

phpstan.org

アップデートによる変更点

bin-pluginを削除して同一のcomposer.jsonで管理する。

bin-pluginについてはこちらに詳しく纏まっていますが簡単に説明すると、ライブラリの依存パッケージのバージョンが他のライブラリの依存パッケージとコンフリクトを起こしていた場合に任意の名前空間でcomposer.jsonやcomposer.lockを分けてコンフリクトを解消しようというものです。

(こんな感じにディレクトリを分けて管理することが出来ます)

f:id:ryoutakaya3623:20220318134128p:plain

以前のバージョンのPHPStanでは依存パッケージのバージョンが他のライブラリとコンフリクトを起こしていたのでbin-pluginを使用して別々で管理するようにしていました。 ですが0.12からPharファイルでの配布が公式採用されて依存パッケージのコンフリクトが起きないようになったので同一のcomposer.jsonで管理するようにしました。

phpstan.org

CakeDC/cakephp-phpstanの導入

CakeDC/cakephp-phpstanはCakePHP4系専用のPHPStanの拡張ライブラリです。

PHPStanは独自のルールを追加する為のカスタムルールや特定のクラスの__get__setなどのマジックメソッドの引数や戻り値を静的検査できるようにする拡張機能があり、その拡張機能を利用した拡張ライブラリが豊富に配布されています。

CakePHP4系に上げてCakeDC/cakephp-phpstanを導入する事によってCakePHP独自の設定をそちらに委任するようにしました。

github.com

phpstan.neonの修正

phpstan.neonはPHPStanの設定ファイルの事で、静的検査の対象/除外したいディレクトリの設定や無視したいエラーなど他にも様々な設定を記述する事によってそのプロジェクトにあった柔軟な設定が実現できます。

1系になった事で設定ファイルの項目名の破壊的変更がありautoload_filesbootstrapの項目が削除されたりしましたが公式ドキュメントに従って修正すれば特に難しい事はありませんでした。

phpstan.org

まとめ

今回は特にPHPStanのメジャーアップデート起因による大きな修正は無かったですが、これからも継続的にメンテナンスをしていき快適な静的検査を実施していきたいと思います。

このブログでは他のメンバーのCakePHP4系や周辺ライブラリのバージョンアップに関する記事があるのでぜひそちらもご覧ください!

「こんなところも?」 CakePHP4・phpunitのアップグレードに伴う変更箇所 - コネヒト開発者ブログ

CakePHP3から4へのバージョンアップ時に困ったキャッシュ周りの話 - コネヒト開発者ブログ

Android版ママリアプリのリファクタ事情 ~時刻テスト編~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

前回のエントリーから、髪の毛はアップデートされておりません。そろそろ予定を立てないとな〜と思いつつ、重い腰が上がりません。

さて今回は、時刻テストに関するリファクタリングについて紹介いたします。

はじめに

コネヒト社で開発しているママリ Android 版は、開発が始まってから 5 年以上経過しました。

開発当初からの歴史の中で、さまざまなコードを継ぎ足してきたママリ Android 版は、いくつもの改善ポイントを抱えています。この記事では、ようやくメスを入れられた 「現在時刻に関係したユニットテストの基盤づくり」 の取り組みを紹介します。

前提

背景

現在時刻に関係したユニットテストのやり方についてググれば、ユニットテスト実行時に現在時刻を固定するサンプルコードは色々ありますが、今回は io.kotest と組み合わせて、少し書きやすくしてみます。

実装

現在時刻を提供するクラス

まずは現在時刻を提供するクラスです。 現状、まだ移行が完了していないため org.threeten.bp.XXX を使っていますが java.time.XXX でも同じです。

import androidx.annotation.VisibleForTesting
import org.threeten.bp.Clock
import org.threeten.bp.Instant
import org.threeten.bp.LocalDate
import org.threeten.bp.LocalDateTime
import org.threeten.bp.ZoneId
import org.threeten.bp.ZonedDateTime

/**
 * 現在時刻を提供するクラス
 */
object CurrentTimeProvider {

    private val systemClock = Clock.systemDefaultZone()
    private var currentClock: Clock = systemClock

    fun currentZoneId(): ZoneId = currentClock.zone

    fun toLocalDate(): LocalDate = LocalDate.now(currentClock)
    fun toLocalDateTime(): LocalDateTime = LocalDateTime.now(currentClock)
    fun toZonedDateTime(): ZonedDateTime = ZonedDateTime.now(currentClock)
    fun toInstant(): Instant = currentClock.instant()
    fun toMillis(): Long = currentClock.millis()

    @VisibleForTesting
    object Test {
        const val DEFAULT_YEAR = 2000
        const val DEFAULT_MONTH = 1
        const val DEFAULT_DAY_OF_MONTH = 1
        const val DEFAULT_HOUR = 0
        const val DEFAULT_MINUTE = 0
        const val DEFAULT_SECOND = 0
        const val DEFAULT_NANO_OF_SECOND = 0

        /**
         * 現在時刻を固定する。
         */
        fun fixed(
            year: Int = DEFAULT_YEAR,
            month: Int = DEFAULT_MONTH,
            dayOfMonth: Int = DEFAULT_DAY_OF_MONTH,
            hour: Int = DEFAULT_HOUR,
            minute: Int = DEFAULT_MINUTE,
            second: Int = DEFAULT_SECOND,
            nanoOfSecond: Int = DEFAULT_NANO_OF_SECOND,
            zoneId: ZoneId = ZoneId.of("Asia/Tokyo"),
        ) {
            val fixedInstant = ZonedDateTime
                .of(
                    year,
                    month,
                    dayOfMonth,
                    hour,
                    minute,
                    second,
                    nanoOfSecond,
                    zoneId,
                )
                .toInstant()
            currentClock = Clock.fixed(fixedInstant, zoneId)
        }

        /**
         * 現在時刻を固定する。
         */
        fun fixed(time: ZonedDateTime) {
            currentClock = Clock.fixed(time.toInstant(), time.zone)
        }

        /**
         * 現在時刻を固定する。
         */
        fun fixed(instant: Instant) {
            currentClock = Clock.fixed(instant, currentZoneId())
        }

        /**
         * 現在時刻の固定を解除する。
         */
        fun tick() {
            currentClock = systemClock
        }
    }
}

現在時刻を固定する拡張関数

今回は io.kotest.core.spec.style.ExpectSpec を対象にしています。 実際のテストコードは test: suspend TestContext.() -> Unit で実行し、その実行前後で現在時刻の固定と解除をします。

import io.kotest.core.spec.style.scopes.ExpectSpecContainerContext
import io.kotest.core.test.TestContext

suspend fun ExpectSpecContainerContext.expectOnFixedTime(
    name: String,
    year: Int = CurrentTimeProvider.Test.DEFAULT_YEAR,
    month: Int = CurrentTimeProvider.Test.DEFAULT_MONTH,
    dayOfMonth: Int = CurrentTimeProvider.Test.DEFAULT_DAY_OF_MONTH,
    hour: Int = CurrentTimeProvider.Test.DEFAULT_HOUR,
    minute: Int = CurrentTimeProvider.Test.DEFAULT_MINUTE,
    second: Int = CurrentTimeProvider.Test.DEFAULT_SECOND,
    nanoOfSecond: Int = CurrentTimeProvider.Test.DEFAULT_NANO_OF_SECOND,
    test: suspend TestContext.() -> Unit,
): ExpectSpecContainerContext {
    CurrentTimeProvider.Test.fixed(year, month, dayOfMonth, hour, minute, second, nanoOfSecond)
    expect(name, test)
    CurrentTimeProvider.Test.tick()
    return this
}

実際に現在時刻を固定したテスト

ExpectSpecContainerContext に対して定義した拡張関数 expectOnFixedTime() を使います。固定したい時刻を引数で指定します。

ExpectSpecContainerContext#expect(...) と近いインターフェースにしておいたので、同じような使い方で書けるようになりました。

class ExpectSpecExtensionTest : ExpectSpec({

    context("current time") {
        val expectFixedYear = 1987
        val expectFixedMonth = 3
        val expectFixedDayOfMonth = 30

        expectOnFixedTime("fixed", year = expectFixedYear, month = expectFixedMonth, expectFixedDayOfMonth) {
            CurrentTimeProvider.toZonedDateTime().apply {
                year shouldBe expectFixedYear
                month.value shouldBe expectFixedMonth
                dayOfMonth shouldBe expectFixedDayOfMonth
            }
        }
    }

おわりに

今回は、現在時刻に関係したユニットテストの基盤づくりの一例を紹介しました。 今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

今回の改修を主導してくれた、もっさん*1に感謝します!!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

*1:業務委託で参画してくれている水元さんです

Sass から styled-components に移行している話

こんにちは!エンジニアの富田です。 今回はママリのアプリ内で使われている WebView の Sass を一部 styled-components へ移行しましたので、その事例を紹介します。

特に真新しい情報はありませんが、1つの事例として読んでいただければ幸いです。

はじめに

ママリのアプリ内の WebView の背景を説明すると、2020/07 以前に作られた画面は Sass x FLOCSS で作成されました。それ以降の新規作成する画面については、styled-components を使用して作成されており、Sass と styled-components が混在する状態になっています。

少しずつではありますが、WebView クライアントの健全性を高めるべく、Sass を利用しているいくつかの画面を styled-components 化しましたので、移行の流れを紹介します。

移行の流れ

  1. 移行する画面を決める
  2. Sass から styled-components へ置き換え
  3. 動作確認

移行する画面を決める

今回はプロダクト開発の空き時間を利用して移行するため、全てを Sass から styled-components 化するには作業量が多く、あまり現実的ではありませんでした。従って、移行する画面をいくつかピックアップしたのですが、それほど使われていない画面を移行してもあまり意味がないため、よく利用されている画面を styled-components 化することに決めました。

Sass から styled-components へ置き換え

移行する画面を決めたらあとは愚直に Sass から styled-components へ置き換えていきます。対象画面の Sass のスタイルを styled-components に書き換えていく中で、ママリのカラーパレットに準拠していないカラーコードが散見されたため、適切なカラーコードを利用するように整理しました。

また、Cypress によるスクリーンショットの比較テストが導入されているため、コミットしてプッシュするたびに CI 上で UI が崩れていないか自動チェックしてくれるので、安心しながら効率的に移行を進めていました。

コンポーネント単位で上記を繰り返していくことで、置き換えが完了します。

動作確認

いよいよ動作確認です。正常な動作を確認し、CI のテストが通っていれば、最後に実機で動作確認します。

この段階で特に問題は見つからず、Cypress による自動チェックの恩恵を受けて効率的に移行できました。

おわりに

今回は Sass から一部 styled-components 化した事例を紹介させてもらいました。まだまだ一部なので、引き続き移行は続けていきたいと思います。また Cypress の自動テストのおかげで効率よく作業を進められ、テストの重要性を改めて感じました。

引き続き、ママリのモダン化を進めていきたいと思います。

PR

コネヒトでは、フロントエンド開発のモダン化に挑戦したいエンジニアも募集中です!

hrmos.co

Canvas を使って画像をリサイズする

はじめに

こんにちは! フロントエンドエンジニアの もりや です。

今回はママリのアプリ内で使われている WebView で、画像をリサイズする処理を Canvas で実装した事例を紹介します。

画像のリサイズが必要な理由

昨今のスマホのカメラで撮った画像は数MB程度と大きく、アップロードに時間がかかったり、そもそもサーバー側で何MBまでの画像を許容するかなど課題もあります。 また iOS/Android のママリアプリでも、おそらく同様の理由からリサイズをしてアップロードするようになっていました。 そのため、WebView でもアップロード前に画像をリサイズする処理を入れ、快適かつ安全にアップロードできるようにしました。

ライブラリなどもあると思いますが、今回のようにシンプルなリサイズ用途であれば Canvas のみで十分可能と判断し実装してみました。

Canvas とは

Canvas API は JavaScript と HTML の <canvas> 要素によってグラフィックを描く方法を提供します。他にも、アニメーション、ゲームのグラフィック、データの可視化、写真加工、リアルタイム動画処理などに使用することができます。 https://developer.mozilla.org/ja/docs/Web/API/Canvas_API

つまり、グラフィックに関する様々なことができる Web API です。

サポートされているブラウザも96%以上とかなり多く、ほとんどの環境で使えると思います。

Can I Use Canvas https://caniuse.com/canvas

Canvas を使ったリサイズの実装

今回実装したリサイズ処理を、実装例を使いながら解説します。 (コード全体を見たい場合は「コード例」の章まで飛ばしてください)

なお、今回はコードをシンプルにするため幅 (width) だけを指定してリサイズするような処理にしています。

1. Context の取得

Canvas に描画するために必要な CanvasRenderingContext2D を取得します。

const context = document.createElement('canvas').getContext('2d')

ちなみに 2d の他に webgl, webgl2, bitmaprenderer といった値も指定できるようです。 (私は使用したことがないので、説明は省略します)

2. 画像サイズの取得

リサイズ後のサイズを計算するために、Image を使用して変換対象の画像のサイズを取得します。

const image: HTMLImageElement = await new Promise((resolve, reject) => {
  const image = new Image()
  image.addEventListener('load', () => resolve(image))
  image.addEventListener('error', reject)
  image.src = URL.createObjectURL(imageData)
})
const { naturalHeight: beforeHeight, naturalWidth: beforeWidth } = image
console.log("H%ixW%i", beforeHeight, beforeWidth) // => H800xW600

画像のロード後でしかサイズが取得できないので、コールバックを使いつつ Promise でラップするような感じにしています。

ちなみに new Image() で引数を指定しない場合は、naturalHeight, naturalWidth でも height, width でも同じ値になるようです。

CSS pixels are reflected through the properties HTMLImageElement.naturalWidth and HTMLImageElement.naturalHeight. If no size is specified in the constructor both pairs of properties have the same values.

https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image#usage_note

3. 変換後のサイズを計算

今回は幅 (width) のみを指定する方法にしているので、比率を保ちつつリサイズできる高さを計算して出します。

const afterWidth: number = width
const afterHeight: number = Math.floor(beforeHeight * (afterWidth / beforeWidth))

4. Canvas にリサイズ後のサイズで画像を描画

まず Canvas のサイズをリサイズ後の大きさにします。

context.canvas.width = afterWidth
context.canvas.height = afterHeight

そして、画像をキャンバス上に描画します。

context.drawImage(image, 0, 0, beforeWidth, beforeHeight, 0, 0, afterWidth, afterHeight)

引数を9個指定した場合は、以下のような内容になります。

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage

  • 元画像のデータ (image)
  • 元画像データの描画開始座標 (sx, sy)
  • 元画像のサイズ(sWidth, sHeight)
  • キャンバスへの描画開始座標 (dx, dy)
  • キャンバスへの描画サイズ (dWidth, dHeight)

という感じになります。 元画像全体を、キャンバスのサイズピッタリに描画するというような意味合いになります。 これが実質リサイズ処理になります。

5. Canvas の内容を JPEG で出力

最後に Canvas の内容をJPEGとして出力します。

const jpegData = await new Promise((resolve) => {
  context.canvas.toBlob(resolve, `image/jpeg`, 0.9)
})

こちらもコールバックしか使えないので、Promise でラップするような感じにしています。

ちなみに image/jpeg 以外にも image/pngimage/webp なども使えるようです。

コード例

これらのコードをまとめた関数の実装例を紹介します。 (ママリで実際に使っているコードと全く同じではないので悪しからず)

export const resizeImage = async (imageData: Blob, width: number): Promise<Blob | null> => {
  try {
    const context = document.createElement('canvas').getContext('2d')
    if (context == null) {
      return null
    }

    // 画像のサイズを取得
    const image: HTMLImageElement = await new Promise((resolve, reject) => {
      const image = new Image()
      image.addEventListener('load', () => resolve(image))
      image.addEventListener('error', reject)
      image.src = URL.createObjectURL(imageData)
    })
    const { naturalHeight: beforeHeight, naturalWidth: beforeWidth } = image

    // 変換後の高さと幅を算出
    const afterWidth: number = width
    const afterHeight: number = Math.floor(beforeHeight * (afterWidth / beforeWidth))

    // Canvas 上に描画
    context.canvas.width = afterWidth
    context.canvas.height = afterHeight
    context.drawImage(image, 0, 0, beforeWidth, beforeHeight, 0, 0, afterWidth, afterHeight)

    // JPEGデータにして返す
    return await new Promise((resolve) => {
      context.canvas.toBlob(resolve, `image/jpeg`, 0.9)
    })
  } catch (err) {
    console.error(err)
    return null
  }
}

サンプルページ

上記のコードを使って、簡単に試せるページを用意してみましたので、興味がある方はお試しください。

https://mryhryki.com/experiment/resize-on-canvas.html

preview

(猫画像はこちらのフリー素材を使用しました) https://pixabay.com/ja/photos/%e7%8c%ab-%e8%8a%b1-%e5%ad%90%e7%8c%ab-%e7%9f%b3-%e3%83%9a%e3%83%83%e3%83%88-2536662/

おわりに

ブラウザの機能だけをつかって、シンプルに画像のリサイズ処理を実装することができました。 実は、個人的に Skitch の代替として使っている Web App を作った経験が生きた感じで、割とすんなりと実装ができました。 なんでも色々やって見るものですね。

PR

コネヒトではエンジニアを募集しています!

hrmos.co