[scala] AWS SDK for Java V1 を V2にアップデートしたときにやったこと

最近行ったAWS SDK for Java v1からv2にアップデートでいろいろやったという話です。
2022.06.24

はじめに

最近(かなり頑張って)AWS SDK for Java V1の相当古いバージョンを使っているプロジェクトでV1 → V2のアップデートを行ったのでどんなことをしたのか紹介します。

なぜアップデートしたのか?

AWS Developers Tools Blog 「TLS 1.3 Incompatibility with AWS SDK for Java versions 1.9.5 to 1.10.31」で紹介されていますが、Oracle JREでTLS 1.3がデフォルトで有効になることでAWS SDK for Java バージョン 1.9.5 から 1.10.31 を使用している場合は紹介されているいずれかの対応を取ることが推奨されています。今回はこの対応を行いました。

着手前にやったこと

今回は要対応のバージョンだったためSDKのメジャーバージョンアップを念頭に入れて以下の方法で影響範囲の確認を行いました。

  • git grepでソースコード内のimport 文を検索して対象クラスを確認
  • build.sbt (ビルドツールの設定ファイル)を確認して使用しているサービスを確認

上記で影響を受ける機能と変更が必要なファイル数がわかったのでテストも含めて必要な期間を見積もり、ステークホルダーにスケジュールと影響範囲を伝え、対象の機能の重要度を踏まえた上でアップデートを実施することを合意しました。ここでは影響を受ける機能の重要度が高かったのでそれを意識的に確認しました。

ソースコードの変更

ソースコードの変更は以下のような方針で実施しました。

  • 基本的にはv1 のAPIをv2のAPIで置き換える(各サービスのクライアントを置き換えていく)
  • 使用箇所が多いAPIはv1と同等のシグネチャの互換レイヤーを作成し置き換える
  • 機能やパッケージ毎に置き換えして順次自動テストを実行していく

強いて挙げると特筆すべきことは2点目でしょう、これはすぐ後で紹介します。

c.a.s.dynamodbv2.documentの互換レイヤー

今回変更したコードではDynamoDBクライアントを使用している箇所の割合が多く、変更にも多くの時間を割きました。v1 SDKではcom.amazonaws.services.dynamodbv2.documentパッケージにDynamoDB上のオブジェクトを抽象化したクラスが提供されており、これを使ったコードをv2のクライアントを使ったコードに置き換える(v2では同様のAPIがない)のは量と期間を考慮すると難しかったので、このパッケージに対応する互換レイヤーを作りました。具体的には以下のようにItemおよびTableに相当するクラスを実装しDDBクライアントからこれを取得できるようにしました。ItemおよびTable以外のクラス(APIリクエストに対応する*Specなども同じように実装しています。

// s.a.a.s.dynamodb.DynamoDbClientの拡張
trait DynamoDBOps {
  implicit class Ops(db:DynamoDbClient) {
    /**
     * V1 SDKのgetTableとの互換性のためのメソッド。これはTable(name)のシンタックスシュガーである
     * @param name テーブル名
     */
    def getTable(name:String): Table = Table(name)
  }
}
/**
  * c.a.s.d.document.Tableの互換レイヤー
  * @see com.amazonaws.services.dynamodbv2.document.Table
  **/
final case class Table(name:String) {
  def putItem(item:Item)(implicit db:DynamoDbClient): PutItemResponse = db.putItem(
    item.asPutItemRequest.tableName(name).build()
  )
  def putItem(putItemSpec: PutItemSpec)(implicit db:DynamoDbClient): PutItemResponse = db.putItem(putItemSpec.underlying.tableName(name).build())

  def query(querySpec: QuerySpec)(implicit db:DynamoDbClient): List[Item] = Item.fromGetQueryResponse(db.query(querySpec.underlying.tableName(name).build()))
  
  def getItem(getItemSpec: GetItemSpec)(implicit db:DynamoDbClient):Item = Item.fromGetItemResponse(db.getItem(getItemSpec.underlying.tableName(name).build()))
}

/**
  * c.a.s.d.document.Itemの互換レイヤー
 * V1 SDKとの互換性のためにget*メソッドではnullや例外を返すことがある。
  * @see com.amazonaws.services.dynamodbv2.document.Item
  **/
final case class Item(attrs: Map[String, AttributeValue]) {

  def withString(name:String, value:String): Item = append(name, AttributeValue.fromS(value))

  def append(name:String, value: AttributeValue): Item = copy(attrs = attrs + (name -> value))

  // 互換性のためにキーがないときにはnullを返す
  def getString(name:String): String = attrs.get(name).fold[String](null)(_.s())

  //V1 SDKとの互換性のために値がないときは例外
  def getBoolean(name:String): Boolean = attrs.get(name).fold(throw incompatibleToBoolException(name))(_.bool())
}

その他

以下では置き換えしていて重要だと思った点を紹介します。

ロガーの設定ファイル

SDKのログ設定を明示的に設定している場合はパッケージ名の変更などが必要です。

v2 でのロガーの設定はLogging AWS SDK for Java calls を参照

例外クラスの置換

v2では例外クラスのAPIが変更されています。例外クラスについてのドキュメントで概要を理解することも必要ですが、特に以下の点で互換性がなかったので工夫が必要でした。

v1ではAmazonServiceExpcetion#getErrorTypeでエラーの発生元(クライアント or API) を示すenumが取得できましたが、これに対応するものはv2にはありません。そこでSdkServiceExceptionを以下のように拡張して同様の情報を取得できるようにしました。

sealed trait ErrorType
object ErrorType {
  final case object Service extends ErrorType
  final case object Client extends ErrorType
}

implicit class ExceptionOps(e:SdkServiceException) {
  def getErrorType: Option[ErrorType] = e.statusCode() match {
    case _ if 400 to 499 contains e.statusCode() => Some(ErrorType.Client)
    case _ if 500 to 599 contains e.statusCode() => Some(ErrorType.Service)
    case _ => None
  }
}

まとめ

今回置き換えたコードはそれほど多くはなかったですが、ちょっとした工夫をすることで変更量がかなり抑えられたように思います。APIのシグネチャは特にビルダーパターンにおいては、ある程度規則的に変更されているので、より大規模な変更ではscalafixを使った修正を検討するのもいいかもしれません。