AWS上でElasticSearch7.10ドメインをOpenSearch1.0ドメインに移行した話

こんにちは。 エキサイト株式会社のAです。

今回ElasticSearchドメインの移行を行い、さらにElasticSearch7.10からOpenSearch1.0にバージョンを上げました。

Amzon OpenSearch Serviceとは

Amzon OpenSearchとは、Amazon Elasticsearch Service の後継サービスであり、Elasticsearch 7.10.2から派生したサービスです。

ElasticSearchとは互換性があり、基本的にバージョンアップ自体は容易に行えます。

※ElasticSearchのRestHighLevelClientからのアクセスができなくなり、OpenSearchのClientを使うなどの変更が必要なケースもあります。

ドメイン移行

今回、移行元のドメインのスナップショット(バックアップ)を新しいドメインにリストアして移行を行います。

OpenSearch(ElasticSearch)には、自動でバックアップを作成してくれる機能が標準で備わっていますが、ElasitcSearchの自動スナップショットは他のドメインにリストアをすることはできない為、移行元ドメインの手動スナップショットを作成する必要があります。

参考 docs.aws.amazon.com

スナップショット保存用S3バケット作成

S3コンソールで手動スナップショット保存用のS3バケットを作成 f:id:excite-at-ma:20211001180352p:plain

作成したS3バケットを開きプロパティから以下のようなAmazon リソースネーム (ARN)をメモしておきます。

arn:aws:s3:::s3-dev-opensearch-repo

ポリシー作成

IAM>ポリシー>ポリシーの作成 から先ほど作成したS3のポリシーを作成します。

ポリシー名例 s3-dev-opensearch-repo-policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:ListBucket"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:s3:::s3-dev-opensearch-repo"
            ]
        },
        {
            "Action": [
                "s3:*"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:s3:::s3-dev-opensearch-repo/*"
            ]
        }
    ]
}

Resourceには先ほどコピーしたS3のARNを記述してください。

ロール作成

ロール名例:dev-opensearch-repo-role

ロールの作成 から、先程作成したポリシー(今回の場合はs3-dev-opensearch-repo-policy)をアタッチします。 f:id:excite-at-ma:20211001181642p:plain

その後、作成したロールのARNをメモしておきます。

OpenSearch(ElasticSearch)用ポリシーを作成

ポリシー名例:dev-opensearch-policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "上記で作成したロールのARN"
    },
    {
      "Effect": "Allow",
      "Action": "es:ESHttpPut",
      "Resource": "移行前ElasticSearchドメインのARN/*"
    },
    {
      "Effect": "Allow",
      "Action": "es:ESHttpPut",
      "Resource": "移行後ElasticSearchドメインのARN/*"
    }
  ]
}

その後、再度 dev-opensearch-repo-role に上記のポリシーをアタッチします。

ロールに信頼関係を追加

dev-opensearch-repo-roleの[信頼関係]タブの[信頼関係の編集]ボタンから、下記の信頼関係設定をアタッチします。

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "",
    "Effect": "Allow",
    "Principal": {
      "Service": "es.amazonaws.com"
    },
    "Action": "sts:AssumeRole"
  }]
  
}

リポジトリ登録

移行元、移行先ドメインそれぞれに対して下記のpythonコードでリポジトリ登録を行います。

import boto3
import requests
from requests_aws4auth import AWS4Auth

host = '[移行元・移行先ElasticSearchドメイン]/' # 必ず末尾に/をつける!
region = 'ap-northeast-1'
service = 'es'
credentials = boto3.Session().get_credentials()
awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token)

path = '_snapshot/[スナップショットを置くリポジトリの名前]' # the Elasticsearch API endpoint
url = host + path

payload = {
  "type": "s3",
  "settings": {
    "bucket": "s3-dev-opensearch-repo",
    "region": "ap-northeast-1", # for all other regions
    "role_arn": "[作成したIAMロールのARN]"
  }
}

headers = {"Content-Type": "application/json"}

r = requests.put(url, auth=awsauth, json=payload, headers=headers)

print(r.status_code)
print(r.text)

移行元のkibana,移行先のOpenSearchDashboardsでそれぞれリポジトリを確認

GET /_snapshot?pretty

結果

{
  "[スナップショットを置くリポジトリの名前]" : {
    "type" : "s3",
    "settings" : {
      "bucket" : "s3-dev-opensearch-repo",
      "region" : "ap-northeast-1",
      "role_arn" : "IAMロールのARN"
    }
  }
}

移行先ドメインからkibanaを削除

作ったばかりのドメインだと、移行元ドメインと.kibana_1が被るのでOpenSearchダッシュボード上で下記のコマンドを実行します

DELETE /.kibana_1?pretty

スナップショット登録

import boto3
import requests
from requests_aws4auth import AWS4Auth

region = 'ap-northeast-1' # e.g. us-west-1
service = 'es'
credentials = boto3.Session().get_credentials()

awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token)
host = '[移行元ElasticSearchドメイン]/'

path = '_snapshot/[スナップショットを置くリポジトリの名前]/[スナップショット名]'
url = host + path

r = requests.post(url, auth=awsauth)

print(r.text)

スナップショットの確認

移行元kibanaと移行先OpenSearchDashboardsでそれぞれ下記のコマンドを実行し、スナップショットが存在しているか確認する

GET /_snapshot/[スナップショットを置くリポジトリの名前]/_all?pretty

移行先ドメインにスナップショットをリストア

import boto3
import requests
from requests_aws4auth import AWS4Auth

host = '/' # include https:// and trailing /
region = 'ap-northeast-1' # e.g. us-west-1
service = 'es'
credentials = boto3.Session().get_credentials()

awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token)
host = '[移行先OpenSearchドメイン]/'

path = '_snapshot/[スナップショットを置くリポジトリの名前]/[スナップショット名]/_restore'
url = host + path

r = requests.post(url, auth=awsauth)

print(r.text)

※ スナップショットをリストアした場合、indexのデータは全て入りますがindexテンプレートの設定まではコピーしてくれず、手動で設定し直す必要があるので注意です。

移行後ドメインに対してINDEXテンプレートを確認

GET _template/

結果

{ }

Javaで、JSONのタイムゾーン込みの日付文字列をDateTime型に変換する際の注意点

こんにちは。 エキサイト株式会社の三浦です。

JSON上の日付文字列をDateTime型に変換するのはJavaではよくあると思いますが、その際の注意点について書いていきます。

JSONの日付文字列の変換方法

まずは、通常の変換方法について見ていきます。

例えば、

{
    "sampleDate": "2021-01-01T10:10:00"
}

のような形で日付が渡された場合は、

@Data
public class SampleModel {
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime sampleDate;
}

とすれば変換してくれます。

タイムゾーン込みの場合

以下のようにタイムゾーン込みの場合、

{
    "sampleDate": "2021-01-01T10:10:00+09:00"
}

こうしてしまうと実は変換できません。

@Data
public class SampleModel {
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssZ")
    private LocalDateTime sampleDate;
}

というのも、 Z だけだと +0900 のようにコロンを付けないタイムゾーンのフォーマットとして処理してしまうためです。

+09:00 のようにコロンをつけたい場合は、

@Data
public class SampleModel {
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssZZZZZ")
    private LocalDateTime sampleDate;
}

このように、 ZZZZZ としましょう。

なお、今回は LocalTimeZone で受け取っていますが、これだと変換自体はできてもタイムゾーンは考慮されません。 タイムゾーン情報も使用したい場合は、 ZonedDateTime 型を使いましょう。

最後に

日付は様々なフォーマットがあるので、適切なものを選んでいく必要があります。 渡されるフォーマットに合わせることも大事ですが、渡す側でもどんなフォーマットで渡すかをきちんと考えていきましょう。

Nim言語でダイクストラ法を書いてみる

はじめに

ダイクストラ法というものをご存知でしょうか?
ざっくりいうと、目的地までの最短コースを探すときに使うアルゴリズムの一つです。

それを性懲りもなくNimで実装してみましょう。

ダイクストラ

以下のサイト様がわかりやすいかと思います。
https://products.sint.co.jp/topsic/blog/dijkstras-algorithm

ざっくりいうと、次の目的地までのコストを計算するアルゴリズムの一つです。
あれ?さっきも書いた?
コストというのは、現実世界で言う「信号」だとか「登道」だとかそういった時間がかかるものになります。

ダイクストラ法のコード

では早速コードです。

proc dijkstra*(edges: seq[seq[seq[float64]]], vertexNum: int): seq[float64] =
  # 道のりとして使う変数を初期値として大きな値をセット
  var destination: seq[float64]
  for i in 0..vertexNum - 1:
    destination.add(99999999.0)
  
  # 配列の最初はスタート地点なのでコストを0にする
  destination[0] = float64(0)

  var queue: seq[int]
  for i in 0..vertexNum - 1:
    queue.add(i)
  
  while len(queue) > 0:
    # コストが最小限の頂点(頂点)を見つける
    var r = queue[0]
    for i in queue:
      if destination[i] < destination[r]:
        r = i

    let u = queue[queue.find(r)]
    queue.delete(queue.find(r))

    for edge in edges[u]:
      if destination[int(edge[0])] > (destination[u] + edge[1]):
        destination[int(edge[0])] = destination[u] + edge[1]

    
  return destination

与えられた目的地とそのコストから、コストが最小になるように道のりを出していきます。
実際に動作させてみましょう。

動作付きのコード

proc dijkstra*(edges: seq[seq[seq[float64]]], vertexNum: int): seq[float64] =
  ##
  ## edges: 辺の終点と個数のリストを渡します
  ## vertexNum: 頂点の数を渡します
  ## 

  # 初期値として大きな値をセット
  var destination: seq[float64]
  for i in 0..vertexNum - 1:
    destination.add(99999999.0)
  
  # 配列の最初はスタート地点なのでコストを0にする
  destination[0] = float64(0)

  var queue: seq[int]
  for i in 0..vertexNum - 1:
    queue.add(i)
  
  while len(queue) > 0:
    # コストが最小限の頂点(頂点)を見つける
    var r = queue[0]
    for i in queue:
      if destination[i] < destination[r]:
        r = i       # コストが小さい頂点が見つかると更新

    let u = queue[queue.find(r)]
    queue.delete(queue.find(r))

    for edge in edges[u]:
      if destination[int(edge[0])] > (destination[u] + edge[1]):
        destination[int(edge[0])] = destination[u] + edge[1]

    
  return destination


const dijkstraTestData = @[
    @[@[1.0, 4.0], @[2.0, 3.0]],                    # 頂点A(0.0)からの辺のリスト[[頂点Bへ行ける, コストは4.0], [頂点Cへ行ける, コストは3.0]]
    @[@[2.0, 1.0], @[3.0, 1.0], @[4.0, 5.0]],       # 頂点B(1.0)からの辺のリスト[[頂点Cへ行ける, コストは1.0], [頂点Dへ行ける, コストは1.0], [頂点Eへ行ける, コストは5.0]]
    @[@[5.0, 2.0]],                                 # 頂点C(2.0)からの辺のリスト[[頂点Fへ行ける, コストは2.0]]
    @[@[4.0, 3.0]],                                 # 頂点D(3.0)からの辺のリスト[[頂点Eへ行ける, コストは3.0]]
    @[@[6.0, 2.0]],                                 # 頂点E(4.0)からの辺のリスト[[頂点Gへ行ける, コストは2.0]]
    @[@[4.0, 1.0], @[6.0, 4.0]],                    # 頂点F(5.0)からの辺のリスト[[頂点Eへ行ける, コストは1.0], [頂点Gへ行ける, コストは4.0]]
    @[]                                             # 頂点G(6.0)からの辺のリスト(ゴール)
  ]

let dijkstraResult = dijkstra(dijkstraTestData, 7)
assert dijkstraResult == @[0.0, 4.0, 3.0, 5.0, 6.0, 5.0, 8.0]

このような感じです。
assetの部分は、頂点Aから各頂点に向かったときの最小のコストとなっております。
図にすると以下のようになります。

f:id:taanatsu:20210930182002p:plain

今回だとA→C→F→E→Gと進むのが一番コストが少なそうですね!
このように利用します。

あとがき

結構昔に勉強したので思い出すのに時間がかかりました……
ヒープを使った計算式の最適化とか、ダイクストラを応用したA*(エースター)探索なんてのもありますね。
まだまだ奥が深い領域です。

DMSを使ってDBのAWS移行をする

エキサイト株式会社の武藤です。

オンプレのDBをAWSのRDSに移行する際に、AWSのDatabase Migration Service (DMS) を利用しました。 サービスの特性上読み込みが多く、書き込みが少なかったので、無停止での切り替えができました。 その手順について説明します。

前提情報として、オンプレDB はMySQL 5.5で構築されており、RDSはAurora MySQL 5.7を採用しました。

Database Migration Service とは

DBのAWS移行のために、移行元と移行先のDB間でデータ同期を行うサービスです。 データの一括移行と継続的なレプリケートが設定できるため、ダウンタイムを少なく移行が可能です。

aws.amazon.com

オンプレMySQLからAurora MySQLに移行する方針であったので、下記の記事を参考にしました。

aws.amazon.com

大まかな流れとしては、最初にオンプレDBからRDSにスキーマの変換を行います。 その後、オンプレからRDSに継続的なレプリケートを設定するため、オンプレDBのバイナリログ周りの設定を確認し、必要であれば変更します。 そして、レプリケーションを処理するインスタンスの作成や移行タスクを設定し、移行を実施します。

Schema Conversions Tool でスキーマを変換する

AWSで提供されているSchema Conversions Tool (SCT) を利用して、スキーマの変換を行いました。

docs.aws.amazon.com

SCTはWebの管理コンソールではなく、ローカルにツールをダウンロードします。

SCT上でソース、ターゲットのDBに接続をし、スキーマの変換を行います。

そのままRDSにスキーマを反映させることも可能ですが、変換内容を確認したかったので、一旦SQLファイルに保存して、差分比較を行いました。 今回のケースでは、下記のような差分がありました。

  • カラム、テーブルのCOMMENT の削除
  • MyISAMからInnoDBに変更
  • KEY に USING BTREE が付与
  • RDSの設定に合わせてCHARSETの変更(utf8 -> utfmb4)
  • text型がmediumtext型に変更

おそらくですが、DBエンジンや設計によってどのような差分が出るか異なると思われます。 動作に影響がありそうな変更がありますので、念の為確認しておくのが良いでしょう。

オンプレDBの設定確認

DMSの参考記事には下記が示されています。

AWS DMS は1回限りのデータ移行を行うことも、データを継続的にレプリケーションすることもできます。MySQLから Amazon Aurora への移行では、まずはバルクロードを行い、それからレプリケーションまたは Change Data Capture (CDC) を実行します。変更をレプリケーションするために、DMSはソースデータベースのバイナリログファイルを読む必要があるため、ソースのMySQLデータベースのバイナリログが有効になっているという前提条件を満たしているかを確認する必要があります。

今回は、サービスの特性的に無停止で切り替えが可能だったので、継続的なレプリケーションも設定しました。 そのため、下記のMySQLの設定を確認し、適宜修正しました。

log-bin=mysql-bin

binlog_format=row

binlog_checksum=none

DMSの設定

DMSの設定にもいくつか手順があります。

レプリケーションインスタンスの作成

オンプレDBからRDSへのレプリケーションの処理を担うインスタンスを作成します。 サブネットグループと紐付ける必要があるので、こちらも適宜作成します。

レプリケーションインスタンスの作成

エンドポイントの作成

移行元となるソース、移行先となるターゲットのエンドポイントを設定します。

エンドポイントの作成

それぞれに識別するための名前や、DBホスト名、ユーザ、パスワード等の接続情報を設定します。

移行タスクの作成

作成したレプリケーションインスタンス、エンドポイントを設定します。 移行タイプに「既存のデータを移行して、継続的な変更をレプリケートする」を指定します。

移行タスクの設定

移行タスクの詳細を設定します。

タスクの詳細設定

移行タスクを開始して、オンプレDBからRDSに変更がレプリケートされる状態となりました。

移行スケジュール

DMSの設定が完了して、実際にアプリケーションの向き先をオンプレからRDSへと変更します。

まずは、読み込みが多いアプリケーションから向き先をRDSに変更しました。1日ほど経過を見て問題が起きていないか等を確認します。

次に、書き込みが発生するアプリケーションの向き先をRDSに変更します。適宜、社内運用ツールやバッチを停止して行います。

その後、書き込み動作等を確認して問題なければ完了です。

最後に

DMSの使ったDBのAWS移行について、実際に行った流れを説明しました。 AWSで提供されているSCT、DMSのおかげで独自に移行方法を確立する必要がなく、便利なツールだと思います。 参考になれば幸いです。

参考

docs.aws.amazon.com

エキサイトは「PHP Conference2021」に協賛・登壇します

エキサイトは「PHP Conference2021」にシルバースポンサーとして協賛します。 今回は、弊社社員がRegular session、スポンサーツアーセッションに登壇いたします。 登壇情報については、以下をご覧ください。

Regular sessionについて

登壇者: おおしげ

日時:2021/10/02 16:15〜

場所:Track4

タイトル:新規プロジェクトの開発スタート前にやっておくべき環境整備たち

プロポーザルURL: fortee.jp

スポンサーツアーについて

日時:2021/10/02 12:00-12:30

登壇者: のぎわ あはれん

弊社は「メディアプラットフォーム」「ヘルスケア」「インフラテック」の3つの領域を主軸とし、自社企画開発で様々なサービスを展開しています。設立24年と歴史は長いですが、新規事業の立ち上げなどにも注力をしております。

スポンサーツアーでは、弊社について詳しく説明いたします。 また、今年度から行われている"技術組織活性"の取り組みについても説明いたします。 遊びに来てくださった方とざっくばらんにお話ができたらと思っております。ぜひ遊びに来てください♪

PHP Conference2021

開催日時:2021/10/02(土)~2021/10/03(日)

開催場所:オンライン開催

公式サイト:PHP Conference Japan 2021

公式TwitterPHPカンファレンス2021 (@phpcon) | Twitter

「Booost」ハッカソン型インターンを開催しました。

エキサイト株式会社では、9月9日(木)〜13日(月)に「Booost」ハッカソンインターンを開催しました。

学生向けの長期インターンは以前から実施していますが、ハッカソン型のインターンは初開催でした。 「学生生活をエキサイトさせるサービス」というテーマで、5日間かけてサービスの企画から開発までを行いました。

スケジュールについて

2日間でサービスを企画し、3日間で開発するというスケジュールでした。 サービスの企画は学生のみで行い、 開発からエキサイト株式会社とiXIT株式会社のエンジニアがメンターとしてサポートに入りました。

9月 9日(木) サービス企画1日目

9月10日(金) サービス企画2日目、中間発表

9月11日(土) 開発1日目

9月12日(日) 開発2日目

9月13日(月) 開発3日目、最終発表、オンライン懇親会

ハッカソンでの使用技術について

開発に集中していただきたかったので開発環境用意の負担を減らすため、 Docker・Laravel・Next.js・MySQL・Redisのプロジェクトを用意していました。 また、サービスを本番環境にリリースできるように、学生分のAWSアカウントを用意し、EC2等を利用できる状態にしていました。

ベースは用意していましたが、実際に使用する技術に関しては学生の皆様にお任せしていたので、チームによって違う技術構成になりました。

各チームが、サービスのコンセプトやメンバーの技術スタック、挑戦したい技術等で判断していて、チームの色が出ていました。各チームのサービス企画内容のリンク先に、使用技術について書いていますので、ぜひご覧ください。

コミュニケーションについて

コミュニケーションツールとしては、Discordを利用し、チームごとのボイスチャンネルとテキストチャンネルを用意しました。 ハッカソン中は、ボイスチャンネルに入って開発しており、話し合いがすぐできる環境になっていました。

毎日、朝会と夕会を行い、チームの進捗状況を人事やメンターが都度確認できるようにしていました。

メンターについて

エキサイト株式会社とiXIT株式会社のエンジニアがメンターとして参加しました。

チームごとにメンターが2〜3名付きまして、チームのボイスチャンネルに入って開発の状況を聞きながら、 タスクの優先順位付けやスケジューリング等のプロジェクトマネージメントから、 データベース設計やコーディング等の技術的なことまで、プロダクトの完成を目指して技術問わずサポートしていました。

各チームのサービス企画内容

各チーム、最終発表までにデモができる段階まで完成させてくれました。

以下のリンクに、サービスの詳細が記載されています。

topaz.dev topaz.dev topaz.dev topaz.dev

ハッカソン 結果

優勝:

本音ターン

受賞理由:

サービスの完成度が高く、実機と管理画面のデモが完璧動いていた点が素晴らしかったです。 学生に関する社会的な課題の解決を目指しているところがより実用的で良くアイデアも高い評価がされました。

特別賞:

Lian

受賞理由:

サービスデザインの完成度の高い点や、 短期間の間で、想定していた機能を作り上げていた点が評価されました。

参加者のブログ

参加してくださった学生さんがブログ記事を書いてくださりましたので、こちらもご覧ください。とても有り難いです。

exciteのハッカソン型インターン「Booost」で特別賞もらえた話 - Qiita

最後に

ハッカソン初心者の学生もいる中で、学生やメンター含め全メンバーが切磋琢磨し、最後発表まで開発をやり遂げてくれました。 また学生の皆様に、このハッカソンを通してチーム開発や技術的な知識について学びを得ることができたと言っていただけました。 運営としても、学生の皆様のチャレンジ力に刺激され、様々な学びを得ることができました。 どちらとも学びのある良い5日間となりました。

弊社では、今回のようなハッカソンインターンや、長期インターン等の用意しています。 また、一緒に働いてくれる仲間を絶賛募集しております! ご興味のある方は、以下のリンクからぜひご応募ください!

www.wantedly.com

JavaとPythonとGoのHTTPステータスコードの実装を調査した

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 Spring Boot / Javaで既存システムのリビルドを進めてHTTPステータスコードの実装を調べている途中に、ふと他の言語の実装が気になり、普段趣味で使用しているPythonとGoのソースコードを見てみました。 本記事では、JavaPython、GoのHTTPステータスコードの実装について紹介します。

調査環境

今回調査した環境は以下のとおりです。

  • JavaSpring Framework 5.3.7)
    • org/springframework/http/HttpStatus.java
  • Python 3.9.7
    • http/__init__.py
  • Go 1.17
    • src/net/http/status.go

Javaステータスコード

JavaSpring Framework)のステータスコードenumを使って実装されていました。 ステータスコードごとに要素が存在し、とてもわかりやすい実装になっています。 また、HttpStatusには2xx系のステータスコードかどうかを判定するis2xxSuccessful()メソッドや、エラーかどうかの判定するisError()メソッドが実装されています。ここに挙げたメソッド以外にもHTTPステータスコードに関係する便利なメソッドがいくつか実装されているので、必要に応じて活用していきたいです。

public enum HttpStatus {
    OK(200, Series.SUCCESSFUL, "OK"),
    CREATED(201, Series.SUCCESSFUL, "Created"),
    ACCEPTED(202, Series.SUCCESSFUL, "Accepted"),

   public boolean is2xxSuccessful() {
        return (series == Series.SUCCESSFUL);
    }


    public boolean isError() {
        return (is4xxClientError() || is5xxServerError());
    }

    private final int value;
    private final Series series;
    private final String reasonPhrase;

    HttpStatus(int value, Series series, String reasonPhrase) {
        this.value = value;
        this.series = series;
        this.reasonPhrase = reasonPhrase;
    }

    public int value() {
        return this.value;
    }
}

使い方

HttpStatusはvalue()メソッドを呼び出すことでステータスコードを取得することでき、getReasonPhrase()メソッドを呼び出すことでメッセージを取得することができます。

// 引数にHttpStatus.CREATEDを渡して実行
public void check(HttpStatus status) {
    System.out.println(status.value()); 
    // → 201
    
    System.out.println(status.getReasonPhrase());
    // → Created
}

Pythonステータスコード

Pythonの標準ライブラリのステータスコードは、IntEnumを継承して実装されていました。 Javaステータスコードの実装と比較的似ていますが、こちらはステータスコードのみ実装されており、ちょっとしたことで使える便利なメソッドの実装はありませんでした。

class HTTPStatus(IntEnum):
    OK = 200, 'OK', 'Request fulfilled, document follows'
    CREATED = 201, 'Created', 'Document created, URL follows'
    ACCEPTED = (202, 'Accepted', 'Request accepted, processing continues off-line')

使い方

HTTPStatusは、valueを呼び出すことでステータスコードを取得することができ、phraseを呼び出すことでメッセージを取得することができます。 また、IntEnumを継承しているためint型と比較することができます。

# 引数にHTTPStatus.CREATEDを渡して実行
def check(status: http.HTTPStatus):
    print(status.value)
    # → 201

    print(status.phrase)
    # → Created

    print(status == http.HTTPStatus.CREATED)
    # → True

    print(status == 201)
    # → True

Goのステータスコード

Goには上記に挙げたenumのような機能はありません。 Goでは、定数とmapを組み合わせてHTTPステータスコードを実装しています。 そのため、上記2つとは異なり、HttpStatus型のようなものはないため、ステータスコードはint型として扱わなければなりません。

package http

const (
    StatusOK       = 200 // RFC 7231, 6.3.1
    StatusCreated  = 201 // RFC 7231, 6.3.2
    StatusAccepted = 202 // RFC 7231, 6.3.3
)

var statusText = map[int]string{
    StatusOK:       "OK",
    StatusCreated:  "Created",
    StatusAccepted: "Accepted",
}

func StatusText(code int) string {
    return statusText[code]
}

使い方

とてもシンプルです!

// 引数にhttp.StatusCreatedを渡して実行
func check(code int) {
    fmt.Println(code)
    //→ 201

    fmt.Println(http.StatusText(code))
    // → Created
}

おわりに

本記事ではJavaPython、GoのHTTPステータスコードの実装についてまとめました。 実際にソースコードを比較してみると、各言語の特色が出てきて面白かったです。 最後まで読んでいただき、ありがとうございました!

Javaでクエリパラメータの日時データを受け取る方法

こんにちは。 エキサイト株式会社の三浦です。

APIで、日付情報をクエリパラメータとして受け取るという場面はそう珍しいものではありません。 ただし日付情報の問題点として、様々なフォーマットが存在しうるというものがあります。

今回は、渡されるフォーマットに合わせて日付情報を受け取る方法を説明します。

日付情報とフォーマット

日付情報というのは、例えば「2020年1月1日 0時0分0秒」みたいなものです。 ただ、これを示すフォーマットはいくつかあります。 例えば、

  • 2020-01-01 00:00:00
  • 2020/1/1 00:00:00
  • 20200101000000

などなど…。 先程の日本語を織り交ぜた「2020年1月1日 0時0分0秒」もそうですし、日本語以外でもそのようなフォーマットはあるでしょう。

では、これらをJavaで考慮してAPIのクエリパラメータとして受け取れるようにするにはどうすれば良いでしょうか?

@DateTimeFormat

実は、Spring Bootであれば @DateTimeFormat というものを使えば簡単にできます。

実装方法は、

class SampleRequest {
    LocalDateTime sampleDateTime;

    public SampleRequest(
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime sampleDateTime
    ) {
        this.sampleDateTime = sampleDateTime;
    }
}

こんな感じで、引数にアノテーションとしてつけるだけでOKです。 アノテーション内の pattern で、受け取りたいフォーマットを指定します。

また、もし複数パターンで来ることがある場合でも、

class SampleRequest {
    LocalDateTime sampleDateTime;

    public SampleRequest(
        @DateTimeFormat(pattern = "[yyyy-MM-dd HH:mm:ss][yyyyMMddHHmmss]") LocalDateTime sampleDateTime
    ) {
        this.sampleDateTime = sampleDateTime;
    }
}

このように指定することで複数パターンを許容することができます。

ただし注意点として、 LocalDateTime 型(年月日・時間の両方を持つことが前提の型)では、

class SampleRequest {
    LocalDateTime sampleDateTime;

    public SampleRequest(
        @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDateTime sampleDateTime
    ) {
        this.sampleDateTime = sampleDateTime;
    }
}

のように日付だけで受け取ろうとするとエラーになるのでご注意下さい。

最後に

日付のフォーマットは、言語によって取り扱い方の難易度が変わってきます。 Javaであれば上記のようにフォーマットの指定が必要ですが、言語によってはあまり気にせず受け取れたりする場合もあるでしょう。

ただ、最初はゆるい制限の言語を使っていたために日付のフォーマットはそこまで気にしていなかったが、仕様や言語が変わって硬いフォーマット指定が必要になって大変…ということも起こり得るので、普段から日付のフォーマットの統一は心がけると良いでしょう。

もうキャッシュの実装は怖くない!

はじめに

こんにちは。エキサイト株式会社で長期インターンをさせていただいている岡崎です。

今回は私が学んだキャッシュについての記事を書かせていただきます。

「もう分かっているんだが?」というエンジニアの皆さん向けではなく、Spring Bootでキャッシュを初めて実装する人向けの記事となっております。

そもそもキャッシュとは?

キャッシュとは、アクセスしたwebページの情報を一時的に保存していく技術です。

キャッシュを用いることで1からページを読み込むことがないので、動作の速さが期待できるぞってことですね。

キャッシュの流れは以下の通りになっています。

  1. コントローラまたはサービスは@Cacheableがついたメソッドを呼び出す
  2. Cache AOPが提供する@Cacheableにキーが渡される。CacheManegarを利用して、Hash Mapからデータを取得する。キャッシュデータが取得できた場合はコントローラまたはサービスへキャッシュデータを返却し、キャッシュデータが取得できない場合は次を実行する。
  3. Cache AOPは引数を渡し、定義されたサービスメソッドを実行し、戻り値を取得する。Cache AOPは2で特定されたキャッシュキーで取得した戻り値をCache Manegerを利用してHash Mapへデータとして格納する。
  4. Cache AOPはコントローラまたはサービスへ戻り値を返却する。

Cache AOP

キャッシュ機能の入り口になるインターフェース

Cache Maneger

キャッシュ機能をコントロールしてくれる

キャッシュのデータはredisが保持してくれます。

このredisはデータをメモリに保存してくれる高速なデータストアのことです。

キャッシュの実装は?

はい、ようやく本題ですね。

じゃあ、それってどうやって実装するの?という話です。

Spring Bootではキャッシュ機能のためのライブラリがすでに用意されています。

 キャッシュする必要があるメソッドに@Cacheableをつける

ますはここです。

この@Cacheableとは、キャッシュを有効化にするためのアノテーションです。

キャッシュを行いたいメソッドに対し、

@Cacheable(

cachename = “名前を任意でつける”

key = “キーがあればキーをつける。なければなくてよい”

condition = “キャッシュをする条件があればここでつける。なくてもよい”

)

public void sample(Integer sample, String sample) {

とつけます。

これだけでいい場合もあるのですが、現在だとそれだけではないときも多いと思います。

散々見たエラーはCould not read JSON: Cannot construct instance ofというものでした。

データのやり取りのイメージは以下です。

f:id:ooo-ka999:20210907105750p:plain

このアノテーションをつけることだけでうまくいく場合もありますが、そうでないこともあります。

例えばLocalDateTimeです。 うまくデフォルトのものだと変換ができなくて落ちてしまいます。 なので、

@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime sampleTime;

のようにアノテーションをつけてあげる必要があります。

そのほかにも、こんな事例がありました。 Jacksonでは、publicのフィールド、publicのゲッターを対象としてしまうので、対象にしたくないものは@JsonIgnoreをつける必要がありました。これで対象から外すためです。

使用例は以下の通りです。

@JsonIgnore
public Boolean isHogeHoge(String hogehoge) {
 return this.hogehoge == hogehoge;
}

また、デシリアライズ時にデフォルトコンストラクタが使用されてしまうので、デフォルトコンストラクタでデシリアライズしてほしくない場合は、@JsonCreatorを使ってデシリアライズ時のコンストラクタを使用してあげる必要があります。また、このときにprivateなインスタンス変数を使う場合は@JsonPropertyを用いてマッピングしたいキーの名前を指定してあげる必要がありました。

使用例は以下の通りです。

@JsonCreator
public SampleModel(@JsonProperty("sample") String sample) {
 this.sample=sample;
}

まとめ

キャッシュはキャッシュの対象にきちんと必要なアノテーションをつけてあげることが大切でした。キャッシュって奥深いんだなと今回では学びました。 まだまだ未熟なエンジニアですが、どんどん成長していきたいと思います。

また、余談ですがエキサイトのインターンはとても楽しく、充実しています! 興味ある人にはとてもおすすめしたいです。私はまだ引き続きインターンは続くので、残りの時間も成長できるように頑張っていきたいです。

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

PHPDoc の @uses を使って、可変関数の未使用警告を抑制する

エキサイト株式会社の武藤です。

PHP の可変関数

PHP には可変関数という機能があります。

www.php.net

関数名を変数にすることで、動的に呼び出したい関数を変更できます。

<?php

class Demo {

    function foo() {
        echo "foo";
    }
}

$functionName = 'foo';
$demo = new Demo();

$demo->$functionName();

使い方によっては有用な機能かもしれません。

しかし、intelliJ (PhpStrom) でファイルを開いてみると、未使用関数としてグレーアウトされてしまいます。

f:id:excite-mthiroshi:20210913201930p:plain
intelliJ で可変関数呼び出しをすると未使用関数として扱われる

未使用のコードは、運用開発の際にノイズになるので、可能な限り削除してしまう方がよいでしょう。しかし、可変関数呼び出しのコードがある場合、プロジェクト全体に検索をかけて、使われていないことの確認が必要です。 また、未使用関数の場合、定義元へコードジャンプも効かないため、修正するファイルの切り替え等の作業が煩雑になってしまいます。

PHPDoc の @uses を使おう

intelliJ で可変関数がグレーアウトされてしまうのは、静的解析を基に未使用と判断されるからです。 そこで、 PHPDoc の @uses を使って適切に呼び出し関係を補足してあげましょう。

f:id:excite-mthiroshi:20210913202215p:plain
呼び出し元で @uses をつける

可変関数が未使用関数ではなくなり、色が付きました。また、これでコードジャンプもできるようになりました。

最後に

PHP の可変関数について、intelliJ で表示される未使用警告の回避方法を説明しました。

長年運用されてきたソフトウェアは、使われなくなったコードが溜まってきてしまいます。Doc 以外にも、多くの言語で静的解析ツールが用意されていますので、それらを上手く駆使して無駄がないコードを保っていきましょう。

参考

PhpStorm の静的解析機能をさらに活用するための3つのアノテーション | バシャログ。

@uses & @used-by — phpDocumentor

Spring Bootでクエリパラメータの順序が異なるURIを比較する

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 UriComponentsのreplaceQueryParam()を使用してクエリパラメータの値を書き換えたときに、書き換える前と書き換えた後とでクエリパラメータの順序が異なってしまい、 単体テストで落ちてしまいました。 本記事では、クエリパラメータの順序が異なるURIを比較する方法について共有します。

概要

クエリパラメータの順序が異なる2つのURIを用意します。 このとき、uri1とuri2を比較するとfalseになります。

URI uri1 = URI.create("https://example.com?page=2&per_page=10");
URI uri2 = URI.create("https://example.com?per_page=10&page=2");

System.out.println(Objects.equals(uri1, uri2)); // → false

これは、URIがクエリパラメータを文字列で保持しており、equalsで比較したときにクエリパラメータは"page=2&per_page=10""per_page=10&page=2"の文字列比較を行っているため、falseになってしまうからです。 クエリパラメータの順序が異なったとしても同一のものとして比較したいときは、少し工夫する必要があります。

解決策

URIをUriComponentsに変換することで容易に比較することができるようになります。 UriComponentsではクエリパラメータをMap<K, List<V>>のラッパーであるMultiValueMapで保持しているため、クエリパラメータの順序が異なるものを比較したときにtrueを返します。

UriComponents u1 = UriComponentsBuilder.fromUri(uri1).build();
UriComponents u2 = UriComponentsBuilder.fromUri(uri2).build();

System.out.println(Objects.equals(u1, u2)); 
// → true

実際にUriComponentsのクエリパラメータの中身を見てみると、確かにMap形式で保持されていることが確認できます。

System.out.println(u1.getQueryParams());
// → {page=[2], per_page=[10]}

注意点

MultiValueMapは1つのキーに対して複数の値を扱うことができます。 そのため、クエリパラメータに同一のキーが複数指定されている場合、Listに変換されるため、falseを返す可能性があります。

URI uri3 = URI.create("https://example.com?page=2&per_page=10&page=3");
URI uri4 = URI.create("https://example.com?page=3&per_page=10&page=2");

UriComponents u3 = UriComponentsBuilder.fromUri(uri3).build();
UriComponents u4 = UriComponentsBuilder.fromUri(uri4).build();

System.out.println(u3.getQueryParams());
// → {page=[2, 3], per_page=[10]}

System.out.println(u4.getQueryParams());
// → {page=[3, 2], per_page=[10]}

System.out.println(Objects.equals(u3, u4));
// → false

したがって、同一のキーが複数指定される場合は注意しなくてはなりません

おわりに

UriComponentsを利用してクエリパラメータの順序が異なるURIを比較する方法についてまとめました。 クエリパラメータの順序が異なるURIを比較したいときって一度はあるのかなと思います。 URIをUriComponentsにして比較を行うことで、クエリパラメータ順序が異なっていたとしてもtrueを返すようになります。 「クエリパラメータの順序が異なるURIでも同じものとして扱いたい!」といった方に本記事が参考になれば幸いです。

参考

docs.oracle.com

spring.pleiades.io

mysqlslap で MySQL の負荷エミュレーションをしてみる

エキサイト株式会社の武藤です。

オンプレにあるDBを稼働中のRDSに統合する際に、mysqlslapを使った負荷エミュレーションをしたので、使い方を紹介します。

mysqlslapとは

mysqlslapとは、負荷エミュレーションをするコマンドです。クライアントの接続数やクエリ数などを設定して、実行時間を計測してくれます。 dev.mysql.com

mysqlCLIクライアントをインストールした際に一緒にいくつかの便利コマンドが入っているみたいです。mysqlslapもその一つです。

実行例

75クライアントから75クエリーのUPDATE文の実行を10回繰り返す実行例です。

DB設定

DBホスト : demo_db
mysqlユーザ : demo_user
mysqlパスワード : demo_password
ポート : 3306
mysqlslap \
--no-defaults \
--auto-generate-sql \
--engine=innodb \
--create-schema=demo_schema \
--password=demo_password \
--host=demo_db \
--user=demo_user \
--port=3306 \
--number-char-cols=3 \
--iterations=10 \
--concurrency=75 \
--auto-generate-sql-write-number=10 \
--auto-generate-sql-add-autoincrement \
--auto-generate-sql-load-type=update \
--number-of-queries=75
mysqlslap: [Warning] Using a password on the command line interface can be insecure.
Benchmark
    Running for engine innodb
    Average number of seconds to run all queries: 1.200 seconds
    Minimum number of seconds to run all queries: 1.068 seconds
    Maximum number of seconds to run all queries: 1.371 seconds
    Number of clients running queries: 75
    Average number of queries per client: 1

1回の試行の実行時間の平均が1.200秒、最小値が1.068秒、最大値が1.371秒でした。 このように、パラメータを調整して実行時間を計測します。

設定したオプションの説明です。

--no-defaults : デフォルト値を読み込まない
--engine : 使用するストレージエンジン
--create-schema : スキーマ
--password : DBのパスワード
--host : DBのホスト
--user : DBのユーザ
--port : ポート番号
--iterations : 試行回数
--concurrency : クライアント数
--auto-generate-sql : SQLを自動で生成
--auto-generate-sql-write-number : 各スレッドで実行する行挿入の回数
--auto-generate-sql-add-autoincrement : AUTO_INCREMENT カラムを自動生成されたテーブルに追加
--auto-generate-sql-load-type : テストの負荷タイプを指定
--number-of-queries : 各クライアントのクエリー数の指定
--number-char-cols : --auto-generate-sql が指定されている場合に使用する VARCHAR カラムの数

これ以外にも様々なオプションがありますので、公式ドキュメントを御覧ください。

検証したこと

今回は、オンプレDBからRDSで稼働中のDBに相乗りさせる形で移行を考えていました。その場合、オンプレDB分の負荷がRDSにそのままかかってきますので、それに耐えられるかを検証しました。

まずは、オンプレDBにかかっている負荷をzabbixから確認しました。それを基にmysqlslapでRDSに負荷をかけて、実行時間を確認していきました。最終的には、zabbixで確認した負荷より大幅に高い負荷をかけても実行時間が十分に短かったので、移行しても耐えられると判断しました。

そして、実際にRDSへ統合してCloudWatchで負荷を確認したところ、詰まることなく捌けていました。

事前に負荷検証をすることで、安全にDB移行ができました。

最後に

オンプレDBのAWS移行にあたって、RDSに対して負荷検証をしてみました。 mysqlslapで簡単に負荷検証ができるので、ぜひ使ってみてください。

第4回定期勉強会「Flutter勉強会」

こんにちは。 エキサイトの三浦です。

毎月恒例の定期勉強会が今月も開催されました。 今回は「Flutter勉強会」ということで、エキサイトでも使われ始めたFlutterの話になります。

なお過去開催分は以下になりますので、よければ御覧ください。

tech.excite.co.jp

tech.excite.co.jp

tech.excite.co.jp

Flutter勉強会

Flutterは、公式サイトで以下のように紹介されています。

Flutter is Google's UI toolkit for building beautiful, natively compiled applications for mobile, web, desktop, and embedded devices from a single codebase.

端的に言えば、「単一コードからモバイルアプリやwebなど様々なデバイス用のアプリケーションを生成できるフレームワーク」です。 その利便性から徐々にいろいろなサービスに取り入れられていっており、エキサイトでも導入され始めています。

今回は実際にエキサイト内でFlutterに触っているエンジニア3名に、

  • Flutterとは何か
  • どんな特徴があるのか
  • 触ってみた所感

を発表してもらいました。

Flutter自体の説明は以上の通りで、その利便性は言うまでもないですが、実際に使っているからこそわかる

  • 具体的なディレクトリ構造やコードはどうなっているのか
  • Swiftなどのネイティブコードの開発と比べた時のメリット・デメリット
  • 広告など、実際にアプリケーションコードを書く上で必要になってくる要素をどのように実装すべきなのか

などを教えてもらうことができました。

単に「Flutterっていいよね」だけではなく、こんな面倒な点がある、デメリットがあるというところも発表されていて、Flutterを技術選定の選択肢に入れる上で有意義な勉強会になったと感じています。 こうした生の声は、導入しはじめの技術では特に共有のメリットが大きいので、Flutter以外の技術でもぜひ勉強会をしていきたいところです。

最後に

定期勉強会は第4回目ということで、ついに四半期分をやったということになりました。 今後も半年、1年、そしてそれ以上と続いていったらと思います。

Spring Bootでコンポーネント名が重複してエラーになったときの解決策

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 Spring Bootでコンポーネント名が重複したときに、ConflictingBeanDefinitionExceptionが発生してエラーとなってしまいました。 その原因と解決策についてまとめました。

別のパッケージの同一のコンポーネント

例としてItemに関するItemServiceについて考えてみます。 ItemServiceはWebとアプリで処理が少し異なるため、それぞれで実装します。

  • WebのService
package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* 処理 */
    }
}
  • アプリのService
package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

上記2つは似ていますが、パッケージが異なることに注意が必要です。 通常、同一のクラス名であったとしても、パッケージが異なれば問題なく利用できます。 しかし、ConflictingBeanDefinitionExceptionが発生しコンパイルできずにエラーとなってしまいました。

ConflictingBeanDefinitionException: 
Annotation-specified bean name 'itemServiceImpl' for bean class [com.sample.app.service.item.ItemServiceImpl] conflicts with existing,
non-compatible bean definition of same name and class [com.sample.service.item.ItemServiceImpl

原因

コンポーネントの場合、同一のクラス名が2つ以上存在するときにコンパイルできずにエラーとなってしまいます。 これは、Spring BootがDIするときに、どちらのBean名もItemServiceImplとなってしまうからです。 そのため、どちらのSpring BootがどちらのItemServiceImplをDIしてよいのか判別できなかったため、ConflictingBeanDefinitionExceptionが発生してしまいました。

解決策1:コンポーネントプレフィックスに文字列を付与する

一番単純でわかりやすいのはクラス名の前にAppAdminなどの文字列を付与することです。 これで問題なくコンパイルおよび実行することができます。

package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class AppItemServiceImpl implements AppItemService {
    @Override
    public void getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

解決策2:アノテーションの引数に文字列を指定する

@Controller@Service@Repository@Componentなどのアノテーションの引数に独自のBean名を定義します。 これにより、同じクラス名のコンポーネントが複数存在しても問題なく実行することができるようになります。

package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service("AppItemServiceImpl")
public class ItemServiceImpl implements ItemService {
    @Override
    public void getItemByID(Long id) {
      /* 処理 */
    }
}

このとき、アノテーションの引数には変数を渡すことができます。 これで管理するのもよいかもしれません(ただし面倒だとは思います)。

@Service(ComponentName.APP_SERVICE_IMPL)

おわりに

Spring Bootでコンポーネント名が重複してエラーになったときの原因と2つの解決策についてまとめました。 個人的には、命名規則を決めて、管理者用ならAdminを、アプリ用ならAppプレフィックスに付与したほうがよいのかなと考えています。 例えば、解決策2で運用したときにItemServiceIDEでファイル検索したときに、ファイルパスまで含めて見ないとたどり着けないため、少し不便に感じるからです。 ただ、どちらの実装方法も大きく異なることはないため、最終的には好みになりそうです。

どこまでアプリケーションを「完璧」にすべきか

こんにちは。 エキサイト株式会社の三浦です。

アプリケーションを作るときや改修するとき、すべてのエンジニアが「完璧なアプリケーションを作ろう」と一度は思うはずです。

しかし、シンプルなコードで済むアプリケーションならともかく、複雑になればなるほど「完璧」を達成するのは難しくなっていきます。

今回は、どこまで「完璧」を目指すべきか、持論を語っていきたいと思います。 なおこちら、あくまで持論であり、必ずしも会社の方針と100%一致するとは限らないので、あらかじめご了承下さい。

完璧なアプリケーションとは

まず最初に、「完璧なアプリケーション」とは何でしょうか? それはもちろん、「いついかなる時も問題が起きない」アプリケーションです。

例えば、「2つの値を渡すとその合計値を返す」というアプリケーションの場合、正の数・負の数・非整数でも計算が可能であるとか、(仕様次第ですが)IntegerやLong値の限界を超えても問題なく動作する、などが「完璧」の条件として挙げられます。

この程度であれば問題ありませんが、ではWebサイトであればどうでしょうか? 考えられる条件としては、

  1. サイトにアクセスすれば、必ず想定通りの情報がサイトに載っている
  2. いつサイトにアクセスしても、一定以下の時間でページが表示される

でしょうか。 一見条件も少なく、簡単に達成できるような気もしてきますが、Webサイトというのは様々な状況下に陥ることが多々あります。 例えば、

  • 急に大量のアクセスが来る
  • 外部からの攻撃を受ける
  • ごく限られた条件下でしか起きない、コード内の潜在的なバグが発火する
  • 運用上のミス等で、DBなどのデータソース内のデータに不整合が生じる
  • インフラ周りに物理的な障害が発生する
  • クラウドを使用している場合、クラウド側に問題が発生する

などが挙げられるでしょう。 これら(もちろんここで挙げていないその他様々な原因も)をすべて解決し、「完璧なアプリケーション」を作り上げることは可能なのでしょうか?

「完璧」でなく「ベストエフォート」なアプリケーションを作る

個人的には、これら全てを完全に解決することはかなり難しいと考えています。

もちろん、例えば「急に大量のアクセスが来る」の対処法として「サーバ/コンテナが自動スケールするようにする」ですとか、「外部からの攻撃を受ける」の対処法として「セキュリティ対策を万全にする」など様々な解決策はありますが、それでも自動スケールが完了するまでの数分間はページ速度の急激な低下、もしくはタイムアウトはどうしても発生してしまうでしょうし、外部からの攻撃も新しい種類のものが出てきてしまえばまた新しく対処法を考える必要が出てきます。

さらに言えば、例えばごく限られた条件でしか起きない問題に対してどこまで対応するべきでしょうか? 1年に1~2回、しかも1回あたり数秒程度しか起きない問題に対して、何日も何週間もかけて原因調査・対応をするべきなのでしょうか?

私もしばらくこういった問題に頭を悩ませてきましたが、他のエンジニアと相談したり、色々と考えた結果、「完璧」ではなく「ベストエフォート」こそ目指すべきアプリケーションなのではないか、と考えるようになりました。

アプリケーション、及びそれらの上で展開されるサービスには、もちろん譲れない点があります。 例えば、「1日6時間はエラーでサイトが見られない」などといったサービスは、いくらなんでも受け入れられないでしょう。 可能な限り、いつでも見られるようにする必要はあります。

しかし、例えば「年間で99.9%の時間のサイトの動作は保証する(0.1%の時間はエラーでサイトが見られない可能性がある)」だとしたらどうでしょう? この程度であれば許容範囲、というサービスならあるのではないでしょうか? なおこれは、1年 = 365日 = 8760時間の0.1%なので、8.7時間ほどエラーが許容される、ということになります。

考えてみると、そもそもインターネットという仕組みそのものがベストエフォートという考え方で作られています。 今回の例であるWebサイトでいえば、その上で作られているアプリケーションがベストエフォートを目指すのは、ある意味自然なのかもしれません。

また有名なAWSも、サービスレベルアグリーメントという形で、「完璧」ではなく「ベストエフォート」でサービスを作っていることを宣言しています。

もちろん、サービスの内容によってどこまで許容されるかは変わってきます。 ニュースメディアサイトと医療用のオンライン手術サービスのようなものであれば、許容されるエラーのレベルは全く違うことでしょう。

ただしなんにせよ、やみくもに「完璧」なアプリケーションを作ることを考えるのではなく、まずはそのサービスでどこまでエラーが許容されるかを考えるべき、というのはすべてのアプリケーションに共通しているのではないでしょうか。 そして、許容されるエラーレベルに対して、適切に「ベストエフォート」なアプリケーションを作っていくことが、開発速度と開発精度の両方を兼ね備えた開発方法なのかもしれません。

最後に

AWSのように非常に大きく、インフラそのものをサービスとして提供するサービスであれば、最初から「~~%までの正常動作は保証する」というように宣言をしています。 一方で、いわゆる一般的なサービス・アプリケーションであれば、暗黙的に「まれに障害が発生することもある」という認識はありつつも、どこまでエラーが許容されるかという話し合いをすることは少ないのではないでしょうか? 特に自社開発であれば、その傾向は大きなものとなるでしょう。

ですがエラーや障害というのは、そのサービスが大きくなればなるほどいつか必ず起きるものとなっていきます。 例え自社開発のサービスであっても、一度はエラー許容レベルに関して話し合って決めることで、開発速度と精度のバランスを取ることができ、長期的に見てサービスの開発に良い影響を与えるのではないでしょうか。