booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Reader StoreでInfrastructure as Codeしてみた

アイキャッチ

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のOS EOL対応の一環として行った、「Infrastructure as Code(以降IaC)」についてお伝えします。

IaC化検討の背景

  1. Elastic Beanstalk platforms based on Amazon Linux AMI (AL1)のEOL
    https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2022-07-18-linux-al1-retire.html

  2. Amazon Linux AMI (2018.03) のEOL
    https://aws.amazon.com/jp/amazon-linux-ami/faqs/

この2つのEOLを対応する上で最大の課題として、以下が存在していました。

  • 設計書含む過去資料がない
    手順書もないため、再構築すらできない

  • 構築担当した者が既に離任してしまっている
    既存で動いているものが正で誰も把握できていない状態になっていた

  • 手順書があるものの、荒く、環境再現できない
    そもそも手順が複雑であった

結果、一から新規構築するとなるため将来性を考え、これまで困難だった環境複製や同様のものを再構築できるようにこれを機にIaC化する運びとなりました。

IaCとは

IaC (Infrastructure as Code) は、手動のプロセスではなく、コードを使用してインフラストラクチャの管理とプロビジョニングを行うことを言います。

引用:https://www.redhat.com/ja/topics/automation/what-is-infrastructure-as-code-iac

簡単に説明すると、AWSなどのクラウド環境やOS・ミドルウェアの構築・管理・運用を私のようなインフラエンジニアが手作業で行なうのではなく、コードで管理することです。

IaCを何で行うか

まずは、IaC化を実現する上でツールを何にするかを検討しました。
今回のターゲットとしてはAWS周りがメインだったので主流としては以下があります。

  • CloudFormation
    AWSが提供している、プロビジョニングにおける自動化および構成管理のためのサービスです。
    JSONまたはYAML形式でリソースの設定を記述し、リソースの作成や設定の変更ができます。
    https://aws.amazon.com/jp/cloudformation/
  • CDK
    AWSが提供している、プログラミング言語(Python/Java等)を使用してアプリケーションをモデル化し、AWS CloudFormation を通じてプロビジョニングするツールです。
    https://aws.amazon.com/jp/cdk/
  • Terraform
    HashiCorp社によって提供されているオープンソースのツールです。
    クラウドのみならずSaaS/PaaS、オンプレミス(VMwareなど)との連携にも対応しています。
    https://www.terraform.io/

どれでも可能ではありましたが、対応期間も迫ってきていたこともあり、私が前職でも利用していたTerraformを採用しました。
なお、OS・ミドルウェアをコード化する主流なツールとしては、AnsibleやChefがあります。
Reader StoreではAnsibleでOS・ミドルウェアはコード化されていました。

IaC化までの流れ

今回私が対応した流れとしては以下となります。

コード管理

IaC化するにあたり必須になるコード管理は、アプリケーション開発で利用しているコード管理ツールにインフラ用のリポジトリーを準備しました。

設計

これが一番大変でした。
まず既存で動いているAWSリソースから次期構成図を作成しました。
そこから各種リソースで既存流用するもの、新規構築するものを選定していきました。
既存の命名規則が不明だったので、リソース命名規則も定義しました。
各種リソース作成のソースはできるだけ1モジュール1リソースにし、実際にリソース作成するモジュールは機能単位でソースをラッピングするような形をとりました。
環境単位で変わるものは環境変数を利用する方針にして、環境変数ファイルは環境単位で環境ファイルとして準備しました。
またAWSアカウントを本番と検証開発で分離するという案件も並行して実施することになったのでアカウント単位の環境変数もファイル化。
ディレクトリー構成も悩みましたが以下のような構成にしました。

├── <account>.tfvars          # <-- AWSアカウントの共通環境変数ファイル
├── <environment>             # <-- 環境ごとの機能リソース集格納
|      └─ <environment>.tfvars # <-- 環境個別の環境変数ファイル
|      └─ <function>           # <-- 機能毎のリソース作成用モジュール格納。モジュールはsourceをラッピング
└── <modules>                 # <-- リソース作成モジュールソース集格納
        └─ <source>             # <-- リソース作成モジュールソース格納
            └─ <prebuild>         # <-- 対象リソースで事前に作成しておく必要があるモジュールソース格納
            └─ <postbuild>        # <-- 対象リソースで事後に作成する必要があるモジュールソース格納

コーディング

Terraformコードをコーディングしていきます。
検証で事前構築したリソースがあればTerraformerを使って既存リソースからコードを書き出すことも可能でした。
環境変数化など手直しが必要になりますがベース作りには有効でした。

コードテスト

コードテストはterraform planを実施しコードに問題がないか、作成するリソースの予想結果を確認します。
今回はコードテスト+コード実行してテスト構築し意図したリソースが構築できるかの評価も実施しました。
1環境をベース環境として必要なコード全てテスト用に利用しReader Store全機能分のリソースをコード化していきました。

インフラ構築

ベース環境のテスト構築ができたので、環境単位でディレクトリーを準備し環境に応じた環境変数ファイルを準備しました。
あとは構築していく環境単位でterraform applyを順次実施していく形をとりました。
手作業でやると工数はそれなりにかかりますが、コード実行だけなので構築工数はだいぶ削減されました。

具体的な例でEC2構築を少しだけ紹介します。

functionコードサンプル

locals {
  configs = merge(var.acccount_configs, var.environment_configs)
}

module "batch_instance" {
  source = "../../../modules/components/ec2-single"

  configs = local.configs
  instance_configs = {
    service                       = "batch"
    instance_type                 = "t3.medium"
    root_block_device_volume_size = 30
    vpc_security_group_ids        = [local.configs.batch.security_group.id]
    private_ip                    = "${local.configs.batch.private_ip}"
  }
}

module "batch_postbuild" {
  source = "../../../modules/batch/postbuild"

  configs = local.configs
  batch_configs = {
    instance_role                  = module.batch_instance.instance_role
    instance                       = module.batch_instance.instance
    extra_block_device_volume_size = 100
    dynamodb_table_name  = "dynamo_table"
  }
}

moduleコードサンプル

#
#IAM Role
#
resource "aws_iam_role" "instance_role" {
  name               = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path               = "/"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "${var.configs.environment_name}-${var.instance_configs.service}-role"
  path = "/"
  role = "${var.configs.environment_name}-${var.instance_configs.service}-role"
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::${var.configs.aws_account_id}:policy/${var.configs.common.log_bucket_policy.name}"
}

resource "aws_iam_role_policy_attachment" "attach_ssm_managed_instance_policy" {
  role       = aws_iam_role.instance_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}


#
#EC2
#
resource "aws_instance" "instance" {
  ami                         = var.configs.ec2.plain_ami
  availability_zone           = "${var.configs.region}${var.configs.main_az}" # e.g. "ap-northeast-1a"
  ebs_optimized               = true
  instance_type               = var.instance_configs.instance_type
  monitoring                  = false
  key_name                    = var.configs.ec2.key_name
  subnet_id                   = var.configs.subnets["${var.configs.main_az}_private"].id
  vpc_security_group_ids      = var.instance_configs.vpc_security_group_ids
  associate_public_ip_address = false
  source_dest_check           = true
  iam_instance_profile        = aws_iam_instance_profile.instance_profile.name
  private_ip                  = var.instance_configs.private_ip


  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.instance_configs.root_block_device_volume_size
    delete_on_termination = true
  }

  tags = {
    "Name" = "${var.configs.environment_name}-${var.instance_configs.service}"
    "AutoStopStart" = "true"
  }
}

postbuildコードサンプル

#
# IAM Policyの追加
#

resource "aws_iam_policy" "batch_policy" {
  name        = "${var.configs.environment_name}-batch-policy"
  path        = "/"
  description = "for batch instances."
  policy      = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": ["arn:aws:s3:::codepipeline-${var.configs.region}-*/*",
                         "arn:aws:s3:::codepipeline-${var.configs.region}-*"]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.main_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}", 
                        "arn:aws:s3:::${var.configs.s3.srel_bucket_name}/*",

                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}", 
                        "arn:aws:s3:::${var.configs.s3.cms_bucket_pre_name}/*"

                        ]
        },
        {
            "Effect": "Allow",
            "Action": "dynamodb:GetItem",
            "Resource": "arn:aws:dynamodb:${var.configs.region}:${var.configs.aws_account_id}:table/${var.batch_configs.dynamodb_mediainfo_table_name}"
        },
        {
            "Sid":"",
            "Effect":"Allow",
            "Action":[
                "logs:PutLogEvents",
                "logs:CreateLogStream"
            ],
            "Resource":"arn:aws:logs:${var.configs.region}:${var.configs.aws_account_id}:log-group:${var.configs.environment_name}/batch:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "SQS:ChangeMessageVisibility",
                "SQS:DeleteMessage",
                "SQS:ReceiveMessage",
                "SQS:SendMessage"
            ],
            "Resource": "arn:aws:sqs:${var.configs.region}:${var.configs.aws_account_id}:voucher-customer-linking-${var.configs.environment_name}"
        }
    ]
}
POLICY
}


resource "aws_iam_role_policy_attachment" "attach" {
  role       = var.batch_configs.instance_role.name
  policy_arn = aws_iam_policy.batch_policy.arn
}


resource "aws_ebs_volume" "extra_volume" {
  availability_zone = "${var.configs.region}${var.configs.main_az}"
  size              = var.batch_configs.extra_block_device_volume_size
  type              = "gp3"
}

resource "aws_volume_attachment" "ebs_attach" {
  device_name = "/dev/xvdf"
  volume_id   = aws_ebs_volume.extra_volume.id
  instance_id = var.batch_configs.instance.id
}


#
# CodeBuild
#

# バイナリビルド用
resource "aws_codebuild_project" "batch_code_build" {
  artifacts {
    encryption_disabled    = false
    name                   = "${var.configs.environment_name}-batch-build"
    override_artifact_name = false
    packaging              = "NONE"
    type                   = "CODEPIPELINE"
  }

  badge_enabled = false
  build_timeout = 60

  cache {
    type = "NO_CACHE"
  }

  concurrent_build_limit = 1
  description            = "for build batch"
  encryption_key         = "arn:aws:kms:${var.configs.region}:${var.configs.aws_account_id}:alias/aws/s3"



  environment {
    compute_type = "BUILD_GENERAL1_MEDIUM"

    image                       = "${var.configs.aws_account_id}.dkr.ecr.${var.configs.region}.amazonaws.com/java-gradle:5.0"
    image_pull_credentials_type = "SERVICE_ROLE"
    privileged_mode             = false
    type                        = "LINUX_CONTAINER"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "CodeBuild"
      status      = "ENABLED"
      stream_name = "${var.configs.environment_name}-batch-build-log"
    }

    s3_logs {
      encryption_disabled = false
      status              = "DISABLED"
    }
  }

  name           = "${var.configs.environment_name}-batch-build"
  queued_timeout = 480
  service_role   = "arn:aws:iam::${var.configs.aws_account_id}:role/${var.configs.environment_name}-build-role"

  source {
    buildspec           = "container/batch/buildspec.yml"
    git_clone_depth     = 0
    insecure_ssl        = false
    report_build_status = false
    type                = "CODEPIPELINE"
  }
}

#
# CodeDeploy
#

resource "aws_codedeploy_app" "batch_deploy" {
  compute_platform = "Server"
  name             = "${var.configs.environment_name}-batch-deploy"
}


resource "aws_codedeploy_deployment_group" "batch_deploy_group" {
  app_name              = aws_codedeploy_app.batch_deploy.name
  deployment_group_name = "${var.configs.environment_name}-batch-deploy-group"
  service_role_arn      = var.configs.ci.code_deploy_role_arn

  ec2_tag_set {
    ec2_tag_filter {
      type  = "KEY_AND_VALUE"
      key   = "Name"
      value = "${var.configs.environment_name}-batch"
    }
  }

  trigger_configuration {
    trigger_events     = ["DeploymentFailure"]
    trigger_name       = "failure-notice-trigger"
    trigger_target_arn = var.configs.sns.notice_topic_arn
  }

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  alarm_configuration {
    alarms  = ["${var.configs.environment_name}-batch-deploy-alarm"]
    enabled = true
  }
}


#
# CodePipeline
#

data "template_file" "codebuild_environment_data" {
  template = file("${path.module}/templates/environment-variables.json")
  vars = {
    environment_short_name = var.configs.environment_short_name,
    environment_name       = var.configs.environment_name
  }
}


resource "aws_codepipeline" "batch_pipeline" {
  artifact_store {
    location = "codepipeline-${var.configs.region}-524718723338"
    type     = "S3"
  }

  name     = "${var.configs.environment_name}-batch-pipeline"
  role_arn = "arn:aws:iam::${var.configs.aws_account_id}:role/service-role/common-code-pipeline-role"

  stage {
    action {
      category = "Source"

      configuration = {
        BranchName           = var.configs.batch.branch_name
        ConnectionArn        = var.configs.ci.connection_arn
        FullRepositoryId     = var.configs.ci.full_repository_id
        OutputArtifactFormat = "CODE_ZIP"
      }

      name             = "Source"
      namespace        = "SourceVariables"
      output_artifacts = ["SourceArtifact"]
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Source"
  }

  stage {
    action {
      category = "Build"
      configuration = {
        EnvironmentVariables = data.template_file.codebuild_environment_data.rendered
        ProjectName          = aws_codebuild_project.batch_code_build.name
      }

      input_artifacts  = ["SourceArtifact"]
      name             = "Build"
      namespace        = "BuildVariables"
      output_artifacts = ["BuildArtifact"]
      owner            = "AWS"
      provider         = "CodeBuild"
      region           = var.configs.region
      run_order        = 1
      version          = 1
    }

    name = "Build"
  }

  stage {
    action {
      category = "Deploy"

      configuration = {
        ApplicationName     = aws_codedeploy_app.batch_deploy.name
        DeploymentGroupName = "${var.configs.environment_name}-batch-deploy-group"
      }

      input_artifacts = ["BuildArtifact"]
      name            = "Deploy"
      namespace       = "DeployVariables"
      owner           = "AWS"
      provider        = "CodeDeploy"
      region          = var.configs.region
      run_order       = 1
      version         = 1
    }

    name = "Deploy"
  }
}


構築コマンドは以下になります。

  • terraform init実行
    tfファイルを格納しているフォルダーを初期化させます。
    フォルダー内直下のtfファイルが読み込まれ、必要なpluginがインターネットからフォルダーにダウンロードされます。
Initializing modules...
- batch_instance in ../../../modules/components/ec2-single
- batch_postbuild in ../../../modules/batch/postbuild

Initializing the backend...

Initializing provider plugins...
(略)

Terraform has been successfully initialized!


  • terraform plan実行
    構築前にどのようなリソースが作成(変更)されるかを事前に確認できます。
    基本的にはmodulesにコーティングしてあるリソースしか表示されませんが、念の為確認しておきます。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

--- 

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run
"terraform apply" now.


  • terraform apply実行
    Enter a value:が表示され止まるので、yesを入力してEnterキーを押下すると、リソースの構築が開始されます。
    構築進捗状況が出力されるので数分ほど待ち、Apply complete!が出力されれば構築が完了しています。
    Apply complete!の後の文言は、terraform plan時の出力結果と一緒になります。
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create

Terraform will perform the following actions:

(略)

Plan: 5 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: <yes>

module.batch_instance.aws_iam_instance_profile.instance_profile: Creating...
module.batch_instance.aws_iam_role.instance_role: Creating...

(略)

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.


terraform apply中にインスタンスIDが表示されるので、AWSコンソールのEC2画面にてインスタンスIDでフィルターし、該当するインスタンスが表示され作成されているか確認します。
EC2

インフラテスト

テストの内容としては、コードから構築されたリソースがちゃんと作成できているか、リソース設定が環境変数で指定した内容になっているか、リソース間疎通確認などを実施しました。

アプリケーション実装評価

ここからは実際にアプリケーションを作成した環境に乗せてみて画面が確認できるかを確認しました。
CI/CDテストもここで実施しました。

システムテスト

このテストはAurora1からAurora3へアップグレードした際に、全機能テスト項目をまとめていたためそちらを実施しました。
またシステムリプレイスのため性能負荷試験も実施し問題がないかも確認しました。

直面した課題・反省点

Beanstalk EOLとOS EOLでモジュール配置のディレクトリー構成を変えてしまった

ディレクトリー構成を決めたもののその配下まで詳細に決めきれてなかったため、Beanstalk EOL時とOS EOL時でモジュール配置を安易に変えてしまいました。
そのため先行リリースしたBeanstalk用のモジュール群が動作しない状態に陥ってしまいました。
結果Beanstalk用とOS用でECSリソースモジュールは分離した状態になりました。

Terraformローカル実行前提だったのでMac M1(ARM)考慮できてなかった

Terraform運用まで検討しておらずローカル実行をするようにしていました。
当初ARM環境がなかったため参画してきたメンバーがTerraform実行環境を整備したときに発覚しました。
結果としてTerraform実行環境をAWS上に準備しTerraform実行はその環境で実行する運用としました。

tfstateを外部参照できるように考慮すればよかった

tfstate(Terraformがリソースの現在の状態を管理するファイル)も、コード管理する必要がありました。
terraform実行後にマージするしかなく、コード修正しながら構築可能な状態となってしまいました。
結果ブランチのままterraform実行することになってしまっていてプルリクエストが構築後になってしまいました。
今後の検討でtfstateはS3保存できるような形にする予定です。

モジュールは1つにつき1リソース作成するものにできなかった

設計でできるだけ1モジュール1リソースとしていたものの関連するリソースを含む形になってしまいました。
構築だけを考えれば現在のままでも問題はないですが、運用を考えると他リソースまで変更される可能性もあるため徹底すればよかったなということが反省点です。
運用に関しては今後対応していく中で少しずつ改善していく予定です。

まとめ

今回EOL対応におけるIaC化についてお話ししました。

本EOL対応は私が入社してまもない頃から今まで対応してきた内容にもなります。
他にもEOL対応の中で実施した内容はあるのでどこかでまたお話しできたらと思っています。

Flutter開発におけるアプリ内課金で注意すべき点

アイキャッチ

自己紹介

はじめまして。株式会社ブックリスタ プロダクト開発部の横山です。

現在はモバイルアプリエンジニアとしてコミックアプリ「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」の開発を行なっています。

コミックROLLYとは

2023年8月1日にリリースされたiOS/Android向けのコミックアプリです。
フルカラーの縦読みコミック「webtoon」をはじめ、電子コミックをスマートフォンやタブレットで読むことができます。

コミックROLLY

https://rolly.jp/

はじめに

ここではFlutter開発において、iOS/Androidのアプリ内課金に対応したことについて話します。
その中でも、特に必要だったAndroid独自の対応について注目して話します。

アプリ内課金(消耗型)のシステム構成

アプリ内課金は、1回購入する度に消費する消耗型の課金アイテムを使用します。
システム構成については、アプリ、ストア(AppStoreおよびGooglePlayストア)、独自サーバーのよくある構成を取ります。
独自サーバーでは、ストア購入後のレシート検証および、課金アイテムの対価を付与する処理を実施します。

AppleのStoreKitのドキュメントにあるシステム構成図は以下のとおりです。

システム構成図

https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase

in_app_purchaseパッケージの導入

FlutterでiOS/Androidのアプリ内課金を共通的に実装するために、in_app_purchaseパッケージを使用しました。

https://pub.dev/packages/in_app_purchase

以下は、上記ページに記載されているサンプルコードの抜粋です。

消耗型課金アイテムを購入する

課金アイテムに対して、購入処理を開始します。

final ProductDetails productDetails = ... // Saved earlier from queryProductDetails().
final PurchaseParam purchaseParam = PurchaseParam(productDetails: productDetails);
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam);

購入処理の更新をlistenする

上記の購入処理に対し、購入成功やキャンセル、エラーなどのステータスを受け取れるため、対応した処理を書くことができます。

Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
  for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
    if (purchaseDetails.status == PurchaseStatus.pending) {
      showPendingUI();
    } else {
      if (purchaseDetails.status == PurchaseStatus.error) {
        handleError(purchaseDetails.error!);
      } else if (purchaseDetails.status == PurchaseStatus.purchased ||
        purchaseDetails.status == PurchaseStatus.restored) {
        final bool valid = await _verifyPurchase(purchaseDetails);
        if (valid) {
          unawaited(deliverProduct(purchaseDetails));
        } else {
          _handleInvalidPurchase(purchaseDetails);
          return;
        }
      }
      if (Platform.isAndroid) {
        if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
          final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
          await androidAddition.consumePurchase(purchaseDetails);
        }
      }
      if (purchaseDetails.pendingCompletePurchase) {
        await _inAppPurchase.completePurchase(purchaseDetails);
      }
    }
  }
}

注意点

ストア購入成功後のサーバー処理の失敗を考慮したリトライ

課金アイテムの購入成功を受けて、独自サーバーでの課金アイテムの対価を付与することになりますが、付与が完了しなかった場合に備えてリトライできるような設計が必要です。

例えば、ネットワークエラーや、ユーザーがアプリを終了させてしまった場合などがあります。

ネットワークエラーであれば、その場でダイアログを表示してリトライを促すことができます。
しかし、アプリを終了した場合は、次回起動時にリトライできるような仕組みが必要です。

リトライ時に、保留状態になった課金アイテムは、別途取得できます。 但しiOSとAndroidとでは取得方法が異なることに注意です。

iOS

SKPaymentQueueWrapperを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_storekit パッケージが必要)

https://pub.dev/packages/in_app_purchase_storekit

final paymentQueueWrapper = SKPaymentQueueWrapper();
final transactions = await paymentQueueWrapper.transactions();
for (final transaction in transactions) {
    // 独自サーバーへの購入処理
    await deliverProduct(transaction);
    // 購入トランザクション終了
    await paymentQueueWrapper.finishTransaction(transaction);
}

Android

InAppPurchaseAndroidPlatformAdditionを使用して未処理の購入トランザクションを取得できます。(※in_app_purchase_android パッケージが必要)

https://pub.dev/packages/in_app_purchase_android

final androidAddition = InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
for (final purchaseDetails in response.pastPurchases) {
    if (purchaseDetails.pendingCompletePurchase) {
        // 独自サーバーへの購入処理
        await deliverProduct(purchaseDetails);
        // 購入トランザクション終了
        await InAppPurchase.instance.completePurchase(purchaseDetails);
    }
}

尚、購入処理の更新をlistenするコード において、独自サーバーでの購入処理が失敗して未完了だった場合を考えます。
その場合、そのまま_inAppPurchase.completePurchase()を呼ぶと、購入トランザクションが終了してしまい、上記のリトライもできなくなってしまいます。
そのため、独自サーバーでの購入処理が失敗した場合は呼ばないように制御する必要があります。

逆に、独自サーバーでの購入処理が成功した場合には、リトライ時にもきちんと購入トランザクションを終了しましょう。

更にAndroidで注意すべき点

Androidでは購入アイテムの消費を明示的にする

先程の 消耗型課金アイテムを購入するコードbuyConsumable()において、第2引数のautoConsumeがデフォルトtrueになっています。

Future<bool> buyConsumable({
  required PurchaseParam purchaseParam,
  bool autoConsume = true,
})

しかし、Androidでは先程の 購入処理の更新をlistenするコード でもあったとおり、以下のとおり明示的にconsumePurchase()を呼ぶ必要があります。

if (Platform.isAndroid) {
  if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
    final InAppPurchaseAndroidPlatformAddition androidAddition =
              _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
    await androidAddition.consumePurchase(purchaseDetails);
  }
}

そのため、buyConsumable()においては、autoConsumeを使用せずに、以下のとおりiOSとAndroidで分けましょう。

final bool _kAutoConsume = Platform.isIOS || true;
InAppPurchase.instance.buyConsumable(purchaseParam: purchaseParam,
 autoConsume: _kAutoConsume);

これをしないと、購入が成功した場合でも消費が正常に完了せずに、同じ課金アイテムを購入する際に「このアイテムはすでに所有しています」とエラーが出てしまう場合があります。

consumePurchaseでエラーが返る場合に備えてリトライ処理を追加する

前述のconsumePurchase()の対応を入れても、テスト中に端末によっては「このアイテムはすでに所有しています」とエラー表示されてしまう場合がありました。

それは、購入処理中に機内モードでオフラインにするなどの動作をした後、リトライのテストをした場合に限ります。

同じ条件において様々なOSバージョンで検証したところ、 Android10以下で発生し、Android11以上では発生しないという差がありました。

また、Android10以下でも、オンラインに復帰してからある程度時間が経っていた場合、もしくはDebugモードだった場合では発生しないこともありました。

Android10以下でエラーの詳細を確認したところ、リトライ時のconsumePurchase()において、SERVICE_UNAVAILABLE(エラーコード 2)が返っていることが分かりました。

デベロッパーサイトのリファレンスによると、 このエラーは一時的なもので、再試行で解決する類のエラーということが分かりました。

https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode

したがって、解決策として、 consumePurchase()が失敗した場合、非同期でconsumePurchase()のリトライを実施するようにした方がよいでしょう。

リトライ方法については、以下デベロッパーサイトのページを参考になります。

https://developer.android.com/google/play/billing/errors?hl=ja

さいごに

in_app_purchaseパッケージを活用して、iOS/Androidそれぞれのアプリ内課金を共通的に書くことができました。
しかし、それと同時にOS固有の処理が必要な部分もあり、その処理についての理解も必要だということも分かりました。
今回は消耗型の課金アイテムについて触れましたが、自動更新サブスクリプションであれば更に複雑度も増すことでしょう。
自動更新サブスクリプションの対応機会がありましたら、また注意点をシェアできればと考えています。
この記事が、これからFlutterにてアプリ内課金を対応される方の参考になれば幸いです。

ショートマンガ創作支援サービスに画像生成 AI(DALL·E 2)を導入した話

アイキャッチ

初めまして。

株式会社ブックリスタで、ショートマンガ創作支援サービス(YOMcoma)のフロントエンド開発を担当している前田と申します。

今回 YOMcoma に、DALL·E 2 を導入しましたので経緯も含めてお話させて頂きます。

最近はYOMComaコミック機能がリリースされました。

YOMcomaコミック機能とは、投稿したマンガを紙のコミックとして販売できる機能です。

アプリからご購入できますので、ぜひ一度見ていただけると嬉しいです。

目次

DALL·E 2 とは

今回導入させていただいた DALL·E 2 について、簡単に説明させていただきます。

昨今、様々な生成 AI が誕生していますが、その中でも注目度が高い画像を生成する AI です。

画像生成が注目され始めたのは Nvidia の研究者によって開発された StyleGAN が登場した 2019 年頃です。

そのクオリティの高さから、AI による画像生成が注目されていきました。

そんな中、2021 年 1 月、AI の研究組織である OpenAI が 2021 年 DALL·E を発表し、その後 2022 年 4 月に DALL·E 2 を発表しました。

テキストを入力するだけで、その内容に近い画像が作られることで大きな反響を呼びました。

そして 2022 年 11 月に DALL·E 2 の API が開発者向けに提供されました。

スクリーンショット (DALL·E 2 によって作成された様々なタッチの画像)

導入経緯

YOMcoma は、作品を読んだ際に閲覧者は「いいね」や「シェア」ができます。

その際、投稿者はお礼メッセージと合わせて画像も追加できます。

ただし、この画像は投稿者が YOMcoma のために作る必要がありました。

この画像を AI によって作成できたら、投稿者の負担が少し減るのではないかと思いました。

そこで、DALL·E 2 の導入を提案し実装させていただくことになりました。

DALL·E 2 の API について

DALL·E 2 の API には、下記のパラメータが用意されています。

curl https://api.openai.com/v1/images/generations \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "prompt": "A cute baby sea otter",
    "n": 2,
    "size": "1024x1024",
    "response_format": "b64_json",
    "user": "user123456"
  }'
名前 説明
prompt 生成したい画像のテキスト説明です。最大長は1000文字です。
n 生成する画像の数です。1から10の間で指定します。デフォルトは1です。
size 生成される画像のサイズです。256x256、512x512、または1024x1024のいずれかを指定します。デフォルトは1024x1024です。
response_format 生成された画像の返される形式です。urlまたはb64_jsonのいずれかを指定します。デフォルトはurlです。
user エンドユーザーを表す一意の識別子です。これにより、OpenAIが監視し、乱用を検出するのに役立ちます。

画像生成の API を利用するに辺り大きく 2 つの点に注意する必要があります。

  • prompt の記載は英語ではなくてはなりません。
  • 公序良俗に反する使い方をするとエラーになり、最悪アカウントを停止される可能性があります。

ただし、こういった注意点を投稿者が理解しながら作るにはハードルが高いと思いました。

なので、こちら側で公序良俗に反しない prompt のパターンを用意し、投稿者に選んでもらうようにしました。

下記がその選択をする UI になります。

そして「描いて欲しいイメージのキーワード」と「イメージのスタイル」を選択します。

最後に生成ボタン(🤖)のアイコンを押す事により、選択した画像を生成できます。

予め用意されたパターンを選ぶだけなので、英語で入力する必要もなくなります。

prompt について

prompt を送信する際に、対象物をどのように書かせるかを具体的に書くとよりイメージに近い画像を生成出来ます。

例えば、単純に「sky」と prompt を送信した場合、下記のようなリアルな絵になります。

これを例えば「sky, oil painting」と入力すると、油絵の空になりました。

ゲームのような空を描かせたい場合は「sky, digital art, cyberpunk」と入力するとゲームのワンシーンのような絵が作られました。

このように「対象物, 対象物の条件, 対象物の条件...」と描いていくとよりイメージに近い画像が作れますので、ぜひお試し下さい。

また他の画像生成 AI にも同じ prompt 「sky, digital art, cyberpunk」を送信し比較してみたので、ぜひ参考にして下さい。

Midjourney

Stable Diffusion

Novel AI

Adobe Firefly

ユーザーからの反響

画像生成を使ったユーザーから「今後、もし自分の絵柄を反映した画像が生成できるようになったら、非常に魅力的だと思いました」などのフィードバックが来ておりました。

今後も投稿者の反応やご意見をもとに、創作に役立てるような仕組み作りをしていきます。

まとめ

会社としてトレンドの AI をこうやって積極的に導入できた事は、非常にプラスだと思いました。

2022 年の末は ChatGPT が話題になり、2023 年の 2 月には ChatGPT Plus が登場し、今年は AI 元年になるとも言われています。

実は今回 DALL·E の話だけでしたが、その GPT 使用した機能も追加しております。

こちらも次回書ければと思っています。

YOMcoma は 0→1 で作っており、様々な機能を試しに導入し、ユーザーからのフィードバックを得て日々改善を繰り返しています。

今後ともご支援賜りますようお願い申し上げます。

以上、読んでいただきありがとうございました。

JavaのユニットテストにおけるDBテスト環境の改善

アイキャッチ

はじめまして(前回のブログを読んでくださった方は改めまして)。プロダクト開発部アプリケーションエンジニアの中村と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。
今回はReader Storeの開発中に行ったDB関連のユニットテストの改善の話をしていきます。

現状のテスト環境と問題点

Reader Storeではこれまでユニットテストで使用するDBを以下のように構築していました。

  1. MySQLのコンテナを起動
  2. DDLおよびテストデータのDMLが定義されたSQLファイルを読み込む

上記の処理をテスト起動時に行い、各テストでDMLに定義されたデータを使用してテストが行われておりました。
この場合、いずれかのテストでデータの追加・更新・削除を行なった場合に他のテストに影響を及ぼす可能性があるため、テスタビリティがよくないという問題がありました。

また、INSERTやUPDATEのテストを行う場合、結果の確認方法としてテストコード内でSELECT文を実行して取得した結果をAssertionしておりました。
それにより、テストコード内でテスト対象外の処理を実行しているという問題や単純にテストコードが肥大化してしまうという問題がありました。

上記の問題を解決するために、今回はDatabase Riderというライブラリを導入することにしました。

Database Rider

Database Riderとは

Database Riderは、DBUnitをよりJUnitのように記述できるようにしてデータベースのテストを簡単に書けるようにすることを目的としたライブラリになります。

公式リポジトリ:https://github.com/database-rider/database-rider

Javaでは元々DB Unitというデータベースのテストを行うためのフレームワークがあります。
データベースで使用するデータの定義をcsv, xls, xmlのいずれかで定義し、テストコード内でそのファイルを指定することでデータベースにテスト用のデータをロードできます。 また、DML実行後の結果となるデータをファイルで定義して、それを実際のデータベースのデータとAssertionするといったこともできます。
しかし、それをするためにはコードを複数行書く必要がありました。
Database Riderでは、データのロードや結果のAssertionをアノテーションを使用して1行で済ませることができ、より使いやすいものになりました。
また、ファイルのフォーマットについて、上記に加えてjsonやyamlなどモダンなフォーマットもサポートしています。

Database Riderの使い方

Database Riderの使い方について簡単に説明させていただきます。

※今回はSpringBoot + JUnit5を使用している前提となります。

依存関係の追加

使用しているMavenもしくはGradleの依存関係に以下を追加します。

testCompile "com.github.database-rider:rider-junit5:{バージョン}"

テストクラスの定義

Database Riderを使用してテストしたいクラスには@DBRider および @DBUnit を付与します。

@DBRider
@DBUnit
@SpringBootTest
public class SampleRepositoryTest {
...
}

テストメソッドの定義

Database Riderを使用してテストしたいメソッドには、テストの目的に応じてアノテーションを付与していきます。

SELECT系のテスト

SELECT系のクエリのテストでは、まずは検索したいデータをファイルで定義します。
例えばyaml形式で定義する場合には、以下のように定義します。

sample_table:
  - column_a: "aaa" ← 1レコード目
    column_b: "bbb"
  - column_a: "ccc" ← 2レコード目
    column_b: "ddd"

※他のテーブルも使いたい場合は下に同様に定義する

なお、データを定義したファイルは、xxx/test/resources/配下に配置する必要があります。(左記ディレクトリ配下にディレクトリを追加することは可)
例えば、以下のようなディレクトリ構成でテストごとのデータファイルを管理するなどです。
xxx/test/resources/datasets/{クラス名}/{メソッド名}/{テストケース}/init.yml

データ定義ファイルの用意が完了したら、テストメソッドでそのファイルをロードするための定義をします。
データをロードするためには@DataSetをメソッドに付与します。
@DataSetの引数には、データ定義ファイルのパスを指定します。(xxx/test/resources/までのパスは省略してそれより配下のパスを記述)

@Test
@DisplayName("データ検索テスト")
@DataSet({"/datasets/SampleRepository/getData/normal/init.yml"})
public void getData_normal() throws Exception {
  List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd"));
  List<Sample> actual = sampleRepository.findAll();
  assertEquals(expected, actual);
}

これにより、データベースに必要なデータをロードできます。
また、ロード時には対象のテーブルに元々入っていたデータはクリアされるため、別のテストのデータと混ざることはありません。

なお、@DataSetの引数で指定するファイルパスは配列になっています。
例えばテストで複数のテーブルを使用する際に、各テストにおいて同じデータで良いテーブルとテストごとにデータを変えたいテーブルがあったとします。
そのような場合は、共通のテーブルデータを定義したファイルとテストごとのテーブルデータを定義したファイルを分けて複数のファイルをロードするということもできます。

@Test
@DisplayName("データ検索テスト")
@DataSet({"/datasets/SampleRepository/getData/common_init.yml", "/datasets/SampleRepository/getData/normal/init.yml"})
public void getData_normal() throws Exception {
  List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd"));
  List<Sample> actual = sampleRepository.findAll();
  assertEquals(expected, actual);
}

INSERT系のテスト

INSERT系のクエリのテストでは、実行結果と一致するデータをファイルで定義します。
ファイルはSELECTと同じように用意します。

データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
INSERTでは実行結果のAssertionをするために@ExpectedDataSetを使用します。
@ExpectedDataSetの引数には、データ定義ファイルのパスを指定します。

テーブルにはよくレコードの追加日時や更新日時をカラムに持たせることがあります。
そのようなカラムはテスト実行ごとに値が変わるため、正しくAssertionできません。
そのような場合は、@ExpectedDataSetの引数にignoreColsでAssertionの対象外にしたいカラム名を指定します。

なお、別のテストのデータが残っていると正しくテスト結果の確認ができないため、@DataSetの引数にcleanBefore = trueを指定してテーブルのデータをクリアできます。
ただし、cleanBefore = trueでは全てのテーブルのデータがクリアされてしまうので、クリアしたくないテーブルがある場合はskipCleaningForでテーブル名を指定してください。

@Test
@DisplayName("データ登録テスト")
@DataSet(cleanBefore = true, skipCleaningFor = {"other_table"})
@ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", ignoreCols = {"insert_at", "update_at"})
public void registerData_normal() throws Exception {
  sampleRepository.insert(new Sample("aaa", "bbb"));
}

@ExpectedDataSetはデフォルトでは全レコードを完全一致でチェックするため、@DataSet(cleanBefore = true)で事前にデータをクリアするやり方を説明しました。
それとは別に、@ExpectedDataSetの引数にcompareOperation = CompareOperation.CONTAINSを指定する方法もあります。
この引数を指定することで、データをクリアせずとも必要なデータが存在することの確認だけテストできます。
このやり方であれば、他のテストでテーブルのデータがどうなっているのかを気にする必要はありません。(ただし、INSERTするデータの主キーが既存データと被らないように気をつけてください)

@Test
@DisplayName("データ登録テスト")
@ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", compareOperation = CompareOperation.CONTAINS)
public void registerData_normal() throws Exception {
  sampleRepository.insert(new Sample("aaa", "bbb"));
}

UPDATE系のテスト

UPDATE系のクエリのテストでは、更新対象のデータと実行結果のデータをそれぞれファイルで定義します。
ファイルはこれまでと同じように用意します。

データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
UPDATEではデータをロードするために@DataSetを使用し、実行結果のAssertionをするために@ExpectedDataSetを使用します。

@Test
@DisplayName("データ更新テスト")
@DataSet("/datasets/SampleService/updateData/normal/init.yml")
@ExpectedDataSet("/datasets/SampleService/updateData/normal/expected.yml")
public void updateData_normal() throws Exception {
  sampleRepository.update(new Sample("aaa", "ccc"));
}

導入時に気をつけたところ・ハマったところ

今回Database Riderを導入するにあたり、既存のテストを一度に置き換えるのは難しいため、まずは新しく実装するテストだけ使用するようにしました。
しかし、既存のテストとデータベースを共有してしまうと、Database Riderによってテーブルがクリアされることにより、既存のテストが動かなってしまいます。
それを回避するために、今回はDatabase Riderを使用するテストのためにデータベースを分けるという対応にしました。
具体的なやり方ですが、テストで使用するデータベースは上で記載したとおりMySQLのコンテナを実行時に立ち上げています。
コンテナで使用するポートはテスト用のconfigファイルで定義しているため、新たなconfigファイルを作成して別のポート番号を指定するようにしました。
そして、Database Riderを使用するテストでProfileを新しく作成したconfigを向くように指定することで、テストごとに使用するデータベースを変えることが実現できました。
それによって、既存のテストには影響を与えずに導入できました。

しかし、Profileを分けたことにより別の問題が発生しました。
新しく作ったテストとControllerのテストをまとめて実行(gradlew test)すると、エラーが起きるようになりました。
どうも使用しているMockライブラリが悪さをしているようなのですが、解決方法がわからなかったため、今回はテスト実行用のGradleタスクを作成し、パッケージごとに分けて実行できるようにしました。
Profileを分けただけでエラーになるとは思わず、新たなテストの課題が出来てしまったのは残念ですが、いずれ解決していきたいです。

まとめ

新たなライブラリを導入することで、データベースのテストをより書きやすくできました。
これでチームのテスト作成の効率化に繋がれば幸いです。
また、今後もテスト環境の改善を進めてテストを充実させて、サービスの品質も高めていきたいです。

今回ご説明した機能はDatabase Riderの基本的なものと一部のオプションになります。
他にも様々な機能がありますので、Database Riderを使ってみたいという方はご自身のテスト環境に合った機能を見つけてみてください。
この記事の内容が、Javaのテストを作成する方の何かしらの助けになれば幸いです。

Github Copilot を使ってみた感想

アイキャッチ

はじめまして。株式会社ブックリスタ プロダクト開発部でエンジニアをしている姚と申します。

私は一年前から個人で Github Copilot を使っていて、最近会社でも GitHub Copilot for Business を試験導入されました。
今回は、Github Copilot の概要と、会社での導入状況、先行利用者が使ってみた感想や利用時の注意点などを紹介します。

Github Copilot とは

Github Copilot は、AI によるコード補完機能を提供するサービスです。
コメントからコードへの変換、コードブロック、重複コード、メソッドや関数全体の自動補完など、プログラマーを支援する機能が含まれています。

会社での導入状態

ブックリスタでは、AI を積極的に活用し、エンタメテックを目指しています。
先月から少人数で GitHub Copilot for Business を試験導入して、先行利用者から好評を得ていますので、今後は利用範囲を拡大していく予定です。

使ってみた感想

良いところ

  • 生産性が向上する
    • 利用したことがない言語やフレームワークでも実装が容易になる
    • ほぼ修正が不要なレベルの、簡単でよくある実装が出てくる
    • テスト観点を書けば、テストコードを生成してくれる
    • IDE より高度な補完が効く
      • 変数の命名すると型を提案してくれる
      • 関数の入出力パターンを他と合わせてくれる
      • コードを書いている最中の文脈に合わせて、コードを補完してくれる
      • プロジェクト全体のコードを分析して、コードの一貫性を保つようにサジェストしてくれる
  • 開発体験が良くなった
    • 基本サジェストを採用し、サジェスト内容を少し修正する程度で済む
    • もう一人の開発者のような存在で、ペアプログラミングのような開発体験ができる

使える事柄

メソッドの自動補完

メソッドや関数の名前を入力すると、そのメソッドや関数の中身を自動補完してくれます。

灰色の文字は、GitHub Copilot が自動補完した部分です。

コードブロックの自動補完

テーブル定義やテストケースをコメントとして貼り付けたら、コードブロックの中身を自動補完してくれます。

複数のサジェストを提案してくれます

デフォルトのサジェストが気に入らない場合は、 GitHub Copilot を開く (別のペインに追加の候補) の機能があり、複数のサジェストを提案してくれます。


悪いところ

  • 言語によって向いている向いていないがある
    • 型がある言語の提案は良いけど、型がない言語の提案はイマイチ
    • HTML/CSS だと指定したデザインの自動生成は難しい
  • 更新が早いライブラリだと古い実装を提案される
  • 提案されたコードが良いコードである保証はない
    • 特に汚いコードの中で使おうとすると提案も悪くなる
      Copilot は既存コードと一貫性のあるコードをサジェストしてくれるため、既存のコードが汚いと、良いコードをサジェストしてくれない
  • インライン候補は行の末尾にしか表示されないため、行の途中でサジェストさせることができない


バグや脆弱性があるコードが含まれる可能性がある

例えばうるう年の判定は例外として、西暦年号が 100 で割り切れて 400 で割り切れない年は平年としていますが、Github Copilot が生成したコードにこの判定が含まれていませんでした。


利用時の注意点

  • 良いコードをサジェストさせるには、コードの前に具体的な処理内容のコメントを書いたり、関数名や変数名に適切な名前を付ける必要がある
  • Copilot から提案されたコードは、必ずしも良いコードではないので、開発者がコードを読んで理解した上で、正しいかどうかを判断する
  • Copilot は重複コードを生成することがあるので、DRY 原則を意識する必要がある



まとめ

Github Copilot を使うことで、開発体験が良くなり、生産性が向上すると感じました。
今後も、Github Copilot を使い続けていきます。

今後の展望

Github Copilot は既存コードのリファクタリングやバグの修正などにまだ不足点がありますが、それらは Github Copilot Labs で改善されることを期待しています。

Next.jsのブラウザーエラーログを収集するためのNew Relic導入方法

アイキャッチ

株式会社ブックリスタ プロダクト開発部エンジニアの土屋です。 現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」におけるフロントエンド領域の刷新に取り組んでいます。 今回、表題の通りブラウザーエラーログ収集を目的とし、Next.jsにNew Relicを導入したため、その経緯と導入方法についてまとめました。

New Relicについて

公式サイトから引用。

New Relicは、モバイルやブラウザのエンドユーザーモニタリングや、外形監視、バックエンドのアプリケーションとインフラモニタリングなど、オンプレやクラウド、コンテナからサーバレスまであらゆるシステム環境での性能管理を実現するプラットフォームです。

New Relicは上記の通りアプリケーション監視の多岐にわたる機能を備えているサービスです。 Reader Storeではサーバーのトラフィックや性能監視のために以前から利用しています。

導入経緯

現在、Reader Storeではレガシーなフロントエンド環境(PHP/Java+JQuery)からNext.jsへ置き換えを進めています。その中でブラウザーのエラーログを収集したいという要件がありました。 ブラウザーエラーを収集することで、不具合の早期発見や、特定のOS/ブラウザーでのみ発生するような不具合の解析に役立ちます。 以上の理由から今回、ブラウザーエラー収集の機能を持つNew Relicブラウザーモニタリングを導入しました。 Datadog, Sentry等ブラウザーエラーの収集ができるサービスは他にもいくつかあります。New RelicはAPIサーバー等の監視で既に導入済みのため、管理を一元化できることから選択しています。

導入方法

New Relicブラウザーモニタリングの導入方法は、コピー&ペースト方式とAPMエージェントを利用する方式があります。
https://newrelic.com/jp/blog/how-to-relic/what-is-the-difference-between-apm-agent-and-copy-paste

  • コピー&ペースト方式

    名前の通りNew Relicが提供するjsコードスニペットをそのままコピー&ペーストする方式です。

  • APMエージェント利用方式

    サーバーサイドでAPMエージェントを利用するためのスクリプトを生成し、それをhtmlに埋め込みます。APMエージェントの接続先を環境変数等で指定できるメリットがあります。

コピー&ペースト方式はデプロイする環境ごとにコードスニペットを用意しなければならず、管理が煩雑になるため、APMエージェントを利用する方式を選びました。 すこし前までは、APMエージェントを利用する方式だとSSGで生成したページに適用できず、静的ページには別途コピー&ペースト方式で導入する必要がありましたが、 v9.8.0からSSGにも対応しました。 そのため初期導入のしやすさを除いてコードスニペット埋め込み方式を選ぶメリットは無くなっています。 https://docs.newrelic.com/jp/docs/release-notes/agent-release-notes/nodejs-release-notes/node-agent-9-8-0/

この記事ではnode.js向けパッケージ(newrelic)を利用してAPMエージェントをインストールします。 @newrelic/nextというパッケージがありますが、こちらはNext.jsサーバーのパフォーマンスを監視するものになり、この記事では触れません。

前提条件

  • New Relicのアカウントが作成してある
  • newrelicパッケージのv9.12時点での導入方法

導入手順

  1. New Relicでブラウザーモニタリングの機能を有効にします

    https://docs.newrelic.com/jp/docs/browser/browser-monitoring/installation/install-browser-monitoring-agent/

  2. Next.jsプロジェクトにnewrelicパッケージをインストール

     npm i newrelic
    
  3. _document.tsxでnewrelicのスクリプトを埋め込む

    _document.tsxは主に共通のメタタグを埋め込むために利用します。以下はnewrelicのスクリプトを埋め込むサンプルです。

     const newrelic = require("newrelic");
    
     type ExtendedDocumentProps = DocumentInitialProps & {
       browserTimingHeader: string;
     };
    
     class MyDocument extends Document<ExtendedDocumentProps> {
       static getInitialProps = async (
         ctx: DocumentContext
       ): Promise<ExtendedDocumentProps> => {
         const initialProps = await Document.getInitialProps(ctx);
    
         // see https://github.com/newrelic/newrelic-node-nextjs#client-side-instrumentation
         if (!newrelic.agent.collector.isConnected()) {
           await new Promise((resolve) => {
             newrelic.agent.on("connected", resolve);
           });
         }
    
         const browserTimingHeader = newrelic.getBrowserTimingHeader({
           hasToRemoveScriptWrapper: true,
           allowTransactionlessInjection: true,
         });
    
         return {
           ...initialProps,
           browserTimingHeader,
         };
       };
    
       render = () => {
         const { browserTimingHeader } = this.props;
    
         return (
           <Html lang="ja">
             <Head>
               <script
                 type="text/javascript"
                 dangerouslySetInnerHTML={{ __html: browserTimingHeader }}
               />
             </Head>
             <body>
               <Main />
               <NextScript />
             </body>
           </Html>
         );
       };
     }
    
     export default MyDocument;
    

    以下の部分でAPMエージェントを読み込むためのスクリプトを取得しています。

     const browserTimingHeader = newrelic.getBrowserTimingHeader({
       hasToRemoveScriptWrapper: true,
       allowTransactionlessInjection: true,
     });
    
    • hasToRemoveScriptWrapperをtrueにすることで、scriptタグを除いた状態で取得できるのでhead内のscriptタグの中に埋め込む
    • allowTransactionlessInjectionはv9.8.0から追加された新しいオプションで、これをtrueにすることでSSGのタイミングでもスクリプトが生成される

    allowTransactionlessInjectionオプションを有効にする場合はnewrelic.getBrowserTimingHeaderを呼び出す前に、以下のようにNew Relicエージェントとnode.jsのコネクションが確立するまで待つ必要があります。

     if (!newrelic.agent.collector.isConnected()) {
       await new Promise((resolve) => {
         newrelic.agent.on("connected", resolve);
       });
     }
    
  4. 環境変数設定

    newrelicに必要な環境変数を.envに定義します。

    アプリ名は1の手順で有効にしたアプリの名前、ライセンスキーはこちらのライセンスキーになります。

     NEW_RELIC_APP_NAME={アプリ名}
     NEW_RELIC_LICENSE_KEY={ライセンスキー}
    

以上でAPMエージェントが利用できるようになり、コアウェブバイタルなどの測定が可能になりました。

しかし、これだけではjsエラーログを収集するには不十分です。実際にブラウザー側に配信されるjsはバンドルされたものであるため、トレースログを表示するにはsourcemapをNew Relicに連携する必要があります。

sourcemapの連携

Next.jsの場合、next buildを行うと、通常.next/static/chunks配下にsourcemapが生成されるため、それをNew Relicにアップロードします。

以下がNew Relicにsourcemapをアップロードする際に利用しているスクリプトのサンプルです。

const fs = require("fs");
const path = require("path");

const {
  publishSourcemap,
  deleteSourcemap,
  listSourcemaps,
} = require("@newrelic/publish-sourcemap");
const recursive = require("recursive-readdir");

// 以下に記載のブラウザーアプリIDを設定
// https://docs.newrelic.com/jp/docs/apis/rest-api-v2/get-started/get-app-other-ids-new-relic-one/
const NR_APP_ID = /* ブラウザーアプリID */;

// 以下に記載のブラウザーキーを設定
// https://docs.newrelic.com/jp/docs/apis/intro-apis/new-relic-api-keys/
const NR_API_KEY = /* APIキー */;

// srcmapの配置ディレクトリー
const SRC_MAP_DIR = "path/to/.next/static/chunks";
// jsファイルを配信する際のベースURL
const STATIC_FILE_BASE_URL = "https://yourdomain/_next/static/chunks";

const SRC_MAP_CHUNK_SIZE = 50;

// newrelic上のソースマップを全件取得
const listSrcMap = (list = [], offset = 0) => {
  return new Promise((resolve, reject) => {
    listSourcemaps(
      {
        applicationId: NR_APP_ID,
        apiKey: NR_API_KEY,
        limit: SRC_MAP_CHUNK_SIZE,
        offset: offset * SRC_MAP_CHUNK_SIZE,
      },
      (err, res) => {
        if (err) return reject(err);

        const allMaps = [...list, ...res.sourcemaps];

        // 取得した件数がlimit未満なら抜ける
        if (res.sourcemaps.length < SRC_MAP_CHUNK_SIZE) {
          return resolve(allMaps);
        }

        // limitと同値ならこのメソッドを再起的に呼びだす
        listSrcMap(allMaps, offset + 1)
          .then((list) => resolve(list))
          .catch((e) => reject(e));
      }
    );
  });
};

// newrelic上のソースマップ削除
const deleteSrcMap = (srcMaps) => {
  return Promise.all(
    srcMaps.map((srcMap) => {
      return new Promise((resolve, reject) => {
        deleteSourcemap(
          {
            sourcemapId: srcMap.id,
            applicationId: NR_APP_ID,
            apiKey: NR_API_KEY,
          },
          (err, res) => {
            console.log(err || `Sourcemap ${srcMap.javascriptUrl} deleted.`);
            if (err) return reject(err);
            resolve();
          }
        );
      });
    })
  );
};

const IGNORE_FILES = ["*.js"];
// newrelic上にソースマップ登録
const uploadSrcMap = () => {
  recursive(SRC_MAP_DIR, IGNORE_FILES, (err, files) => {
    // mapファイル以外のものは無視
    const mapFiles = files.filter((file) => file.endsWith(".js.map"));

    mapFiles.forEach((file) => {
      // scriptタグのsrc属性に設定されるjsリソースパス
      const jsFileSrcUrl = `${STATIC_FILE_BASE_URL}/${path.relative(
        SRC_MAP_DIR,
        file
      )}`.slice(0, -4);

      publishSourcemap(
        {
          sourcemapPath: file,
          javascriptUrl: jsFileSrcUrl,
          applicationId: NR_APP_ID,
          apiKey: NR_API_KEY,
        },
        (err) => {
          console.log(err || `Sourcemap ${jsFileSrcUrl} upload done.`);

          // mapファイルは公開しないため削除
          fs.unlinkSync(file);
        }
      );
    });
  });
};

listSrcMap().then(deleteSrcMap).then(uploadSrcMap);
  • @newrelic/publish-sourcemapパッケージを利用し、sourcemapをアップロードしている
  • アップロード最大容量の制限(50MB)があるため、事前に古いsourcemapを削除している

Reader StoreではAWSのcodebuildを使ってNext.jsアプリのビルドを行う際、上記スクリプトを実行してsourcemapを更新するようにしています。 アップロード手段についてはcurlでapiエンドポイントを直接叩いたりnpmスクリプトを利用する方法が提供されているため、運用に合わせてどうアップロードするかを選んでください。
https://docs.newrelic.com/jp/docs/browser/new-relic-browser/browser-pro-features/upload-source-maps-api/

以上でNew Relicのブラウザーエラー収集の準備は完了です。 ブラウザーでエラーが発生した際は、New Relic管理画面 > Browser > JS Errorsにエラーが表示され、Error Instancesタブから以下のように発生箇所がトレースできるようになります。

トレースログ

苦労したところ

  • 必要のない大量のエラーを拾ってしまう

    本番環境で運用したところ、Google Tag Managerで読み込んでいるサードパーティjsなどで大量のエラーが発生しており、Next.jsアプリのエラーを見つけにくい状態になっていました。

    そのためNext.jsアプリで発生したエラーのみ一覧できるよう、以下のnrqlを作成し外部スクリプト起因のエラーをフィルターした状態のダッシュボードを作成しました。

    ダッシュボードであれば無料のアカウントでも参照できるため、まずはこのダッシュボードでエラーが発生していないか確認する運用をしています。

      SELECT
          * 
      FROM
          JavaScriptError 
      WHERE 
          errorMessage != 'Script error.' 
          AND 
          stackTrace != ''
      SINCE 7 day ago
    
    • ErrorBoundaryでエラーをキャッチし、newrelicのnoticeError APIを使ってエラーを送付することでアプリのエラーだけ拾う方法も考えられます。ErrorBoundaryで拾えるのはレンダリング時のエラーだけでイベントハンドラのエラーは拾えないという問題がありReader Storeでは採用しませんでした
  • エラーを拾えているのかわからない

    現在Reader StoreでNext.jsを利用しているページはごく僅かで、開発規模もそこまで大きくないため、現状プロダクトコード起因のエラーが発生していません。それによりエラーが発生していないのか拾えていないのかわからない状態になってしまいました。 対策として意図的にエラーを発生させるhooksを作成し、エラーが拾えるか各環境で確認しています。

      // 特定のコマンドを入力した際にエラーを発生させるhookの例
      const COMMAND = [
        "ArrowUp",
        "ArrowUp",
        "ArrowDown",
        "ArrowDown",
        "ArrowLeft",
        "ArrowRight",
        "ArrowLeft",
        "ArrowRight",
        "a",
        "b",
      ];
      export const useTestError = () => {
        useEffect(() => {
          let typedKeys: string[] = [];
          const clear = debounce(() => {
            typedKeys = [];
          }, 10000);
          const handler = (e: KeyboardEvent) => {
            typedKeys.push(e.key);
            if (
              JSON.stringify(typedKeys.slice(-COMMAND.length)) ===
              JSON.stringify(COMMAND)
            ) {
              typedKeys = [];
              throw new Error("TEST ERROR!!");
            }
            clear();
          };
          window.addEventListener("keyup", handler);
          return () => {
            window.removeEventListener("keyup", handler);
          };
        }, []);
      };
    

今後やっていきたいこと

  • 現状はダッシュボードを日々確認する運用だが、有意にエラーの数が増えたらslackに通知するような仕組みも入れて、不具合の早期発見に役立てていきたい
  • 今回はほとんど触れてないがNew RelicはAPMとしての機能が豊富なため、アプリのパフォーマンス測定と改善にも取り組んでいきたい

Datadog Synthetics Testsによる外形監視の自動化について

アイキャッチ

こんにちは。プロダクト開発部QAエンジニアの岡と申します。
弊社は「auブックパス (運営:KDDI株式会社)」と包括的なパートナーシップを結び、開発を行なっています。
こちらのサイトの監視に、Datadog Synthetics Tests(ブラウザーテスト)を導入して自動化したことについてお伝えします。

サイトの監視を自動化した結果、障害の検知と解決を迅速に行うことができるようになりました。

この記事で伝えたいこと

Datadog Synthetics Tests 導入理由

auブックパスは24時間365日営業しているため、ユーザーが本を購入できない、または読めないといった障害が発生することは許容できません。これまでサイトの監視を外部に委託していましたが、今後は障害の検知と解決を迅速に行うため、サイトの監視を内製化することにしました。また、内製化によるコスト削減も狙いのひとつです。システム構築コストも抑えるため、サイトの監視を簡単に実現できる有償ツールであるDatadog Synthetics Tests(ブラウザーテスト)を採用しました。

Datadog Synthetics Tests の概要

Datadog 公式URL

公式引用

Synthetic ブラウザテストを使用して、エンドツーエンドのテストで世界中の Web ページを顧客がどのように体験しているかを監視します。

Datadog Synthetics Testsには、公式サイトで紹介されている通り、ブラウザーテスト機能があります。レコーディング機能を使ってブラウザーの操作を記録し、ノーコードでブラウザーテストを作成できます。また、外形監視として障害の検知やサイトのレスポンスタイムの確認などを簡単に行うことができます。

ブラウザーテスト作成手順

まず、ブラウザーテスト作成手順について、「考慮したポイント」を交えて紹介します。

1. ブラウザーテストの作成開始

Datadogにアクセスし、ブラウザーテストを作成するためのダッシュボードに移動します。
Ux Monitoring > Synthetic Testsを選択し、画面右上の+New Testsをクリックして Browser Testをクリックします。

2. 設定

Set your test details

以下を設定します。

  • Starting URL:テスト対象のURL
  • Name:テスト名
  • Envrionment:環境(任意)
  • Additional Tags:タグ(任意)
    • 分類に使用
  • Advanced Options(任意)
    • Cookie設定、Proxyの設定、Basic認証などの設定が可能

Browsers & Devices

実行クライアント環境は「Laptop Large」「Tablet」「Mobile Small」「Chrome」「Firefox」「Edge」の組み合わせが可能です。

Select locations

アクセス元の設定をします。

  • 設定例:Tokyo (AWS)を選択

Define retry conditions

テスト失敗時の待機時間と再実行回数を設定します。

  • 設定例:テスト失敗後10000ミリ秒待機し2回リトライ

Define scheduling and alert conditions

テスト実行間隔の設定をします。

  • 設定例:テストを5分間隔で実行
  • Advancedでは詳細な時間設定が可能

アラートの閾値の設定をします。

  • 設定例:過去8分間に1か所でエラーが発生した場合、テストはアラートステータスになり通知が送信される

(ポイント) 実行コストとのバランスを考慮して、監視対象の重要度に応じて間隔を設定しました。参考:Datadog料金 (15ドル / 1,000テスト)

Configure the monitor for this test

障害検知時の通知方法を設定します。
通知方法としては、メール、Slack、PagerDuty、Webhookなど、豊富な選択肢があります。
※ここは通知作成方法で詳しく触れます。

設定が完了したらSave & Edit Recordingをクリックしてレコーディングに進みます。

4. レコーディングの開始

レコーディング開始前にChrome拡張機能「Datadog test recorder」をインストールする必要があります。

レコーディング画面では、 ノーコードで簡単にブラウザーテストが作成できます。
Start Recordingでレコーディングを開始します。右側に表示されたブラウザーでの操作が、左側に記録されていきます。

(ポイント) テストの対象は、外部連携している箇所など障害が発生しやすい箇所に絞って監視しています。 監視対象が増えると誤検知が増え、不要なアラートが上がってしまい、テストの実行コストも増えてしまいます。

(ポイント) 障害の発生頻度が高い箇所を中心にしてテストシナリオのステップ数を最小限に抑えました。
テストシナリオのステップ数が多いと、本来検出したい障害ではない箇所でテストが失敗して原因の特定が困難になります。そのため、障害の発生頻度が高い箇所にアサーション(検証)を配置し、ステップ数を絞りました。

5. 自動記録された内容を確認して調整

以下を必要に応じて調整します。

  • Step Name: シナリオのステップ名
  • Target Element: 操作対象
  • Click type: 操作対象のクリック方法
  • Advanced Options > User Specified Locator: ユーザー指定のロケーターが使用可能
    • 例えば、idやテキストを起点として要素を指定するためにXPathを使用するなど、柔軟な対応が可能
  • Wait for 60s before declaring this step as failed: 検証実行前にwait(待機ステップ)を挿入
  • Continue with test if this step fails: このステップが失敗したときにテストを続行するか選択

自動記録以外にも手動でアサーション(検証)ステップを追加できます。

(ポイント) テストステップ名を編集し、各ステップが何をテストしているのかを分かりやすくすることで、障害の発生箇所の特定が容易になります。

(ポイント) テストシナリオに分岐機能がないため、Continue with test if this step failsの設定を使用して分岐を実現しました。 例えば、特定の要素が表示された場合にのみアサーションを実施したい場合があります。シナリオステップのContinue with test if this step failsにチェックを入れることで、要素がある場合のみアサーションを実施するように設定することで分岐を実現しました。

(ポイント) 部分的にユーザー指定のロケーターを使用しました。
auブックパスのフロントエンドではReactを使用しています。Reactを使用する場合、デフォルトでは要素にIDが付与されないため、テキスト表示のない要素を特定したり、階層が変更された場合のテストメンテナンスが困難です。この課題に対処するため、開発者に対してテスト対象の要素にIDを付与するように依頼しました。付与されたIDを使用して、Advanced Options > User Specified Locatorでカスタムロケーターを指定しました。これにより、画面変更の際にもテストメンテナンスが容易になりました。

6. カスタムスクリプトの作成

✔︎Assertion > 下から2番目のTest custom JavaScript assertionを選択すると、JavaScriptの記述が可能になります。
クリックを繰り返す、サイト内に表示された値を変数に格納し後で検証に使用するなどの処理をJavaScriptで記述することにより、テストが拡張できます。
JavaScriptの記述以外にも、メールの受信テスト、DLファイルのテスト、サブテスト機能など機能が豊富です。

7. テストシナリオの実行

Run Test Nowで作成したシナリオを実行します。
テスト実行結果はダッシュボードで確認できます。結果をクリックして詳細を確認します。

  • Faild(失敗)の場合は作成したテストシナリオに問題が無いか、内容を見直す
  • 実行時の画面キャプチャーがあり、レスポンスタイムの確認が可能なため、テスト失敗の原因を特定できる

上記の場合、「モーダル画面が表示されてしまい要素のクリックができず、次の画面に遷移できなかった」がテスト失敗の原因です。そのためモーダル画面を閉じる処理を追加するとテストが成功します。

8. テストの修正

さきほど実行したテストが失敗しているので、「モーダル画面が表示された場合にクリック」というステップを追加します。

テストが成功したので、ブラウザーテストは完成です。

通知作成方法

つぎに、作成したブラウザーテストが障害を検知したときに通知する方法を紹介します。
電話通知設定と、Slack通知設定の2つを実施します。

電話通知設定

以前、弊社のテックブログ Amazon Connectを使った障害発生時の自動オンコール実現について でご紹介した方法を実施します。

使用する要素は以下の4つです。

  • Datadog
  • AWS SNS
  • AWS Lambda
  • Amazon Connect

1. 初期設定

電話番号を取得するための申請などを行います。 参照:初期設定について

2. 架電設定

参照:架電用のAmazon Connectの問い合わせフロー(SNS, Datadog設定, Lambda)について

3. Datadog側の設定

2の最後ではモニターに対して設定する例がありますが、シナリオに設定もできます。
今回は、シナリオテストの失敗(Faild)を検知して電話の着信で知らせるため、以下のように設定をします。

  • ブラウザテストの設定 > Configure the monitor for this test > Notify your team

4.調整

障害検知の条件を調整します。
Datadog Monitors機能とalert conditionsを設定し、リトライでテストが成功した場合は即時復旧したとみなし、障害とはみなさないように調整しました。
これにより、不要な電話通知を防ぎつつ、実際の障害を正確に検知できています。

Slack通知設定

1. Slack側の設定

Datadog公式参照

2. Datadog側の設定

こちらも、シナリオテストの失敗(Faild)を検知してSlackに通知するため、以下のように設定をします。

  • ブラウザテストの設定 > Configure the monitor for this test > Notify your team

定期実行を開始

テストシナリオの「Resume Scheduling」で定期実行を開始します。 障害発生時にSlackと電話に通知されます。

運用手順

ブラウザーテストの作成と、障害の検知したときの通知方法の設定が完了したので、さいごに運用手順を紹介します。

1. 障害の発生

障害が発生すると、電話による通知があり、同時にSlackチャンネルにも通知が行われます。

2. 障害の特定

Slackのメッセージリンクからテスト失敗箇所へアクセスし、障害発生時のスクリーンショットやテストステップを確認できます。
テストシナリオ数とステップ数を厳選し、テストステップ名の編集により、各ステップが何をテストしているのかを分かりやすくすることにより、障害の発生箇所を迅速に特定できるようになりました。

3. 障害対応、復旧

障害の発生箇所が特定されたら、それに基づいて障害対応が行われます。 復旧した場合にも電話での通知とSlackへの通知が行われるように設定できます。(障害が発生している間もテストのスケジュール実行は継続されるため、復旧も即時検知されます)

4. 分析

電話通知やSlack通知により、障害発生から解消までの経過や復旧までの期間などをダッシュボードで確認できます。
これにより、障害の計測や原因分析が可能となり、障害の傾向やパフォーマンスの改善点を特定し、将来の問題を予防するための情報を得ることもできます。

さいごに

Datadog Synthetics Tests(ブラウザーテスト)を導入し、活用することで、障害の検知と解決を迅速に行うことができるようになりました。 弊社ではSynthetics Testに限らず、様々な方法でサイトの品質を担保しています。
お客様が快適にサイトをご利用いただけるよう、品質の向上を目指します。