$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

ScalaのOptionとEitherで例外処理を行う方法


Scalaの例外処理はOptionとかEitherを利用するっぽいんだけど、調べてもいまいちその使い方が分からなかった。いろいろやってみたところ、だいぶ分かってきたので、後から自分で読み返せるようにメモ。

Optionを利用する

Optionは値があるかないかわからない場合に、ラップして返してくれるもの。値がある場合はSome()に値が包まれて返ってきて、ない場合はNoneが返ってくる。エラーの内容が特に必要がない場合の例外処理に便利。

パターンマッチで例外処理をする

SomeとNoneでパターンマッチすれば例外処理できる。こんな感じ。

val map = Map("a" -> 1, "b" -> 2)

map.get("a") match {
  case Some(n) => println(n)
  case None => println("Nothing")
}

map.get("c") match {
  case Some(n) => println(n)
  case None => println("Nothing")
}

MapのgetはOption型を返すので試せる。aというkeyは存在するので、最初のパターンマッチでは1がprintされる。cというkeyは存在しないので、二つ目のパターンマッチではNothingが表示される。

getOrElseを使う

単に入ってなかったらデフォルトを使いたいということであればgetOrElseを使ったらいい。

val map = Map("a" -> 1, "b" -> 2)

println(map.get("a").getOrElse(3))
println(map.get("c").getOrElse(3))

aというkeyは存在するので、一行目では1が返る。cというkeyは存在しないので二行目では3が返る。

flatMapとmapを利用する

flatMapとmapを利用することで、いくつかのOption型の値がすべてSomeだった場合に処理をするということもできる。

val map = Map("a" -> 1, "b" -> 2)

map.get("a").flatMap(a =>
  map.get("b").map(b => {
    println(a + b)
    a + b
  })
)
map.get("a").flatMap(a =>
  map.get("c").map(c => {
    println(a + c)
    a + c
  })
)
map.get("c").flatMap(c =>
  map.get("b").map(b => {
    println(b + c)
    b + c
  })
)

1つ目はaというkeyもbというkeyも存在するので、3と表示される。2つ目3つ目はcというkeyが存在しないので、表示が行われない。ちなみにflatMapとmapの組み合わせで返される型はOption型になっているので、1つ目はSome(3)、2つ目3つ目はNoneが返る。

flatMapとmapの代わりにforを利用する

flatMapとmapの組み合わせを利用しているときは、必ずforを利用できる。forの使い方によってはflatMapやmapの呼び出しに変換できるためである。

val map = Map("a" -> 1, "b" -> 2)

for {
  a <- map.get("a")
  b <- map.get("b")
} { println(a + b) }

for {
  a <- map.get("a")
  c <- map.get("c")
} { println(a + c) }

for {
  c <- map.get("c")
  b <- map.get("b")
} { println(c + b) }

これも一番上だけprintされて、他はprintされない。


また値を返したい場合はyieldを用いれば良い。

val map = Map("a" -> 1, "b" -> 2)

for {
  a <- map.get("a")
  b <- map.get("b")
} yield a + b

for {
  a <- map.get("a")
  c <- map.get("c")
} yield a + c

for {
  c <- map.get("c")
  b <- map.get("b")
} yield c + b

これは一番上だけSome(3)を返し、他はNoneを返す。


ちなみにforはforeachやfilterの呼び出しに変換する場合もある。このへんは今回は詳しくは述べない。

Eitherを利用する

どんなエラーかも含めて返したい場合はEitherを利用する。Eitherは成功したらRightに値を入れ、失敗したらLeftにエラーを入れて返すことで、呼び出し元で例外処理できる。

パターンマッチを利用する

基本はパターンマッチ。

簡単のため自分でEitherのオブジェクトを作ってやってみる。

val a: Either[String, String] = Right("a")
val b: Either[String, String] = Right("b")
val c: Either[String, String] = Left("c")

a match {
  case Right(str) => println("success: " + str)
  case Left(str)  => println("failed: " + str)
}

c match {
  case Right(str) => println("success: " + str)
  case Left(str)  => println("failed: " + str)
}

これはaの場合はRightに包まれているので、success: aと表示される。cの場合はLeftなのでfailed: cと表示される。これを利用すれば例外処理できる。

flatMapとmapを利用する

EitherもflatMapとmapを利用できるので、いくつかのEither型の値がすべてRightだった場合に処理をするということができる。ただしここで難しいのが、scalaのEitherはRight優先で使われるわけではなく、どちらに対して適用するか明示的に指定しなければならない(RightProjection型やLeftProjection型に変換しないといけない)。

val a: Either[String, String] = Right("a")
val b: Either[String, String] = Right("b")
val c: Either[String, String] = Left("c")

a.right.flatMap(a =>
  b.right.map(b => {
    println(a + b)
    a + b
  })
)

a.right.flatMap(a =>
  c.right.map(c => {
    println(a + c)
    a + c
  })
)

c.right.flatMap(c =>
  b.right.map(b => {
    println(b + c)
    b + c
  })
)

こういう感じで明示的にrightを呼び出して、それに対してflatMapやmapを呼ぶ。一番上はaもbも両方Rightなので"ab"と表示される。二つ目三つ目はcが無いために何も表示されず、返ってくるのはLeft("c")となる。

Eitherを使って嬉しいのはLeftがあったら、その後は無視してそのLeftを返してくれるところにある。例えば二つのLeftを使ったコードは以下のとおり。

val a: Either[String, String] = Left("a")
val b: Either[String, String] = Left("b")

a.right.flatMap(a =>
  b.right.map(b => a + b)
)

b.right.flatMap(b =>
  a.right.map(a => a + b)
)

この二つの結果は異なり、上はLeft("a")が、下はLeft("b")が返ってくる。Leftがあったら途中で計算を諦めて、そのLeftを返してくれるのでどこで例外が起こったかも知ることができる。

forを利用する

Optionでも紹介したとおり、flatMapとmapの組み合わせはforに置き換えられる。

val a: Either[String, String] = Right("a")
val b: Either[String, String] = Right("b")
val c: Either[String, String] = Left("c")

for {
  val_a <- a.right
  val_b <- b.right
} yield val_a + val_b

for {
  val_c <- c.right
  val_b <- b.right
} yield val_c + val_b

for {
  val_a <- a.right
  val_c <- c.right
} yield val_a + val_c

これは一つ目は両方共RightなのでRight("ab")が返る。二つ目はcがLeft("c")なのでそこで計算を終了し、Left("c")を返す。

Option.toRight

ちなみにOption型からRightやLeftに変換することもできる。これを利用するとOptionで取ってきたものがなかった場合に、そのエラーを返すこともできる。toRightはSomeだったら中身をRightにくるんで返し、Noneだったら指定した値をLeftにくるんで返す。

val map = Map("a" -> 1, "b" -> 2)

for {
  val_a <- map.get("a").toRight("a: Not Found").right
  val_b <- map.get("b").toRight("b: Not Found").right
} yield val_a + val_b

for {
  val_a <- map.get("a").toRight("a: Not Found").right
  val_c <- map.get("c").toRight("c: Not Found").right
} yield val_a + val_c

これは一つ目の式はaとb両方のkeyが存在するので、Right(3)が返る。しかし二つ目の式はcが存在しないため、Left("c: Not Found")が返る。

まとめ

今回はOptionやEitherを使った例外処理の方法について簡単にまとめた。ようやく使い方はなんとなくわかったような気がする。最初の頃はScalaやってる人がEitherが右寄りじゃなくて困ると言ってたのがよく分からないけど、こうやってまとめてみると毎回.rightを呼び出さないといけなかったりして不便。

またこの辺りがわかっていると、以下の記事を読むことでさらに知識が深められるのでオススメです。


yuroyoro.hatenablog.com
yuroyoro.hatenablog.com
hakobe932.hatenablog.com

Amazon SESとSNSを利用してバウンスメールを自動的にハンドリングする

メールを送るアプリケーションを作成していると、使われていないメールアドレスで登録された時や、携帯のメールアドレス変更によって登録されているメールアドレスが使えない状態になって、メール送信時にバウンスメールとして返ってくることがある。この時バウンスメールとして返ってくるメールアドレスをアプリケーション側で無効にするなどしておかないと、メール送信の無駄が発生する。また、AWS SESを使っている場合、バウンス率が高くなった場合、規制されることもある( https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/e-faq-bn.html )。

今回は、AWSを利用してバウンスメールとして返ってきたメールアドレスを自動的にハンドリングするというのをやってみたので、それを書いてみる。

前提

今回はAWS SESを利用して、メールを送信しているということを前提とする。

概要

  • Amazon SESでバウンスメールが返ってきた時にAmazon SNSに通知する
  • Amazon SNSに通知が来たら、特定エンドポイントに向けてリクエストを飛ばす
  • エンドポイントでAmazon SNSからの通知を受け取り、無効なメールアドレスをハンドリングする

Amazon SESでバウンスメールが返ってきた時にAmazon SNSに通知する

まずAmazon SESでメールを送った際、バウンスメールが返ってきたらAmazon SNSに通知を送るようにする。Amazon SNSとはプッシュ通知サービス( https://aws.amazon.com/jp/sns/ )で、通常モバイル通知などを担当するが、SESのエラーハンドリングにも用いることが出来る。

これは https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/configure-sns-notifications.html あたりを見ながら設定すれば良い。

まず最初にAmazon SNS上で、バウンスメール通知を受け取るためのTopicを作成する。Amazon SNSのHomeからCreate Topicを選択し、Topic Nameなどを入力してTopicを作成する。
f:id:shiba_yu36:20150827091823p:plain:h500
f:id:shiba_yu36:20150827092013p:plain:h300

続いてAmazon SES上で、作成したSNS Topicに対して、Bounce通知が行われるように設定する。Amazon SESのコンソールにいき、Domains > (バウンス通知を設定したいドメイン)を選択し、View Detailsを押し、NotificationsのEdit Configurationから設定する。Bouncesに先ほどのTopicを選択すればOK。

f:id:shiba_yu36:20150827092327p:plain:h300
f:id:shiba_yu36:20150827092348p:plain:h300

最後にAmazon SNS上でそのTopicをSubscribeすれば通知されるようになる。ここではひとまずEmailでBounce通知を受け取れるようにする。Amazon SNS上で設定したいTopicを選択し、Subscribe to topicしてEmailを選択すると良い。
f:id:shiba_yu36:20150827093223p:plain:h300
f:id:shiba_yu36:20150827092609p:plain:h300

ここまででAmazon SESでメールを送った際に、送り先からバウンスメールが届いたら、Amazon SNSに通知を飛ばし、その内容をメールで受け取る、ということが出来た。

Amazon SNSに通知が来たら、特定エンドポイントに向けてリクエストを飛ばす

Amazon SNSでは、通知が届いた時にHTTPもしくはHTTPSのエンドポイントにリクエストを飛ばすように設定もできる。これは簡単。先ほどのように、Subscribe to Topicをして、HTTPもしくはHTTPSを選択し、URLを入れるだけで良い。

f:id:shiba_yu36:20150827093342p:plain:h300

エンドポイントでAmazon SNSからの通知を受け取り、無効なメールアドレスをハンドリングする

Subscribeを手動で承認する

以上の設定まで行っても、まだHTTPへの通知がPending状態になっている。そのエンドポイントに送られたSubscriptionConfirmationに応答しないといけないためである。

一番簡単な方法は、そこに通知されたJSONからSubscribeURLをコピーし、ブラウザでアクセスすることである。これでSubscribeは完了できる。

ただしこれだと毎回設定のたびに人力でアクセスすることになるので、ここを自動化する。

Subscribeを自動で承認する

Subscribeを自動で承認するには、

のを知る必要がある。上のエントリを読んでおくと良い。

まず別の場所からのなりすましのアクセスを防ぐために、署名の検証をする必要がある。これは公式でAWS SDKが提供されている場合はそれを利用すれば良い。今回はPerlで実装したので、CPANモジュールのAWS::SNS::Verifyというのを利用する。

また、他の人が作成した別の人のTopicからのSubscribeでなりすます、というのを防ぐために、TopicArnの検証も同時に行う必要がある。これは http://dev.classmethod.jp/cloud/aws/amazon-sms-message-verification/ を見ると分かりやすい。


これらの検証が済んだら、SubscribeURLに対してアクセスすれば良い。自動的に承認するためのコードは以下のとおりである。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'SubscriptionConfirmation') {
    my $furl = Furl->new(timeout => 3);
    $furl->get($sns->message->{SubscribeURL});
}

Bounce通知が届いた時に無効なメールアドレスをハンドリングする

ここまででBounce通知が届いたらエンドポイントにJSONがPOSTされるようになっているので、次は無効なメールアドレスをハンドリングする。

SESでのBounce通知の形式は https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/notification-contents.html を参照する。これによると

  • もう使えないアドレスの場合はnotificationTypeにBouncebounce->bounceTypeにPermanentが入っている
  • bounce->bouncedRecipientsに無効なメールアドレスが入っている

という事がわかる。そこで無効なメールアドレスをハンドリングするために、これらを用いる。もちろんこの時も署名の検証などは行わないといけないので注意すること。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'Notification') {
    my $ses_message = decode_json($message->{Message});
    return unless $ses_message->{notificationType} eq 'Bounce' && $ses_message->{bounce}->{bounceType} eq 'Permanent';

    my $unavailable_mail_addresses = [
        map { $_->{emailAddress} } @{ $ses_message->{bounce}->{bouncedRecipients} },
    ];

    # あとはこの$unavailable_mail_addressesに対して順に処理をしていく
}

これで無効なメールアドレスを処理することができる。

最終的なエンドポイントの実装

Subscribeを自動で承認するのと、無効なメールアドレスのハンドリングは同じエンドポイントで行わないといけないので、最終的なコードは以下のようになる。

# $reqがPlack::Requestとする
my $sns = AWS::SNS::Verify->new(body => $req->content);
return unless $sns->verify;

my $message = $sns->message;
return unless $message->{TopicArn} eq '...'; # 自分のTopicArn

if ($message->{Type} eq 'SubscriptionConfirmation') {
    my $furl = Furl->new(timeout => 3);
    $furl->get($sns->message->{SubscribeURL});
}
elsif ($message->{Type} eq 'Notification') {
    my $ses_message = decode_json($message->{Message});
    return unless $ses_message->{notificationType} eq 'Bounce' && $ses_message->{bounce}->{bounceType} eq 'Permanent';

    my $unavailable_mail_addresses = [
        map { $_->{emailAddress} } @{ $ses_message->{bounce}->{bouncedRecipients} },
    ];

    # あとはこの$unavailable_mail_addressesに対して順に処理をしていく
}

またバウンスメールのテストをしたい場合は、そのためのメールアドレスがあるので利用すると良い。
https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/mailbox-simulator.html

まとめ

今回はAmazon SESとAmazon SNSを利用して自動的にバウンスメールに対応する方法について書いた。AWSは便利。


Scalaスケーラブルプログラミングを読んだ

Scalaの勉強をしたかったので読んだ。

この本はScalaの利用について、かなり詳しく書かれている本。Scalaの使い方にとどまらず、Scalaの内部についても触れているので、どういう仕組みで動いているかについても知ることが出来る。ただし、そのためにけっこう読むのに苦労するので、Scalaをとりあえず入門しようという用途には向かなそうだった。なんとなくわかりづらい理由はScalaの使い方と内部実装と、例に上がっているアルゴリズムの理論と、型言語の理論の説明が混ざってるからだと思う。

さらっと流し読みしたけど面白いところも多かった。例えば

  • 演算子はメソッド呼び出しにマッピングされ、全てがメソッド呼び出しとして扱われる
  • forに書かれた内容が、実際にはflatMapやmap、foreach、filterなどの関数呼び出しにマッピングされることがある
  • メソッドに渡せる型制約の柔軟な指定

など。


ただし、やはり入門でこれを読むのは挫折すると思う。入門の方法はまた今度まとめようと思うが、軽く調べた感じだと、以下の様な本をさらっと流し読みするのが良さそう。まだ読んでないけど。

またこの本だけでは、Scalaの例外処理に必須といえる、OptionやEither周りの話について全く言及されていないので、この辺りは個別に勉強する必要がある。例えば以下の様なエントリが参考になる。
yuroyoro.hatenablog.com
yuroyoro.hatenablog.com
hakobe932.hatenablog.com


まとめると、勉強になる本だが、難しいところも多かった。Perlでいうところの、初めてのPerlとプログラミングPerlの間くらいの位置付けの本だなと思う。