booklista tech blog

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

新規プロジェクトをスクラム開発でローンチまで完了しておもうこと

アイキャッチ

株式会社ブックリスタ プロダクト開発部の藤井です。 現在、2023年8月1日から始まった「コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」というサービスのスクラムマスターをしております。

今回は新サービスプロジェクトを立ち上げるにあたって、スクラム開発を採用した話をさせていただきます。

近年ソフトウェア開発の現場でスクラムを採用するケースをよく聞きます。

これから採用する方・採用してるけど暗中模索してる方の参考になればと考えています。

あと、このやり方は専門のひとにはスクラムじゃないと言われるものではあるかなと自覚もしているので、その様な場合、改善の意見を私まで指摘を届けていただけたら嬉しいです。

なぜスクラムを選んだのか

今回新規プロジェクトを始めるに当たって開発手法も特にきまっておらず、 課題としては次の様なことが挙げられました。

  • 初めてのZEROからプロジェクトで具体的なイメージが決まりきっていない
  • 開発期間が約一年程度しかない

この状態では完全な要件定義は難しく、暫定のまま進めてユーザーテストを実施した後に再度要望を吸い上げて修正する期間を取ることも難しいと考えました。

そのため以下の様な効果が得られることを期待してスクラム開発をとり入れてみようとなりました。

  • 短期間で細かな要求・要望に対してのリリース可能な成果物を作成可能
  • 進捗状況の把握がしやすい
  • 要求・要望が変わった場合に柔軟に対応ができる

スクラムイベントと期間について

今回私達は水曜日を始まりとした2週間スプリントで実施しました。

水曜日始まりにしたのは「月曜日と金曜日は休みな事も多いので余裕ある方が良いよね」といった考えによるものです。

参考にどんなスケジュールだったのかもあげておきます。

時間割

スクラムイベントについては参考書等に記載があるのでここでは細かく記載しません。

スクラム導入して良かったこと

スプリント完了タイミングで常に現在の完成品を見せられる

これはベストプラクティスだったなと感じました。

特にスプリント毎に機能を見せることができるため、徐々にシステムが完成していくことで業務側からのフィードバックが即時受け取れる状態を作れたのがよかったです。

実際に触って動かせる成果物があることで、「必要だった物」とのギャップを即時に確認でき新たな要望として積み上げることが容易でした。

成果物に対して褒めていただける反響もたくさんありモチベーションアップにも繋がりました。

また、ローンチ直前に外部テストベンダーを使ったアドホックテストも実施したのですがテストベンダーさんからもバグ数が少ないという評価を頂くことができました。

すべての開発サイクルに関わることができる

スクラムのスプリントでは、要件定義、設計、実装、テスト、リリースなどの一連の工程をメンバーが実行していくことになります。

そのため、経験の少ないメンバーも自ずとやったことのない工程を体験していくことになりました。

そういった経験を積むことでメンバーから「今まではサービスとしてどうすべきかをあまり考えたことがなかったが、考えることができた」という発言をもらった際はとても嬉しくなりました。

また、リファインメントでも最初期は発言するメンバーが固定化されており、どうしても経験が浅いメンバーの発言が少ない傾向にありましたが、徐々にその傾向も少なくなってきました。

発言の少なかったメンバーが率先して発言したり、質問をしてくれる様になっていきました。

スクラム導入して見えた課題

全体像の把握とスケジュール管理が難しい

そもそも開発したいことの全量が決まっていなかった初期は、プロジェクト全体としてどれくらい進んでいるのかどうかなどは把握できていませんでした。

また、全量が出てもローンチまでの期間が決まっているので開発したいもの全てが入っているプロダクトバックログのみではローンチまでの作業のみを絞り込んで管理できませんでした。

その結果、別途管理表を作成しローンチまでに消化すべきポイントのみに焦点を当てて管理をしていましたが、ローンチまでに必須なものとそうじゃないものの区別がメンバーには把握できずらいものになっていました。

数値のみの消化をメンバーに共有していたこともあり、ローンチまでに完了が本当にされるのかメンバーに不安を与えるとともにスプリント以外の期限を意識させてしまいました。

ただ実際プロジェクトローンチはお尻があるものなので、この辺りどうしたらよかったかは今後のために考えていかないとなと思っています。

スプリントレビューにステークホルダーが参加できない

これはエンジニアリングだけの問題ではないのですが、ステークホルダーの方達が専任ではないためスクラムイベント関連に出席ができてませんでした。

結果、定期的にスプリントレビューのタイミングで複数のスプリントをまとめた総合レビューを挟むことになりました。

当然そこで初めて触る方も出てくるので、色々な意見が出てきて他の優先事項を処理するため後回しになってしまった改修も発生してしまいました。

ここはスクラムを行う時にステークホルダーを含めて定期的な時間をとってもらう様にすべきであるという事を強く学べました。

チーム内、コミュニケーションの難しさ

よくあることですが、今回のチームは今まで存在していたチームではなく他チームから集められたかつ、ここ数年でエンジニアを積極採用していたのもあり特に社歴の長い人が少ない状態です。

ちなみにこの時私は入社して半年たったくらいでした。つまり、皆さんほぼ初見。

どんな人なのか、どんなスキルを持っているのかは不明の状態でした。

そこでスクラムイベントとは別に問題を解決するための会議や知識共有を目的とした会議の設定をしました。

これらの会議自体は機能しているのですが、まだまだチームとして自然な状態にはなっていないかなと感じています。

この問題は徐々に改善していっていますが、これぞといった解決策が取れていない状態です。

リファインメントがすごく難しい

リファインメントは大きな問題がたくさんありました。

分割する大きさがまちまち

プロダクトバックログアイテムがより小さく詳細になるように分割および定義していくことが必要だと考えています。

その中で、透明度が高い要望は小さく詳細なチケットができるのですが、不透明な案件は分解できずに大きなチケットのまま実施することになってしまいました。

その結果、大きなチケットに関してはスプリント期間に完了しなかったり、実作業時間が見積より小さくなることが頻発しました。

評価がどうしても絶対評価になりがち

事前にストーリーポイントで見積をし、見積数値は相対見積で求めることを決めていましたが、経験上から絶対見積をしてしまうことが多かったです。

「絶対見積=自分ならこれぐらいでできる」というものになるため、作業する人が固定化されてしまったりすることもありました。

発言者の偏りが生じる

経験豊富なメンバーが主導して話すことがおおく、控えめなメンバーの意見が埋もれがちになっていました。

空気的にも経験者が言うなら特にないと言った雰囲気もでており、質問などの発言も初期の頃は少なかったと記憶しております。

会話が発散してしまう

チーム全員のスキルレベルが同じ状態ではないので、リファインメント対象の要件を話している際にどこまですべきかどこまで考えなければいけないかよくブレていました。

考えることや話あうこと自体は間違いではないですが、1時間のリファインメント時間で1つの要件しか見積できないということがよく発生していました。

改善について

これらの課題は解消してきているものもあれば、まだ継続して発生している課題もあります。

現在は「会話が発散してしまう」に対してスケジュールに載せているリファインメント時間以外にも個人で事前にチェックする時間を設けています。

そうすることで、一度は目を通して方向性を考えることで発散しすぎる事を解消できるのではと考えています。

ローンチが完了してみて

この度2023年8月1日にAndroid、iOSに向けてコミックROLLYは無事ローンチされました。

大体開発プロジェクト自体が2022年4月から開始、エンジニアが2022年6月ごろから徐々に集まり始めたことを考えても約1年ぐらいでの開発期間で開発できたのは驚きでした。

特に感じたことは今までのプロジェクトより終盤に向けての絶望感がかなり少なかったです。

なぜなのかと振り返ってみると、やはり常にできた成果物を第三者が確認できる状態を作れていたことが大きいのかなと。

ウォーターフォール型で開発してる場合は、開発終盤になっても第三者に見てもらうことは基本なく最終のユーザーテストや受け入れテストに入った段階で初めて意見の収集ができます。

そのため、規模が大きい場合はフィードバックによる修正の大きさがそのままプロジェクトの遅れにつながることが多く怖かったのですが今回はその心配がそこまでなかったからでした。

重ねてになりますが、1つの事例として迷えるプロジェクト管理者さんに届けば幸いです。

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としての機能が豊富なため、アプリのパフォーマンス測定と改善にも取り組んでいきたい