Upgrade to Pro — share decks privately, control downloads, hide ads and more …

KotlessとDynamoDBで自分のツイートを収集するサーバーレスアプリケーションを作る

 KotlessとDynamoDBで自分のツイートを収集するサーバーレスアプリケーションを作る

2021年12月21日(火) 「集まれKotlin好き!Kotlin愛好会 vol.33 @オンライン」の談義資料です。

D1531f9547e24397c7e85881fac03096?s=128

Takehata Naoto

December 21, 2021
Tweet

More Decks by Takehata Naoto

Other Decks in Programming

Transcript

  1. Kotless とDynamoDB で自分のツイートを収集するサーバーレス アプリケーションを作る 2021 年12 月21 日 Kotlin 愛好会

    vol.33 竹端 尚人
  2. 自己紹介

  3. 概要 竹端 尚人 フリーランスエンジニア Twitter: @n_takehata 職種: バックエンドエンジニア 好きな言語:Kotlin Server-Side

    Kotlin 、Java 、Go 、etc… ( 少し前まで) スマートフォンゲーム開発 昨年12 月からフリーランスに
  4. 登壇、執筆など CEDEC2018 、2019 登壇 Software Design 2019 年2 〜4 月号で短期連

    載 Swift/Kotlin 愛好会で技術書典執筆 https://booth.pm/ja/items/1315478 書籍「Kotlin サーバーサイドプログラミング 実践開発」を2021 年4 月に発売 https://gihyo.jp/book/2021/978-4-297- 11859-4
  5. 今回のテーマ

  6. Kotless というサーバーレスフレームワークを 使ったアプリケーション

  7. この内容はKotlin Advent Calendar 2021 24 日目の記事として ブログでも公開する予定です

  8. アジェンダ 1. Kotless とは? 2. 作ろうとしているアプリケーション 3. Kotless でツイートを収集してDynamoDB へ登録

    4. Kotless でDynamoDB からデータの取得 5. まとめ
  9. 1.Kotless とは?

  10. Kotlin で実装できるサーバーレスフレームワーク ざっくり言うと、Kotlin でアプリケーションを実装してAWS Lambda などにデプロイしたりできるもの コードはKotlin で実装し、Kotless のGradle タスクを実行すると裏側でLambda

    を構成するためのTerraform のファイルなどが作られ、AWS のResource 作成からアプリケーションのデプロイまでやってくれる Node.js やPython を使わず、Lambda のコードを「Kotlin で書きたい!」という願いを叶えてくれる夢のある フレームワーク
  11. 導入方法はブログでも紹介しています Kotless でKotlin のアプリケーションをAWS Lambda にデプロイする https://blog.takehata-engineer.com/entry/deploy-kotlin-applications-to-aws-lambda-using-kotless

  12. 簡単なサンプル

  13. Gradle にplugin とDSL を追加 plugins { kotlin("jvm") version "1.4.32" apply

    true id("io.kotless") version "0.1.7-beta-5" apply true } dependencies { // ・・・ 省略 implementation("io.kotless", "kotless-lang", "0.1.7-beta-5") } Kotlin のバージョンは1.5 以上だとエラー出てたので、一旦1.4.32 で検証中。
  14. Gradle にAWS の構成を追加 kotless { config { // ①S3 のバケットの名前を指定

    bucket = "kotless-example-takehata" // ②$HOME/.aws/credentials の情報を指定 terraform { profile = "default" region = "us-west-2" } } webapp { lambda { memoryMb = 1024 timeoutSec = 120 } } }
  15. 関数を一つ実装 import io.kotless.dsl.lang.http.Get @Get("/") fun main() = "Hello world!" Kotless

    DSL の @Get アノテーションなどを使ってルーティングできる ` `
  16. Gradle でKotless のタスクをいくつか実行していく

  17. plan タスクを実行 $ ./gradlew plan

  18. Terraform のplan の結果が出力される # aws_api_gateway_deployment.root will be created + resource

    "aws_api_gateway_deployment" "root" { // ・・・ } # aws_api_gateway_integration.get will be created + resource "aws_api_gateway_integration" "get" { // ・・・ } // ・・・ Plan: 14 to add, 0 to change, 0 to destroy. ------------------------------------------------------------------------ Note: You didn't specify an "-out" parameter to save this plan, so Terraform can't guarantee that exactly these actions will be performed if "terraform apply" is subsequently run. BUILD SUCCESSFUL in 53s
  19. この時に裏ではTerraform のファイルを作って実行されている

  20. deploy タスクを実行 $ ./gradlew deploy

  21. Terraform のapply と、AWS Lambda へのアプリケーションのデ プロイが実行される data.aws_s3_bucket.kotless_bucket: Refreshing state... data.aws_caller_identity.current:

    Refreshing state... data.aws_region.current: Refreshing state... data.aws_iam_policy_document.kotless_static_assume: Refreshing state... data.aws_iam_policy_document.get_assume: Refreshing state... data.aws_iam_policy_document.get: Refreshing state... // ・・・ Apply complete! Resources: 14 added, 0 changed, 0 destroyed. Outputs: application_url = https://8443r74qk8.execute-api.us-west-2.amazonaws.com/1 BUILD SUCCESSFUL in 1m 16s
  22. deploy の結果に出ていたURL でアクセスできる $ curl https://8443r74qk8.execute-api.us-west-2.amazonaws.com/1 Hello World!

  23. これでLambda 周りの構成とアプリケーションのデプロイが完了

  24. AWS 上のリソースはどうなっているか?

  25. S3 にtfstate ファイルと、アプリケーションのjar ファイルが アップロードされる

  26. Lambda には関数が作られる

  27. Lambda の関数に紐付いたAPI Gateway も作られる

  28. Kotlin を書いてポチポチするだけで サーバーレスアプリケーションができました

  29. ちょっとデモ

  30. Ktor とSpring Boot のDSL もある

  31. Ktor DSL // dependencies にKtor DSL を追加 implementation("io.kotless", "ktor-lang", "0.1.7-beta-5")

    class Server : Kotless() { override fun prepare(app: Application) { app.routing { get("/") { call.respondText { "Hello World!" } } } } } Ktor と同様の書き方でAPI を実装できる
  32. Spring Boot DSL // dependencies にSpring Boot DSL を追加 implementation("io.kotless",

    "spring-boot-lang", "0.1.7-beta-5") @SpringBootApplication open class Application : Kotless() { override val bootKlass: KClass<*> = this::class } @RestController object Main { @GetMapping("/") fun main() = "Hello World!" } Spring Boot と同様の書き方でAPI を実装できる
  33. チームや要件に合わせてやりやすい書き方を選べる ( 複数のDSL をdependencies に追加するとエラーになります)

  34. Kotless の基本まとめ Kotlin を書いてポチポチやるとLambda のリソースとアプリケーションのデプロイをしてくれる Ktor やSpring Boot like な書き方もできる

  35. 2. 作ろうとしているアプリケーション

  36. カコミエールというアプリの模倣

  37. 愛用していたが結構前に使えなくなり・・・ 2018 年からアップデートがなく、Twitter API の仕様変更とかの影響か過去が表示されなくなった 作ろうと思ったが、Twitter API は通常だと1 週間前までのデータしか取得できないことが判明 課金すればもっと過去も取れるが、リクエスト数の制限もあり一般公開のアプリには使えない

    search/universal という本来は公開されていないAPI を使えば取れるらしいが怪しい
  38. 別の方法で自分用に作ることを考える

  39. 定期的にTwitter API を叩いて保存しておく方法を取る Daily でTwitter API を叩いて、DynamoDB に保存しておく 過去のツイートはDynamoDB から取る形で実現する

    過去分に関しては、自分のアカウントのものならTwitter から全データJSON でダウンロードすることができ るようなので、それを登録する
  40. 自分が使うだけだしLambda とかでいいのでは?

  41. せっかくだからKotless 使ってみよう!

  42. この後紹介していく実装 Twitter API をDaily で叩いてDynamoDB に保存するバッチ DynamoDB から特定の月日のツイートを取ってくるAPI API はKotless

    DSL で実装
  43. せっかくSwift/Kotlin の合同会なので クライアントアプリもSwift UI で作ってくる予定だったけど 間に合わなかったのでまた今度・・・

  44. 3.Kotless でツイートを収集してDynamoDB へ登録

  45. 必要なライブラリの追加 dependencies { implementation("io.kotless", "kotless-lang", "0.1.7-beta-5") implementation("com.amazonaws:aws-java-sdk-dynamodb:1.12.126") implementation("org.twitter4j:twitter4j-core:4.0.7") } Kotless

    DSL DynamoDB のJava SDK Twitter4J(Java 製のTwitter のサードパーティライブラリ)
  46. サードパーティーライブラリも普通にGradle に追加するだけで 使えます

  47. DynamoDB のテーブル テーブル名: Tweet 物理名 意味 型 id ツイートのID( パーティションキー)

    数値 tweet_date ツイートした年月日 文字列 tweet_time ツイートした時分秒 文字列 tweet_text ツイートした文章 文字列 グローバルセカンダリインデックス パーティションキー ソートキー tweet_date tweet_time
  48. 共通で使う部分

  49. DynamoDB のテーブルに関連付けるobject を作る @DynamoDBTable("Tweet", PermissionLevel.ReadWrite) object TweetTable { // ・・・

    } @DynamoDBTable アノテーションを付与することで、Lambda の関数のIAM ロールにDynamoDB へのアク セスのポリシーを追加してくれる この設定では AmazonDynamoDBFullAccess のポリシーが追加される このobject の中にDynamoDB へアクセスする関数を作っていく ` ` ` `
  50. Twitter API 関連の設定 private val twitterClient = TwitterFactory( ConfigurationBuilder().setDebugEnabled(true) .setOAuthConsumerKey("XXXXXXXXXXXXXXXXXXXX")

    .setOAuthConsumerSecret("XXXXXXXXXXXXXXXXXXXX") .setOAuthAccessToken("XXXXXXXXXXXXXXXXXXXX") .setOAuthAccessTokenSecret("XXXXXXXXXXXXXXXXXXXX") .build() ).instance Twitter4J を使い、自分のTwitter API の認証情報を設定する( 事前にTwitter API の登録が必要) この後のコードではこの twitterClient を使用します ` `
  51. 取得したツイート情報を保持するデータクラス data class Tweet(val id: Long, val time: LocalDateTime, val

    text: String) Twitter API のレスポンスからこのクラスに変換して保持する ツイートのID 、ツイートした時間、ツイートの本文を保持
  52. 登録処理の関数

  53. fun putTweetList(accountName: String, since: String, until: String) { val query

    = Query("from:$accountName since:$since until:$until") val queryResults = twitterClient.search(query).tweets val list = queryResults.map { Tweet(it.id, LocalDateTime.ofInstant(it.createdAt.toInstant(), ZoneId.systemDefault()), it.text) } val client = AmazonDynamoDBClientBuilder.defaultClient() list.forEach { val time = it.time val values = mapOf( "id" to AttributeValue().withN(it.id.toString()), "tweet_date" to AttributeValue().withS( TABLE_DATE_FORMAT.format( time.year, time.month.value, time.dayOfMonth ) ), "tweet_time" to AttributeValue().withS(TABLE_TIME_FORMAT.format(time.hour, time.minute, time.second)), "tweet_text" to AttributeValue().withS(it.text) ) val request = PutItemRequest().withItem(values).withTableName("Tweet") client.putItem(request) } }
  54. 引数とTwitter API での結果取得 fun putTweetList(accountName: String, since: String, until: String)

    { val query = Query("from:$accountName since:$since until:$until") val queryResults = twitterClient.search(query).tweets val tweetList = queryResults.map { Tweet(it.id, LocalDateTime.ofInstant(it.createdAt.toInstant(), ZoneId.systemDefault()), it.text) } アカウント名、検索対象の開始と終了の日時( yyyy-MM-dd_HH:mm:ss_JST の形式の文字列) が引数 Query に検索条件を設定し、 search 関数で実行(Query の引数の文字列はTwitter の検索窓にそのまま入 れて使えます) 結果を Tweet クラスのList に変換 ` ` ` ` ` ` ` `
  55. DynamoDB への登録 val client = AmazonDynamoDBClientBuilder.defaultClient() tweetList.forEach { val time

    = it.time val values = mapOf( "id" to AttributeValue().withN(it.id.toString()), "tweet_date" to AttributeValue().withS( "%d-%02d-%02d".format( time.year, time.month.value, time.dayOfMonth ) ), "tweet_time" to AttributeValue().withS("%02d:%02d:%02d".format(time.hour, time.minute, time.second)), "tweet_text" to AttributeValue().withS(it.text) ) val request = PutItemRequest().withItem(values).withTableName("Tweet") client.putItem(request) } } AmazonDynamoDBClientBuilder でdefault のCredentials を使用したDynamoDB のclient を生成 登録する値はKey が文字列、Value が AttributeValue 型のMap を使用する PutItemRequest クラスで値のMap とテーブル名を指定し、 putItem で実行 ` ` ` ` ` ` ` `
  56. 登録処理をDaily で実行する関数

  57. @Scheduled("0 0 1/1 * ? *") private fun putTweetList() {

    val TIME_FORMAT = "\"%d-%02d-%02d_%02d:%02d:%02d_JST\"" val accountName = "account_name" val lastDate = LocalDateTime.now(ZoneId.of("Asia/Tokyo")).minusDays(1) val year = lastDate.year val month = lastDate.month.value val day = lastDate.dayOfMonth val since = TIME_FORMAT.format(year, month, day, 0, 0, 0) val until = TIME_FORMAT.format(year, month, day, 23, 59, 59) putTweetList(accountName, since, until) } @Scheduled アノテーションで、AWS のcron 形式で実行条件を指定できる( これは1 日1 回実行される設定) 現在日時の1 日前の年月日を取得( 毎日前日のツイートを収集する想定) Twitter API で必要な文字列形式に変換して、前述の登録処理の関数を実行 ` `
  58. 本当はcron の指定は定数が用意されているが・・・ こういう感じで指定できるはずだが @Scheduled(Scheduled.everyHour) @Scheduled(Scheduled.everyDay) everyDay の定数の指定方法が間違ってて動かない(day のフィールドに0 を指定しているため) @Target(AnnotationTarget.FUNCTION)

    annotation class Scheduled(val cron: String, val id: String = "") { @Suppress("unused") companion object { const val everyMinute = "0/1 * * * ? *" const val every5Minutes = "0/5 * * * ? *" const val every10Minutes = "0/10 * * * ? *" const val everyHour = "0 0/1 * * ? *" const val every3Hours = "0 0/3 * * ? *" const val everyDay = "0 0 0/1 * ? *" } } ( とりあえずPR 投げて待ってます) ` `
  59. 登録処理は完成

  60. 4. 検索処理の関数Kotless でDynamoDB からデータの取得

  61. レスポンスとして使うデータクラス data class GetTweetListResponse(val id: Long, val time: String, val

    text: String) 表示で使用するデータのため、time を文字列にしている
  62. fun getTweetListByMonthDay(month: Int, day: Int): Map<Int, List<GetTweetListResponse>> { val twitterUser

    = twitterClient.showUser("account_name") val startYear = LocalDateTime.ofInstant(twitterUser.createdAt.toInstant(), ZoneId.systemDefault()).year val currentYear = LocalDateTime.now().year val client = AmazonDynamoDBClientBuilder.defaultClient() val index = DynamoDB(client).getTable("Tweet").getIndex("datetime-index") val tweetMap = mutableMapOf<Int, List<GetTweetListResponse>>() for (year in startYear..currentYear) { val date = "$year-$month-$day" val query = QuerySpec() .withProjectionExpression("id, tweet_date, tweet_time, tweet_text") .withKeyConditionExpression("tweet_date = :v_date") .withValueMap(ValueMap().withString(":v_date", date)) val queryResults = index.query(query) tweetMap[year] = queryResults.map { GetTweetListResponse( it.getLong("id"), "${it.getString("tweet_date")} ${it.getString("tweet_time")}", it.getString("tweet_text") ) } } return tweetMap }
  63. 引数とTwitter を開始した年、現在の年の取得 fun getTweetListByMonthDay(month: Int, day: Int): Map<Int, List<GetTweetListResponse>> {

    val twitterUser = twitterClient.showUser("account_name") val startYear = LocalDateTime.ofInstant(twitterUser.createdAt.toInstant(), ZoneId.systemDefault()).year val currentYear = LocalDateTime.now().year Twitter API から showUser 関数でアカウントのの情報を取得 アカウントの作成日時から開始した年を、現在日時から現在の年を取得 開始した年から現在の年までのツイートを取得するのに使用する ` `
  64. DynamoDB の検索のためのclient を生成 val client = AmazonDynamoDBClientBuilder.defaultClient() val index =

    DynamoDB(client).getTable("Tweet").getIndex("datetime-index") 登録の時と同じく、default のCredentials を使用したDynamoDB のclient を生成 グローバルセカンダリインデックスを使用して検索するので、Index のオブジェクトを取得
  65. DynamoDB からデータを取得し、年ごとのMap に設定して返却 val tweetMap = mutableMapOf<Int, List<GetTweetListResponse>>() for (year

    in startYear..currentYear) { val date = "$year-$month-$day" val query = QuerySpec() .withProjectionExpression("id, tweet_date, tweet_time, tweet_text") .withKeyConditionExpression("tweet_date = :v_date") .withValueMap(ValueMap().withString(":v_date", date)) val queryResults = index.query(query) tweetMap[year] = queryResults.map { GetTweetListResponse( it.getLong("id"), "${it.getString("tweet_date")} ${it.getString("tweet_time")}", it.getString("tweet_text") ) } } return tweetMap } 開始の年から現在の年までループを回す QuerySpec で取得するカラム、検索条件を指定してクエリを組み立てる クエリを実行し、結果をKey が年、Value がその年のツイートのList のMap に追加 ` `
  66. 取得処理を呼び出すAPI のルーティング

  67. GET でルーティングする関数を作り、取得処理を呼び出す @Get("/find") fun findTweet(month: Int, day: Int) = getTweetListByMonthDay(month,

    day)
  68. ここまでできたらplan →deploy を実行する

  69. DyanamoDB に日々データが積まれていく

  70. API で年ごとのデータを取得 $ curl "https://mdibxq3aai.execute-api.us-west-2.amazonaws.com/1/find?month=12&day=17" {2011=[], 2012=[], 2013=[], 2014=[], 2015=[],

    2016=[], 2017=[], 2018=[], 2019=[], 2020=[], 2021=[GetTweetListResponse(id=1471816801135001604, time=2021-12-17 12:17:25, text= 満を持して強力なMacBook Pro を注文した。メモリ64GB の世界がどんなもんなのか楽しみ。 ただ納品が遠い https://t.co/Q4UeiMPJI2)]}
  71. 完成!

  72. 改善しようと思っている点 年と月日を別のカラムにして、月日だけで検索できるようにする ツイートID 指定で検索するAPI の追加

  73. 5. まとめ

  74. Kotless はKotlin で書いてポチポチやったらAWS Lambda にアプリケーションが作れる サードパーティライブラリなども使って、通常のKotlin のアプリケーションと同じ感覚で作れる これからはサーバーレスもKotlin に!

  75. None