ex_awsを使ってElixirからAWSのAPIを呼び出す

elixir-flame-i-catch

この記事はElixir Advent Calendar 201613日目の記事です。

RubyやJava、Goなどの言語でAWSを利用する時には公式SDKを使いAWSの各APIを呼び出すことが多いと思います。
Elixirにも公式ではないですがex_awsというライブラリがあり、多くのAWSのAPIをサポートしています。開発も非常に活発です。

ex_awsの公式ページ
ex_awsのGitHubリポジトリ

今回は、ex_awsを使ってAWSの以下APIを呼び出してみます。

  1. S3
  2. Dynamodb
  3. SNS + SQS
  4. Kinesis Streams
  5. KMS

依存ライブラリの設定

まずはプロジェクトの設定です。
今回はPhoenixのプロジェクトでex_awsを使ってみます(Phoenixは今回の内容に全く関係ないですが...)。
mixファイルに以下の定義を追加します。

defp deps do
  ...  
  {:ex_aws, git: "https://github.com/CargoSense/ex_aws.git", branch: "master" },
  {:configparser_ex, "~> 0.2.1", optional: true},
  {:poison, "~> 2.0"},
  {:secure_random, "~> 0.5"}

ex_awsは日々機能追加が行われているので、今回は最新のmasterを使います。
configparser_exは設定ファイルのparse処理で使用します。
poisonはエンコード、デコード処理をするために使用します。
secure_randomはランダムなハッシュ値を生成するために使用します。

もし自分でライブラリの中をデバッグしたい場合は、ex_awsをローカルにチェックアウトしてから以下のように定義を変更してください。

{:ex_aws, path: "/path/to/ex_aws"} # チェックアウトしたディレクトリを指定します。

Credentialの設定

設定ファイル(configディレクトリ下のファイル)にAWSのアクセスキー情報を設定します。
以下のように設定することで、環境変数の値を読んで実行するようになります。

config :ex_aws,
  debug_requests: true,
  access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
  secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
  region: "ap-northeast-1"

awscliのように/$HOME/.aws/credentialsに設定した情報を使いプロファイル名を指定することもできます。
以下のように設定します。

config :ex_aws,
  debug_requests: true,
  access_key_id: [{:awscli, "profile-name", 30}, :instance_role],
  secret_access_key: [{:awscli, "profile-name", 30}, :instance_role],
  region: "ap-northeast-1"

profile-nameのところに、使用するアカウントのプロファイル名を指定します。

S3

設定は終わりましたのでAWSのAPIを呼び出していきましょう。
最初はS3からです。S3のバケットにあるJSONファイルを取得して呼び出し元に返却します。
JSONの値は何でも良いのですが、ここでは以下のような配列型のデータ構造とします。

{
    "contents": [
        {
            "id": 100,
            "title": "test content 100",
            "message": "test message 100",
            "start_at": "20161215",
            "end_at": "20170101"
        },
        {
            "id": 101,
            "title": "test content 101",
            "message": "test message 101",
            "start_at": "20161115",
            "end_at": "20170101"
        },
        ...
    ]
}

バケット名とキー名を指定してget_objectAPIを呼び出します。

contents = ExAws.S3.get_object("test-bucket", "contents.json")

戻り値はJSONなのでMap型にデコードしてから、Contentの配列を取得します。
また、配列の中身はただのMap型なので、Elixirの構造体に変換します。

  alias ExAws.S3
  
  def index(conn, _params) do
    body = S3.get_object("test-bucket", "content.json")
    |> ExAws.request!
    |> Map.get(:body)
    |> Poison.decode!
    
    contents = body
    |> Map.get("contents")
    |> Enum.map(fn(content) -> map_to_struct(content, Content) end)

    render(conn, "index.json", contents: contents)
  end
  
  # MapからStructに変換するヘルパー関数
  defp map_to_struct(map, module) do
    module.__struct__
    |> Map.from_struct
    |> Enum.reduce(%{}, fn({k, v}, acc) ->
      Map.put(acc, k, Map.get(map, to_string(k), v))
    end)
    |> Map.put(:__struct__, module)
  end

Content構造体もJSONに記述した内容に合わせて定義しておきます。

defmodule Content do
  defstruct [:id, :message, :title, :start_at, :end_at]
end

APIを試してみます。S3のJSONの内容を取得します。

$curl -H "Accept: application/json" -H 'Content-type:application/json' -X GET http://localhost:4000/api/contents

{"data":
[{"title":"test content 100","start_at":"20161215","message":"test message 100","id":100,"end_at":"20170101"},
{"title":"test content 101","start_at":"20161115","message":"test message 101","id":101,"end_at":"20170101"},
{"title":"test content 102","start_at":"20161201","message":"test message 102","id":102,"end_at":"20170115"},
{"title":"test content 103","start_at":"20161230","message":"test message 103","id":103,"end_at":"20170131"},
{"title":"test content 104","start_at":"20161215","message":"test message 104","id":104,"end_at":"20170131"}]}%

Dynamodb

続いてDynamodbのAPIを呼び出してみましょう。先ほどのJSONと同じデータ構造のテーブルを事前に作成してあります。
get_itemAPIを呼び出してテーブルのレコードを取得します。

  alias  ExAws.Dynamo
  
  def show(conn, %{"id" => id}) do
    content = Dynamo.get_item("contents", %{id: String.to_integer(id)})
    |> ExAws.request!
    |> Dynamo.decode_item(as: Content)

    render(conn, "show.json", content: content)
  end

APIを試してみます。Dynamodbのレコードを取得します。

$ curl -H "Accept: application/json" -H 'Content-type:application/json' -X GET http://localhost:4000/api/contents/103

{"data":{"title":"test content 103","start_at":"20161230","message":"test message 103","id":103,"end_at":"20170131"}}%

ちなみに更新の場合はこうです

  Dynamo.update_item("contents", %{id: id},
    expression_attribute_values: %{m: "test updated message"},
    update_expression: "set message = :m")
  |> ExAws.request!

削除の場合はこう

Dynamo.delete_item("contents", %{id: 103}) 
|> ExAws.request!

SNS + SQS

SNSトピックへのpublishと、トピックをsubscribeしているSQSからのメッセージ取得を試します。
まずはSNSトピックへのpublishです。

  publish_opts = %{topic_arn: "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:test-topic"}
  message = Poison.encode!(%{message: "ExAws Rocks!"})
  SNS.publish(message, publish_opts) |> ExAws.request!

続いてSQSからメッセージを取得します。

response = SQS.receive_message("test-queue") |> ExAws.request!

最後に、取得したメッセージを削除します。

  receipt_handle = response[:body][:messages]
  |> Enum.at(0)
  |> Map.get(:receipt_handle)

  SQS.delete_message("test-queue", receipt_handle) |> ExAws.request!

Kinesis Streams

Streamへのレコードの追加と取得を行います。まずは1レコードのみ追加してみます。
レコードはPoisonライブラリを使用してエンコードしてから追加します。

  alias ExAws.Kinesis
  
  def put_records(conn, _params) do
    record = Poison.encode!(%{language: "Elixir", framework: "Phoenix"})
    Kinesis.put_record("test-stream", SecureRandom.hex(16), record)
    |> ExAws.request!

    send_resp(conn, :ok, "")
  end

続いて、put-recordsAPIを呼び出して複数レコードを追加します。
複数シャードにうまく分散されるようにパーティションキーの値をランダムにします。

  def put_records(conn, _params) do
    records = [%{data: "Hello Elixir", partition_key: SecureRandom.hex(16)},
               %{data: "Hello Clojure", partition_key: SecureRandom.hex(16)},
               %{data: "Hello Rust", partition_key: SecureRandom.hex(16)},
               %{data: "Hello Go", partition_key: SecureRandom.hex(16)}]
    Kinesis.put_records("test-stream", records)
    |> ExAws.request!

    send_resp(conn, :ok, "")
  end

レコードのputはできたので、次はStreamからgetしましょう。
シャードイテレータを取得してから、その値をパラメータにしてレコードを取得します。

  def get_records(conn, _params) do
    shard_iterator = Kinesis.get_shard_iterator("test-stream", "shardId-000000000001", :trim_horizon)
    |> ExAws.request!
    |> Map.get("ShardIterator")

    Kinesis.get_records(shard_iterator)
    |> ExAws.request

    send_resp(conn, :ok, "")
  end

KMS

KMSを使ってデータ暗号化と復号化を試します。事前にカスタマーマスターキーは作成していることとします。

暗号化

まずはデータキーを作成します。

# カスタマーマスターキーを指定する
encrypted_response = ExAws.KMS.generate_data_key("xxxx-xxxx-xxxx-xxxx-xxxx")
|> ExAws.request!

結果

%{"CiphertextBlob" => "AQEDAHhOO/6KtZ8+9x3gvTYAZV9p1b1x31e/xeaj7M0rwtsTCgAAAH4wfAYJKoZIhvcNAQcGoG8wbQIBADBoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDLu2ma0AN/dEOogKlAIBEIA7rWb8UC218CaEsO1D/Qca3OH7Gbj/A2R2zX4U1++QFiyfeEZQBj9tBEvl+RsqNUG8wLtW4XO0y9yvLEU=",
  "KeyId" => "arn:aws:kms:ap-northeast-1:xxxxxxxx:key/xxxx-xxxx-xxxx-xxxx-xxxx",
  "Plaintext" => "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}

次に、作成したデータキーを使ってデータを暗号化します。

echo Hello Elixir |openssl enc -e -aes-256-ecb -pass pass:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > encrypted_data.txt

復号化

暗号化できたので、平文のデータキーは削除していることとします。
続いて復号化を行います。
ます、暗号化済みのデータキーを復号化します。

decrypted_response = ExAws.KMS.decrypt(encrypted_response["CiphertextBlob"]) 
|> ExAws.request!

結果

 %{"KeyId" => "arn:aws:kms:ap-northeast-1:xxxxxxxx:key/xxxx-xxxx-xxxx-xxxx-xxxx",
   "Plaintext" => "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}}

復号化したデータキーでデータを復号化します。

openssl enc -d -aes-256-ecb -pass pass:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -in encrypted_data.txt

結果

Hello Elixir

まとめ

ex_awsはドキュメント、テストコードもしっかりしていて開発も非常に活発です。
私も数ヶ月前から使い始めましたが、各APIも直感的に使えてわかり易いです。

今回試したのは S3, Dynamodb, SNS, SQS, Kinesis, KMSだけですが、ex_aws は他にもDynamoStreams, EC2, Lambda, RDSをサポートしています。
また、現在開発中のもの(SES, STS, Route53)もあります、近いうちにmasterにマージされるかもしれません。

今回のre:Inventでは多くの魅力的なサービスが発表されました!これらのAPIも今後ex_awsに追加されて、さらに機能が充実してきそうです。

Elixir Advent Calendar 2016

12日目の記事: Elixir のコードレビューで見たやつ
14日目の記事: edeliverとconsulを使ったphoenix applicationのdeploy

AWS Cloud Roadshow 2017 福岡