言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

KoinのDIの仕組み

はじめに

みなさん、Koinはお好きだろうか?

KoinとはKotlin用の軽量DI Frameworkである。

insert-koin.io

基本的には下記の3 stepでお手軽にDIの仕組みを提供してくれる。

  1. moduleの宣言
  2. Koinのstart
  3. 任意の場所でinject

Injectする際にinject()というcustomのproperty delegateを使うことができる。おしゃれだ。

val service : BusinessService by inject()

このinject()の中身で何をしているか気になってコードをのぞいて見た

inline fun <reified T : Any> ComponentCallbacks.inject(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }

lazy()でinstance生成するのもまた良い。といういのは置いておいて、このget()の先で何が起こっているのか気になってコードを読んでみた。

注意事項

今回は2020/5/4時点での最新バージョンである2.1.5のコードを読んでいく。 KoinはOSS projectであり、ソースコードGitHubホスティングされている。

github.com

DIの仕組みを読んでいく

1. moduleの宣言

moduleの宣言は下記のように行う。(公式のsample codeより抜粋)

// Given some classes 
class Controller(val service : BusinessService) 
class BusinessService() 

// just declare it 
val myModule = module { 
  single { Controller(get()) } 
  single { BusinessService() } 
} 

module() というDSLの中身はこんな感じ。

fun module(createdAtStart: Boolean = false, override: Boolean = false, moduleDeclaration: ModuleDeclaration): Module {
    val module = Module(createdAtStart, override)
    moduleDeclaration(module)
    return module
}

ModuleDeclarationはtypealiasである。実体はlambdaである。

typealias ModuleDeclaration = Module.() -> Unit

というわけでmodule定義時にlambdaで渡している2行のsingle()というDSLが実行される。
single()の定義はこんな感じ。

    inline fun <reified T> single(
            qualifier: Qualifier? = null,
            createdAtStart: Boolean = false,
            override: Boolean = false,
            noinline definition: Definition<T>
    ): BeanDefinition<T> {
        return Definitions.saveSingle(
                qualifier,
                definition,
                rootScope,
                makeOptions(override, createdAtStart)
        )
    }

Definitions.saveSingle()の実装を追っていくとBeanDefinitionというclassのinstanceを生成してscopeのdefinitionに登録したのちBeanDefinitionを返却している。(singleは定義の実装から分かるようにroot scopeに登録される。)

    inline fun <reified T> saveSingle(
        qualifier: Qualifier? = null,
        noinline definition: Definition<T>,
        scopeDefinition: ScopeDefinition,
        options: Options
    ): BeanDefinition<T> {
        val beanDefinition = createSingle(qualifier, definition, scopeDefinition, options)
        scopeDefinition.save(beanDefinition)
        return beanDefinition
    }

BeanDefinitionが何かと言うとdata classである。

data class BeanDefinition<T>(
        val scopeDefinition: ScopeDefinition,
        val primaryType: KClass<*>,
        val qualifier: Qualifier? = null,
        val definition: Definition<T>,
        val kind: Kind,
        val secondaryTypes: List<KClass<*>> = listOf(),
        val options: Options = Options(),
        val properties: Properties = Properties(),
        val callbacks: Callbacks<T> = Callbacks()
)

上記のようにDI対象のKClassやらqualifierやらを保持している。このBeanDefinitionScopeDefinitiondefinitionsにSetとして保持される。

class ScopeDefinition(val qualifier: Qualifier, val isRoot: Boolean = false, private val _definitions: HashSet<BeanDefinition<*>> = hashSetOf()) {

    val definitions: Set<BeanDefinition<*>>
        get() = _definitions

    fun save(beanDefinition: BeanDefinition<*>, forceOverride: Boolean = false) {
        if (definitions.contains(beanDefinition)) {
            if (beanDefinition.options.override || forceOverride) {
                _definitions.remove(beanDefinition)
            } else {
                val current = definitions.firstOrNull { it == beanDefinition }
                throw DefinitionOverrideException("Definition '$beanDefinition' try to override existing definition. Please use override option or check for definition '$current'")
            }
        }
        _definitions.add(beanDefinition)
    }

2. Koinのstart

AndroidであればApplicationクラスのonCreate()、Kotlinであればmain()の中でstartKoin()DSLを読んでKoinをstartさせる。

fun main(vararg args : String) { 
  startKoin {
    modules(myModule)
  }
} 

modules()DSLModuleのvarargもしくはList<Module>をparameterにとるが、どちらにしろ最終的にmodules(modules: List<Module>)がコールされる。

    fun modules(modules: List<Module>): KoinApplication {
        if (koin._logger.isAt(Level.INFO)) {
            val duration = measureDuration {
                loadModules(modules)
            }
            val count = koin._scopeRegistry.size()
            koin._logger.info("loaded $count definitions - $duration ms")
        } else {
            loadModules(modules)
        }
        if (koin._logger.isAt(Level.INFO)) {
            val duration = measureDuration {
                koin.createRootScope()
            }
            koin._logger.info("create context - $duration ms")
        } else {
            koin.createRootScope()
        }
        return this
    }

loggerの設定で条件分岐をやっているけれど、基本的にはやることはloadModules()createRootScope()の2点。

まずはloadModules()をみていく。このfunctionを辿っていくとKoin#loadModules()にたどり着く。

    fun loadModules(modules: List<Module>) = synchronized(this) {
        _modules.addAll(modules)
        _scopeRegistry.loadModules(modules)
    }

_modulesModuleのHashSetなのであまりここでは気にしない。
ScopeRegistry#loadModules()はmodules listの要素である各moduleをparameterにしてloadModule()をコールしている。

    private fun loadModule(module: Module) {
        declareScope(module.rootScope)
        declareScopes(module.otherScopes)
    }

    private fun declareScope(scopeDefinition: ScopeDefinition) {
        declareDefinitions(scopeDefinition)
        declareInstances(scopeDefinition)
    }

declareDefinitions()ScopeRegistryのメンバ変数のHashMapにScopeDefinitionを追加する。

次にdeclareInstances()。まずはScopeRegistryが保持しているscopeのHashMapからparameterとScopeDefinitionが一致するscopeを見つけ出す。ScopeクラスはInstanceRegistryを保持しており、ここにscopeDefinitionが保持しているBeanDefinition (1の最後で追加したやつ)を登録する。

    fun saveDefinition(definition: BeanDefinition<*>, override: Boolean) {
        val defOverride = definition.options.override || override
        val instanceFactory = createInstanceFactory(_koin, definition)
        saveInstance(
                indexKey(definition.primaryType, definition.qualifier),
                instanceFactory,
                defOverride
        )
        definition.secondaryTypes.forEach { clazz ->
            if (defOverride) {
                saveInstance(
                        indexKey(clazz, definition.qualifier),
                        instanceFactory,
                        defOverride
                )
            } else {
                saveInstanceIfPossible(
                        indexKey(clazz, definition.qualifier),
                        instanceFactory
                )
            }
        }
    }

    private fun saveInstance(key: IndexKey, factory: InstanceFactory<*>, override: Boolean) {
        if (_instances.contains(key) && !override) {
            error("InstanceRegistry already contains index '$key'")
        } else {
            _instances[key] = factory
        }
    }

ちなみにsaveInstance()の1st parameterのためにコールしているindexKey()はclassのfull nameとqualifier(もしあれば)の組み合わせのStringになっている。

fun indexKey(clazz: KClass<*>, qualifier: Qualifier?): String {
    return if (qualifier?.value != null) {
        "${clazz.getFullName()}::${qualifier.value}"
    } else clazz.getFullName()
}

3. 任意の場所でinject

「はじめに」で記載した通り、inject()のproperty delegateはlazyでget()をコールしている。この先を読んでいく。

inline fun <reified T : Any> ComponentCallbacks.inject(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }
inline fun <reified T : Any> ComponentCallbacks.get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
): T = getKoin().get(qualifier, parameters)

Koin##get()をみてみると、

    @JvmOverloads
    inline fun <reified T> get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
    ): T = _scopeRegistry.rootScope.get(qualifier, parameters)

Scope#get()はこんな感じ。

    inline fun <reified T> get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
    ): T {
        return get(T::class, qualifier, parameters)
    }
    fun <T> get(
        clazz: KClass<*>,
        qualifier: Qualifier? = null,
        parameters: ParametersDefinition? = null
    ): T {
        return if (_koin._logger.isAt(Level.DEBUG)) {
            val qualifierString = qualifier?.let { " with qualifier '$qualifier'" } ?: ""
            _koin._logger.debug("+- '${clazz.getFullName()}'$qualifierString")
            val (instance: T, duration: Double) = measureDurationForResult {
                resolveInstance<T>(qualifier, clazz, parameters)
            }
            _koin._logger.debug("|- '${clazz.getFullName()}' in $duration ms")
            return instance
        } else {
            resolveInstance(qualifier, clazz, parameters)
        }
    }
    @Suppress("UNCHECKED_CAST")
    private fun <T> resolveInstance(
        qualifier: Qualifier?,
        clazz: KClass<*>,
        parameters: ParametersDefinition?
    ): T {
        if (_closed) {
            throw ClosedScopeException("Scope '$id' is closed")
        }
        //TODO Resolve in Root or link
        val indexKey = indexKey(clazz, qualifier)
        return _instanceRegistry.resolveInstance(indexKey, parameters)
            ?: findInOtherScope<T>(clazz, qualifier, parameters) ?: getFromSource(clazz)
            ?: throwDefinitionNotFound(qualifier, clazz)
    }

Index keyを生成し、Scopeが保持しているInstanceRegistryからinstanceを取ってくる。

    @Suppress("UNCHECKED_CAST")
    internal fun <T> resolveInstance(indexKey: IndexKey, parameters: ParametersDefinition?): T? {
        return _instances[indexKey]?.get(defaultInstanceContext(parameters)) as? T
    }

これで2の最後でInstanceRegistryに登録されたinstanceを取得してinject完了。

雑感

scopeを実装する必要があるせいか思ったより複雑だった。まぁ複雑と言ってもDaggerみたいにcode generationするようなやつと比べるとシンプルで好き。エラーもわかりやすいし。

言いたいことは以上です。

AndroidからGoogle Sign In経由、AWS Cognitoでuser管理してDynamo DBを操作する ~AWS Java SDKを添えて~

ふと、「そういえばNo SQLのDBって業務で関わったことないな」と思いついたのでAWS DynamoDBをさわってみることにした。
Androidから直接さわるためAWS Java SDKを利用したが、ググって引っかかる情報は結構古くてもう存在しないAPIを使っていたり、公式documentのsampleコードも使えなかったりと苦労したのでメモを残しておく。
とはいえSDKコードgithubで公開されているし、Java docも用意されているので、これからさわる人はこっちからコードを辿ったりした方が早いかもしれない。

1.やったこと概要

f:id:muumuumuumuu:20200416180342p:plain

  1. Android appからGoogle Sign In
  2. 1で取得したtokenを用いてAWS CognitoにOpen ID Connectでlog in
  3. 2で取得したcredentialを用いてAWS DynamoDBのtableをread/write

2. 手順詳細

Google Sign In を実装する

まずは画面ぽちぽちしてFirebase側の設定を終わらす。このあたりを参考にしたらできるので手順は省略。

firebase.google.cn

developers.google.cn

設定が終わったらコードを書いていく。まずapp moduleのbuild.gradleにDependenciesを追加する。

implementation 'com.google.firebase:firebase-auth:19.3.0'
implementation 'com.google.android.gms:play-services-auth:17.0.0'

認証画面のonCreate()GoogleSignInClientを初期化。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.default_web_client_id))
            .requestEmail()
           .build()
   googleSignInClient = GoogleSignIn.getClient(this, gso)
}

onStart()ですでにGoogle Log in 済み状態かどうか取得。前回のLog in情報が残っていればGoogle Sign Inはスキップ。

 override fun onStart() {
    super.onStart()

    val account = GoogleSignIn.getLastSignedInAccount(this)
    if (account == null) {
        authorizeWithGoogle()
    } else {
        logInToCognito(account)
    }
}

未Log in状態であればGoogleが用意しているLog inボタンを表示してclick listenerを設定する。

private fun authorizeWithGoogle() {
    setContentView(R.layout.activity_launch)

    findViewById<SignInButton>(R.id.sign_in_button).apply {
        setSize(SignInButton.SIZE_WIDE)
        setOnClickListener {
            // 第二引数のrequest code(RC_SIGN_IN)は適宜定義してください
            startActivityForResult(googleSignInClient.signInIntent, RC_SIGN_IN)
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LaunchActivity">

    <com.google.android.gms.common.SignInButton
        android:id="@+id/sign_in_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

上記のボタンが押下されるとGoogleSignIn側でいい感じにLog in処理が走ってくれるので結果を受け取るコードを書く。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == RC_SIGN_IN) {
        handleSignInResult(GoogleSignIn.getSignedInAccountFromIntent(data))
    }
}

private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
    try {
        val account = completedTask.getResult(ApiException::class.java)
        if (account != null) {
            logInToCognito(account)
        } else {
            // handle error case
        }
    } catch (ex: ApiException) {
        // handle error case
    }
}

Open ID Connectを使用してGoogleのuser account情報を元にAWS Cognitoにsing in する

この辺りを参考にしてAWSのmanegement画面をぽちぽちしてIdentity Poolを作成する。

dev.classmethod.jp

Cognitoの設定が終わったらコードを書く。

まずはbuild.gradleにaws sdkのdependenciesを追加

def aws_android_sdk_version = '2.15.0'
implementation "com.amazonaws:aws-android-sdk-core:$aws_android_sdk_version"
implementation "com.amazonaws:aws-android-sdk-ddb:$aws_android_sdk_version"

このサンプルコードでは画面間でCognitoCachingCredentialsProviderを共有したいので、雑にApplicationクラスに持たせるようなコードを書いている。

private fun logInToCognito(account: GoogleSignInAccount) {
    val application = application as MyApplication
    application.initCredentialProvider(account.idToken)
}

Applicationクラスはこんな感じ。

class MyApplication : Application() {

    var credentialsProvider: CognitoCachingCredentialsProvider? = null

    fun initCredentialProvider(token: String?) {
        credentialsProvider = CognitoCachingCredentialsProvider(
            applicationContext,
            YOUR_IDENTITY_POOL_ID,
            YOUR_REGION
        ).apply {
            logins = mapOf("accounts.google.com" to token)
        }
    }
}

これでDynamo DBにアクセスするためのcredential providerの準備は完了。

Dynamo DBにアクセスする

再びAWSのmanagement画面側をぽちぽちしてDynamo DBにTableを定義する。Tableが定義されるとAmazon Resource Name (ARN)が発行されるので、CogniteでIdentity Poolを作った時に自動で作成されたIAM RoleにこのARNへのpermissionをつける。Resourceへのpermissionだけでなく、Read/WriteといったActionsへのpermissionも一緒につける。

ぽちぽちが終わったらAndroid側のコードに戻る。
Credential providerからAmazonDynamoDBClientを生成してDynamo DBにアクセスする。ここでsetRegion()を設定する必要があることに気がつかずしばらくはまってしまった…

val dynamoDBClient = AmazonDynamoDBClient(credentialsProvider).apply {
    setRegion(Region.getRegion(Regions.US_EAST_1)) // Regionは適宜置き換えてください
}

ここから先実際にtableにアクセスするにはnetwork通信が走るので別threadで処理を実行する必要がある。サンプルコードでは雑にKotlin coroutine(しかも一部)で書いているので適宜使いたい非同期処理に置き換えてほしい。

まずはread系。table全体のitemsを取得したい時はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.scan(ScanRequest(YOUR_TABLE_NAME))
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

このresultScanResult型になっていて、getItems()経由で各itemのListを取得することができる。さらにこのListの型が<String, AttributeValue>のMapとなっている。このAttributeValueが若干曲者で、例えばNumber型が欲しい場合はgetN()を呼ぶ必要があるが、戻り値自体はStringなのでtoInt()を呼んだりしてキャストしてやる必要がある。詳細は下記のdoc参照。

docs.aws.amazon.com

また、これはscanに限らずtableにアクセスする場合全般に言える話だが、tokenが切れていたらNotAuthorizedExceptionがthrowされるので、この場合は手動でtokenをrefreshする必要がある。

条件を指定するquery()の使い方はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.query(
            QueryRequest(YOUR_TABLE_NAME).apply {
                keyConditionExpression = "id = :id"
                expressionAttributeValues = mapOf(
                    ":id" to AttributeValue(credentialsProvider.identityId)
                )
            }
        )
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例ではidというpartition keyが存在するtableに対し、ログイン中のユーザのIDに一致するitemsを取得している。
key condition expressionの記述方法はJava doc を参照。また、このdocに書いてある通り、

Use the ExpressionAttributeValues parameter to replace tokens such as :partitionval and :sortval with actual values at runtime.

とのことなので、AttributeValueでwrapしたvaluesetExpressionAttributeValues()で設定する必要がある。

次にWrite系。

まずはtableにitemを追加するputItem()のメモ。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.putItem(
            PutItemRequest(
                YOUR_TABLE_NAME,
                mapOf(
                    "id" to AttributeValue(credentialsProvider.identityId),
                    "name" to AttributeValue(account.displayName),
                    "image_url" to AttributeValue(account.photoUrl.toString())
                )
            )
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

追加したいItemのattributeをmapで渡すだけ。ここでもvalueAttributeValueでwrapする必要がある。

最後にAtomic Counterを使ってItemを更新する場合の例。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.updateItem(
            UpdateItemRequest()
                .withTableName(YOUR_TABLE_NAME)
                .withKey(
                    mapOf(
                        "partition_key" to AttributeValue(partitionKey),
                        "sort_key" to AttributeValue(sortKey)
                    )
                )
                .apply {
                    updateExpression = "add #count :i"
                    expressionAttributeNames = mapOf(
                        "#count" to "count"
                    )
                    expressionAttributeValues = mapOf(
                        ":i" to AttributeValue().apply {
                            n = "1"
                        }
                    )
                }
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例では、withKey() でpartition keyとsort keyを渡して更新したいitemを指定し、更新の条件はapply{}節の中に記述している。サンプルではcountというNumber型のattributeを現在のものから1インクリメントしている。ちなみにAttributeValueのpublic constructorはStringを取れるのだが、ここにStringである"1"を渡してしまうとString型のvalueとして処理されてしまうので、この場合はparameterなしconstructorを使ってinstanceを生成してからsetN()でString "1"を渡す必要がある。

言いたいことは以上です。

"Hacking with iOS Online" を完走した

産休中で暇だったので、オンラインの無料iOSハンズオンコース的なやつである "Hacking with iOS Online" をコツコツ進めており、無事本日完走した✌️

www.hackingwithswift.com

よかった点

このコースには39個のプロジェクトがあり、平日一日一つずつ終わらせていった。毎日新しいプロジェクトで作るアプリも変わるので飽きずに続けられてよかった。

本来英語ネイティブの人であれば1プロジェクト1時間くらいでサクッと終わるのだろうが、自分は英語を読むのが遅いのでもっと時間がかかってしまった。でも英語の勉強も兼ねていると思えば悪くないかも。ボキャブラリーも増えた気がする。

全体として、「iOS(UIKit)でこれやろうとしたらどれくらい大変なのかな?」がちょっとだけ体感できたのでよかった。ただし、ほとんどのプロジェクトはロジックがとても少なく、大体の処理がViewControllerに入っているので設計周りの知見は全く得られなかった。(iOS devでどういった設計が流行っているのか、なんのlibraryがよく使われているのかなど。)

以下、普段Androidを書いていた自分が「iOSだとこんな簡単にできるのか!!」とびっくりしたポイントのメモを残しておく。

f:id:muumuumuumuu:20200406180350p:plain

f:id:muumuumuumuu:20200406180452p:plain

f:id:muumuumuumuu:20200406180523p:plain

保育園の新園開設情報ページの更新を監視したい

産休に入って時間があるので、今まで手動でチェックしていた保育園の新園開設情報ページの更新をスクレイピングして監視することにした。1

0. 注意事項

スクレイピングに関しては実行前に一度 こちらを読むことをお勧めします。
今回自分のケースでいうと、事前に以下を確認している。

  • 個人利用であること
  • 週に一度アクセスするだけなので、アクセス対象に負荷をかけないこと
  • アクセス対象のサイトのポリシーを確認し、問題ないこと

また、普段Androidを書いているので微妙なPythonのコードとかあるかもしれないし、AWSの各種サービスの構成も「もっとこうすれば?」みたいなのあるかもしれない。その場合はコメントで教えてください。

1. 概要

f:id:muumuumuumuu:20200311165429p:plain

  1. AWS CloudWatch EventでAWS Lambdaを実行するscheduleのルールを作成
  2. キックされるLambda関数で自治体の新園開設情報ページを見に行き更新日時を取得
  3. AWS S3に前回の更新日時を置いておいて、差分があるかチェック
  4. (差分がある場合) S3のファイルを更新し、Slackに更新があった旨を通知
    (差分がない場合) Slackに更新がなかった旨を通知2

作業の順番としては、2->3->4->1 の順で説明していく。

2. 手順詳細

自治体の新園開設情報ページを見に行き更新日時を取得

今回はPython(3.7.6)を使ってscriptを書いていく。以下使用するlibraryとその理由。

  • boto3 (1.12.16)
    • s3に対するAPI requestをするための公式library
  • requests (2.23.0)
    • API requestを行うためのlibrary. 後述のBeautifulSoup4にrequest情報を渡すだけであればstandard libraryのurllib等でもよかったが、今回はSlack APIを叩く必要もあったためこちらを採用
  • BeautifulSoup (4.8.2)
    • HTMLをparseしてくれる便利な子。CSS selector使えて最高っぽい。

CSS selectorについてはこちらの記事を参考にGoogle Chromeを使って簡単に任意の要素のcssを取得することができる。最高! 自分のケースだと保育園の新園開設情報ページに 更新日:20xx年y月z日 みたいな記載があるのでこの部分のCSS selectorを使用した。

コードはこんな感じ。URL等は適宜置き換えて欲しい。ローカルで試しに動かしたい人は各libraryを実行前にそれぞれpipでinstallしておいてください。

import requests
from bs4 import BeautifulSoup

new_open_page_url = 'page_url_to_check'
new_open_soup = BeautifulSoup(requests.get(new_open_page_url).content, 'html.parser')
latest_update = new_open_soup.select_one("paste_css_selector_here").text

前回の更新日時をおいておくファイルをS3に用意する

AWS S3のバケットに新しいファイルを用意する。(手順は特に説明しないので各自ググって欲しい)
このファイルに前回の更新日時を保存しておき、最新の更新日時と比較することで更新があったかどうかを判断する。

ローカルでこのPython scriptを動かしたい場合はファイルへ読み書き権限を設定したuserの AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY環境変数に設定しておく。

コードはこんな感じ。これでS3に置いたファイルをローカルにdownloadできる

import boto3
s3_bucket_name = 'your_bucket_name'
s3_object_key_name = 'your_object_name'
download_file_path = f'/tmp/{s3_object_key_name}'

s3 = boto3.resource('s3')
s3.Object(s3_bucket_name, s3_object_key_name).download_file(download_file_path)

前回の更新日時と最新の更新日時を比較する

差分があった場合となかった場合にそれぞれslackに通知するmessageを設定する。また、差分があった場合のみ、ローカルに置いたファイル内容を変更する。

with open(download_file_path, 'r+') as last_update_file:
    last_update = last_update_file.read()

    if last_update == latest_update:
        message = 'No new info'
    else:
        message = f'<!channel> News!!!!!!! {new_open_page_url}'

        last_update_file.seek(0)
        last_update_file.truncate()
        last_update_file.write(latest_update)

S3のファイルを更新する

s3.Object(s3_bucket_name, s3_object_key_name).upload_file(download_file_path)

Slackに通知を飛ばす

まずはSlack appを作成する。こちらのページの左上の Create new app のボタンを押下し、適当な名前と通知を飛ばしたいworkspaceを入力する。
appが作られたら次は OAuth & Permissions のメニューから権限のscopeの設定を行う。今回はmessageを投げたいだけなので、 chat:write のscopeのみ設定する。
設定が終わったら同じページの上部にある Install App to Workspaceのボタンをポチッとしてappをinstallする。確認画面に飛ぶので、workspaceとpermissionを確認して Allow をポチる。これでOAuth Access Tokenが発行されるので、このappのWeb APIが叩けるようになる。

Tokenが発行されたのでコードに戻る。SlackにMessageを投げるAPIの仕様はこちら。このAPIapplication/json のparameterを受け付けるとある。が、注意しないといけないのは

  • ここに並んでいるparameterのうちtokenだけはjson parameterではなくheaderに詰めろと書いてある
  • 明示的にheaderにcontent typeでjsonを指定しろと言っている

コードはこんな感じになる。環境変数SLACK_TOKEN としてslackのtokenを設定しておく。

import json
slack_post_url = 'https://slack.com/api/chat.postMessage'
token = os.environ['SLACK_TOKEN']
headers = {
    'content-type': 'application/json',
    'authorization': f'Bearer {token}'
}
payload = {
    'channel': 'your_channel',
    'text': message,
}
requests.post(slack_post_url, data=json.dumps(payload), headers=headers)

このコードを実行する前に、対象channelにあらかじめ作成したappをinviteしておく必要がある。これをやっておかないとapiを叩いてもerrorになる。ちなみにerrorになると言ってもstatus codeは200で返ってくるので混乱する。(messageにerrorが記述されている。)

Lambdaにscriptを登録する

今回はlibraryを使っているので、Python scriptだけでなくlibraryを含めた環境をzipに固めてLambdaにuploadする。

zipに固めてuploadする場合はhandler関数を定義しておく必要があるので、今までのコードをまるっと一つの関数にくくってしまって(ちゃんとしている人は適宜適切なサイズの責務・関数に切り出してください :pray: )、handler関数からこの関数を呼び出すようにする。

# handler関数
def lambda_handler(event, context):
    get_new_open_info()

def get_new_open_info():
    new_open_page_url =  # 以下省略

zipで固める用のdirectoryを用意して、そこに各種Libraryをinstallしておく。

$ mkdir workspace
$ cd workspace
$ pip install beautifulsoup4 -t . // 残りのlibraryも同様にinstall

scriptをこのdirectoryにcopyするのもお忘れなく。

環境が整ったらzipに固める。

$ zip -r upload.zip *

AWS consoleで新しいlambda関数を登録する。適切な関数名・ロール等を設定し、関数を作成。
関数の設定画面でzipファイルをuploadし、handlerに scriptファイル名.handler関数名 を指定する。
関数の設定の下部の環境変数設定メニューでSlackのtokenを環境変数に設定する。(S3アクセス用の環境変数等はロールの方に設定したので今回は特に指定しない。)
また、デフォルトだとtime outの設定が3秒なのでちょっと長めに15秒くらいにした。

各種設定を変更したら設定を保存してテスト実行して問題ないことを確認する。

Cloud Watch Eventで定期実行するようにする

前項で作成したLambda関数設定ページのDesignerメニューからトリガーを追加する。トリガーには Could Watch Events/Event Bridge を選択。
スケジュール式で週に一回実行するように指定する。注意するのは日時設定がGMTになること。日本時間で指定したい場合はJST(+0900)を考慮する必要がある。(我が家の場合は毎週日曜の朝に夫と定例会議をやるので、それに間に合うように日曜朝に実行されるように設定した。3

cron(0 0 ? * SUN *)

cron式の構文についてはこちらの記事を参考にした。

以上。

3. おまけ

上記コードを書いた後に requests_html というLibraryを教えてもらった。こちらはrequestsとbs4のwrapperのようなものっぽい。これからコード書くのであればbs4の代わりにこれ使えばいいと思う。

github.com


  1. 区役所に保育園の相談に行った時に、窓口の方から「新園情報は定期的にチェックしてください!」とアドバイスをいただいたため

  2. 差分がない場合は特に通知しなくてもいいんだけど、ちゃんとscriptが期待通りに動いているか確認するためにこの場合でも通知することにした

  3. お役所のWebページなんだから土日は更新ないんじゃないの?と思ったりしたけど、RSSフィードを購読していると普通に土日でも更新が通知されるのでびっくりする。いつお休みしているのだろう…

androidx.benchmarkの1.0.0がリリースされたので試してみる

Android Dev Summit 2019の下記のセッションで軽くふれられていた androidx.benchmark の1.0.0がリリースされたようなのでさわってみます。このセッションはbenchmarkの使い方を説明したものではないけど、色々と面白いのでおすすめです。

www.youtube.com

それではやっていきましょう。

0. Reference

Jetpack Benchmarkの公式referenceはこの辺り。

developer.android.com

API referenceはこの辺り。

developer.android.com

で、これらをどうやって使っていくかはこの辺りにまとまっています。

developer.android.com

1. Benchmark用のmoduleを作る

最初に紹介した動画でも言及されていたのですが、benchmarkを測る時はdebug modeをOFFにすることが奨励されています。 こういったconfigurationを他のアプリのモジュールから分離するために専用のmoduleを作ります。

Android StudioはBenchmark moduleを作るためのtemplateが用意されているのでこれを利用します。ただし、Android Studio 3.5系を使っている場合はこのtempleteを使うためには手動で下記の設定が必要です。

  1. Help > Edit Custom Properties をクリック。(「idea.propertiesのファイルが今ないから作る?」って聞かれたら Create を選択して下さい。)
  2. 下記を1行追加してAndroid Studioを再起動する
npw.benchmark.template.module=true

で、ここからはAndroid Studio 3.6からと共通。Templateを使ってbenchmark moduleを用意します。

  1. Projectを右クリックして New > Module を選択
  2. Moduleの選択肢が出てくるので Benchmark Module を選んで Next をクリック
  3. Module名とか色々変えたかったら変更して Finishをクリック

これでProject rootの下に benchmark moduleが作成されます。

このmoduleの build.gradle をみると androidx.benchmark:benchmark にすでに依存が付いています。ただしtemplateで指定されたversionが古いままだったりする😇1.0.0 だとpackage nameも変わっているので注意です。下記に変えてやりましょう。

androidTestImplementation 'androidx.benchmark:benchmark-junit4:1.0.0'

また、gradleのsyncが下記のエラーで失敗したりする。

androidx.benchmark.AndroidBenchmarkRunner, in project benchmark, which is no longer valid as it has been moved to androidx.benchmark.junit4.AndroidBenchmarkRunner.

というわけで正しい依存をつけてエラーをとってやりましょう。

    defaultConfig {

-        testInstrumentationRunner 'androidx.benchmark.AndroidBenchmarkRunner'
+        testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner'
    }

また、このmoduleの androidTest 下に作られたAndroidStudioにはdebug modeをOFFにする設定がすでに記述されています。便利!

project_root/benchmark/src/androidTest/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.muumuu.benchmark">

    <!--
      Important: disable debugging for accurate performance results

      In a com.android.library project, this flag must be disabled from this
      manifest, as it is not possible to override this flag from Gradle.
    -->
    <application
        android:debuggable="false"
        tools:ignore="HardcodedDebugMode"
        tools:replace="android:debuggable" />
</manifest>

2. benchmarkを書いていく

BenchmarkはInstrumentation testになります。benchmarkを作るには BenchmarkRule クラスを使っていきます。余談ですが、このクラスにはいくつかサブクラスが存在していて、Activity用のbenchmarkを測りたい時は ActivityTestRulectivityScenarioRuleを使うようです。 UIのbenchmarkを測る時は @UiThreadTestアノテーションを使います。

今回はActivityを作るのが面倒だったのでdata processingでbenchmarkを測っていきます。最近同僚に教えてもらったちょうどいい記事があるのでこれを試します。

blog.kotlin-academy.com

この記事の内容を軽く紹介すると、データサイズが大きい場合に、Kotlinの IterableSequence だとdata processのstepが2以上ある場合はSequence使ったほうが早いよーという話です。

こんな感じで 1から1000のlistを作ってSequenceIterableで全く同じ操作をしてそれぞれのbenchmarkを測ります。

@RunWith(AndroidJUnit4::class)
class SequenceBenchmark {

    @get:Rule
    val benchmarkRule = BenchmarkRule()

    private val dataSet = (1..1000).toList()

    @Test
    fun logSequence() {
        benchmarkRule.measureRepeated {
            dataSet.asSequence()
                .filter {
                    it.rem(2) == 0
                }
                .map {
                    it * 100
                }
                .average()
        }
    }

    @Test
    fun logIteration() {
        benchmarkRule.measureRepeated {
            dataSet
                .filter {
                    it.rem(2) == 0
                }
                .map {
                    it * 100
                }
                .average()
        }
    }
}

で、普通のtestを実行するようにtestを実行します。以下結果。確かにSequenceの方が早い。

Started running tests
benchmark:        63,038 ns SequenceBenchmark.logSequence
benchmark:        87,656 ns SequenceBenchmark.logIteration

感想

意外と簡単にシュッとできるので便利でした。Templeteがupdateされれば言うことない。
言いたいことは以上です。

ConstraintLayout 2.0でFlexboxLayoutとGridLayoutを代用する

はじめに

ConstraintLayout 2.0から導入された Flow を使ってFlexboxLayoutやGridLayoutと同じようなことができるようになる。

androidstudio.googleblog.com

developer.android.com

こんな感じでできた。
(上がFlexboxLayoutっぽいやつ、下がGridLayoutっぽいやつ)

f:id:muumuumuumuu:20190928193852p:plain:w200

ConstraintLayoutは 2.0.0-beta2 を使っている。 コードサンプルはこちら

github.com

FlexboxLayoutっぽいのを作ってみる

<androidx.constraintlayout.helper.widget.Flow
    android:id="@+id/flexbox_flow"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_margin="16dp"
    android:orientation="horizontal"
    app:constraint_referenced_ids="text_1,text_2,text_3,text_4,text_5"
    app:flow_wrapMode="chain"
    app:flow_horizontalStyle="packed"
    app:flow_verticalStyle="packed"
    app:flow_horizontalGap="8dp"
    app:flow_verticalGap="8dp"
    app:flow_firstHorizontalBias="0"
    app:flow_horizontalBias="0"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintBottom_toTopOf="@id/grid_flow"
    />

配置したい要素となるviewとは別に Flow を定義してやる。以下各attributeの説明

  • 要素は app:constraint_referenced_ids でカンマ区切りで指定
  • FlexboxLayoutっぽくするポイントは app:flow_wrapModechain を指定すること
  • app:flow_horizontalStyleapp:flow_verticalStylepacked を指定しているので詰めて表示される
  • 各要素のmerginは app:flow_horizontalGapapp:flow_verticalGap で指定
  • layout_constraintHorizontal_bias などが効かないので、代わりに app:flow_horizontalBias を使う

GridLayoutっぽいのを作ってみる

    <androidx.constraintlayout.helper.widget.Flow
        android:id="@+id/grid_flow"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_margin="16dp"
        android:orientation="horizontal"
        app:constraint_referenced_ids="number_1,number_2,number_3,number_4,number_5,number_6,number_7,number_8,number_9"
        app:flow_wrapMode="aligned"
        app:flow_maxElementsWrap="3"
        app:flow_horizontalGap="4dp"
        app:flow_verticalGap="4dp"
        app:layout_constraintTop_toBottomOf="@id/flexbox_flow"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

FlexboxLayoutっぽい方とほとんど一緒だが、違いは app:flow_wrapModealigned を指定すること。あと、1行(orientationがverticalの場合は1列)にいくつ要素を配置するかを app:flow_maxElementsWrapで指定する。

終わりに

かなり使いやすい印象なので、早くbetaが取れて欲しい。FlexboxLayoutとGridLayoutではなくてConstraintLayoutを使う理由は何かあるのかと思う人がいるかもしれないが、個人的にはこの辺りの理由でConstraintLayoutを推す。

  • デザイン変更が容易にできる(各要素は変更せず、Flowだけの変更で対応できる。運がよければだけど)
  • Layoutがnestしないのでパフォーマンス観点で期待できる(計測して無いですごめんなさい)
  • 同じくConstraintLayout 2.0 から追加された LayerCircular Reveal などのdecoratorが使いたくなるかも?

Androidのdark theme

Androidのdark themeをさわってみたのでメモを残す。

Dark themeとは?

公式documentはこちら

developer.android.com

電池の節約だったり、low visionなuser向けにより良いUIを提供することを目的に、黒背景のthemeがAndroid Q からサポートされた。

アプリでDark themeに対応する

App Theme

まずはApplicationのThemeのparentをDayNightにする

<style name="AppTheme" parent="Theme.AppCompat.DayNight">

もしくはMaterialComponentsの方を使ってもいいらしい。

<style name="AppTheme" parent="Theme.MaterialComponents.DayNight">

MaterialComponentsの方がいい感じに色をそれっぽく自動で変えてくれる気がする。 (ActionBarのcolorPrimaryの色とかbuttonとか。)

Resource

dark themeがONになっているときは基本的にvalues-night などnightのqualifierがついたresource directoryに置いているものが読まれる。 (values-notnight も作ることができる。defaultをdark themeにしたいときはこれを使えば良さそう。)

dark themeで変えなきゃいけないのはcolorとアイコンくらいかなぁ。

f:id:muumuumuumuu:20190608155245p:plain

ちなみにdark themeは端末のSettings > Display > Dark Theme からONにすることができる。 Qより前のOS(Pieとか)でも同じメニューが存在するが、ONにしても values-night 配下のリソースが使われないので注意。

Dark themeを動的に切り替える

端末のDark Themeの設定ではなく、アプリ固有でDark Themeを設定することもできる。

例えば端末の設定に関わらず、アプリ全体でDark ThemeをONにしたいときは下記のような1行を呼んでやれば良い。

AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_YES)

アプリ全体ではなく、Activity単位でDark ThemeをONにしたいときは下記のような1行を呼んでやれば良い。

// Call this from AppCompatActivity
delegate.localNightMode = MODE_NIGHT_YES

引数に使える定数は以下(deprecatedなものは除く)

const values description
MODE_NIGHT_AUTO_BATTERY 3 システムの Battery Saver 機能が有効のときはdark modeを使って、それ以外はlight modeになる。
MODE_NIGHT_FOLLOW_SYSTEM 0 システムの night modeに従う。
MODE_NIGHT_NO 1 常にlight modeを使う。また、notnight qualified resourcesが時間に関わらず(夜中でも)使われる。
MODE_NIGHT_UNSPECIFIED 100 night modeをunspecifiedしたい時に使う。主にsetLocalNightMode() でdefaultのnight modeに戻したい時に使うっぽい。
MODE_NIGHT_YES 2 常にdark modeを使う。また、 night qualified resourcesが時間に関わらず(昼間でも)使われる。

おまけ

実装だけではなく、material designとしてのdark themeの考え方も読んでみると面白い。 背景(elevation = 0)は黒なんだけど、Surface (elevation = 1)は黒みがかった灰色。dark modeはelevationを影の強さで表現することができないので、高いelevationになるほど白みがかって行くことで表現しているらしい。なるほど。 material.io