Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What is going on with this article?

More than 1 year has passed since last update.

@Ushinji

【ruby】「DBリソース」と「AWS_S3上の関連するディレクトリ」を一緒に削除する実装(+Rspecテスト)

はじめに

S3上のディレクトリ削除処理とDBリソースと一緒に削除する実装について、色々勉強になったので記事にしておきます。

Tl;Dl;

  • S3上のファイルの削除は以下の通り。
    • 以下のようにbatch_deleteを使うと指定のディレクトリ配下のファイル、ディレクトリを削除できる
class User < ActiveRecord
  def delete_all_s3_contents!
    s3 = Aws::S3::Resource.new(client: s3_client)
    objects = s3.bucket("bucket_name").objects(prefix: "users/#{self.id}")
    objects.batch_delete!
  end
end
  • DBリソースとS3上の削除処理を一緒に行う場合は、以下のようにtransactionを貼れば、どちらかの処理が失敗した時にDBリソース削除はロールバックされるので、データの整合性が担保できる。
class User < ActiveRecord
  def destroy_with_associating_resources
    ActiveRecord::Base.transaction do
      self.destroy!
      self.delete_all_s3_objects!
    end
    true
  rescue => e
    Rails.logger.error(e)
    false
  end
end

ここから少し詳しく説明

説明したいことは、上述のコードだけで説明終了なのですが、もうちょっと詳しい説明を書きたいと思います。

今回説明する際の例として、 ユーザー削除機能 を考えます。そして、そのユーザー機能の1つとして写真投稿があり、その写真はS3上に保存しているとします。

その前提でユーザー削除機能を実装する場合、 DB内のユーザー情報に加えて、指定ユーザーに関連するS3上のファイルを一緒に削除することが必要 になります。

この記事は このユーザー削除+S3上のファイル削除を考える話 となります。

ユーザーとS3上のファイルアップロード先の関係

ユーザーが投稿した写真は、AWSのS3上の /bucket_name/contents/users/:id にアップロードするとします。:idはユーザーのID(数字)です。

そのため、例えばIDは1のユーザーを削除する場合、S3上の /bucket_name/contents/users/1/パスのディレクトリをファイルを含めて削除することを意味します。

実装

以上の説明を踏まえて、作成した実装が以下の通りです。

class User < ApplicationRecord

  def delete_all_s3_contents!
    s3 = Aws::S3::Resource.new(client: s3_client)
    objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")
    objects.batch_delete!
  end

  def destroy_with_associating_resources
    ActiveRecord::Base.transaction do
      self.destroy!
      self.delete_all_s3_objects!
    end
    true
  rescue => e
    Rails.logger.error(e)
    false
  end

  private
  def s3_client
    @s3_client ||= Aws::S3::Client.new(
      access_key_id: "ACCESS_KEY_ID",
      secret_access_key: "SECRET_ACCESS_KEY",
      region: "ap-northeast-1",
    )
  end
end

S3上の指定ディレクトリの削除

def delete_all_s3_contents!
  s3 = Aws::S3::Resource.new(client: s3_client)
  objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")
  objects.batch_delete!
end

指定ディレクトリ削除の流れは、まずs3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")にて、バケット名bucket_namecontents/users/#{self.id}ディレクトリにある全てのオブジェクト一覧を取得します。

そして、batch_delete!を呼び出し、先ほど取得したオブジェクト一覧をパラメタとして、一括でS3に削除APIをリクエストします。

s3_client はプライベートメソッドとして切り出しています。理由は、その方がテストのモックが簡単になるからです。


private
def s3_client
  @s3_client ||= Aws::S3::Client.new(
    access_key_id: "ACCESS_KEY_ID",
    secret_access_key: "SECRET_ACCESS_KEY",
    region: "ap-northeast-1",
  )
end

また、AWSのS3に対するAPIリクエストを行なった際に、失敗したら例外が吐かれる想定で実装しています。実際のコードで例外送出を行う箇所を見つけられなかったので正確ではないですが、以下のS3のSDKのスタブに関する記事をみる限り、失敗時は例外を吐くと認識しています(間違っていたら、ご指摘ください)
https://docs.aws.amazon.com/ja_jp/sdk-for-ruby/v3/developer-guide/stubbing.html

ユーザー削除処理

def destroy_with_associating_resources
  ActiveRecord::Base.transaction do
    self.destroy!
    self.delete_all_s3_objects!
  end
  true
rescue => e
  Rails.logger.error(e)
  false
end  

ユーザー削除(self.destroy!)とS3上のファイル削除(self.delete_all_s3_objects!)の2つに対して、transactionを貼ることで、どちらかが失敗して例外を早出したら、DBロールバックによってリクエスト実行前に戻ります。

ここのポイントは、self.delete_all_s3_objects! を後にすることですね。処理を逆にしてしまうと、S3上のファイル削除後に行われるユーザー削除が失敗した場合に、S3上のファイル削除だけ行われてしまうのでデータの整合性が保つことができなくなります。

テスト

次に、S3削除処理(delete_all_s3_contents!)に対するテストについて説明します。全体の流れは以下の通りです。

describe :delete_all_s3_contents! do

  subject { user.delete_all_s3_contents! }

  let(:user) { User.create(name: "hoge") }
  let(:s3_contents) {
    [
      { key: "contents/users/1/IMAGE.png" },
      { key: "contents/users/1" },
    ]
  }
  let(:client) { Aws::S3::Client.new(stub_responses: true) }

  before do
    client.stub_responses(:list_objects, contents: s3_contents)
    client.stub_responses(:delete_objects)
    allow(user).to receive(:s3_client).and_return(client)
  end

  it "指定のパラメタでS3のAPIへリクエストが行われていること" do  
    expect(client).to receive(:list_objects).
        with({
          bucket: "bucket_name",
          prefix: "contents/#{user.id}",
        }).and_call_original

    expect(client).to receive(:delete_objects).
        with({
          bucket: "bucket_name",
          delete: { objects: s3_contents },
        })

    subject
  end
end

前準備

まずbeforeでの前準備の説明です。

まず、APIスタブ用のAWSのS3 clientインスタンスを作成しています。S3Clientはオプションを指定するとスタブができるようになります。

let(:client) { Aws::S3::Client.new(stub_responses: true) }

次に、clientから実行するS3のAPIリクエストのスタブを行います。

before do
  client.stub_responses(:list_objects, contents: s3_contents)
  client.stub_responses(:delete_objects)
  allow(user).to receive(:s3_client).and_return(client)
end

S3のsdkにはstub_responsesというスタブ用のメソッドが用意されています(リファレンス)。

今回はbatch_delete!で指定のオブジェクトの削除を行うのですが、実際にS3側へのリクエストは list_objectsdelete_objectsの2つが行われます。なので、その2つをスタブしてあげれば良いです。

最後に、user インスタンスのプライベートメソッドs3_clientが呼ばれた際に、モック用のS3 clientインスタンスclientを返すよう定義します。これでS3へのリクエストをスタブすることができました。

検証

実際の検証部分は以下の通りです。

it "指定のパラメタでS3のAPIへリクエストが行われていること" do  
  expect(client).to receive(:list_objects).
        with({
          bucket: "bucket_name",
          prefix: "contents/users/#{user.id}",
        }).and_call_original

  expect(client).to receive(:delete_objects).
        with({
          bucket: "bucket_name",
          delete: { objects: s3_contents },
        })
  subject
end

expectwithと使うことでパラメタの検証ができるので、「指定のパラメタでS3へリクエストを送っているか?」を検証しています。

あと、今回初めて知った点で and_call_original です。通常expectで対象となったメソッドはデフォルトではレスポンスを返さなくなります。ただ、今回はlist_objectsのレスポンスを使ってdelete_objects を実行するのでレスポンスがないと以後の検証ができません。なので、and_call_originalを呼ぶことで、ちゃんとレスポンスを返す指定をしています。

おわりに

S3の削除の実装、テストを書きながら、色々勉強になった点をまとめました。今後もテストが書きやすく、わかりやすいコードを書けるように日々精進していきたいです。

参考文献

2
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
2
Help us understand the problem. What is going on with this article?