たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

ExposedでEnumを使ってみる

PostgreSQLをデータベースと使用しているときに、一部のカラムをEnumとして表現したい。

そのためにはまず、Enumを型として定義する必要があるので作成してみる。これはテーブルの定義よりも前にやっておく。

こんな感じ。

class PGEnum<T : Enum<T>>(enumTypeName: String, enumValue: T?) : PGobject() {
    init {
        value = enumValue?.name
        type = enumTypeName
    }
}

カラムの値として受け取りたい値をenum classを使って定義する。

enum class Service {
    INSTAGRAM,
    TWITTER,
}

この二つを使って、テーブルを定義するとこんな感じになる。

第三引数と第四引数では、いわゆるfrom/to変換関数を渡している。

object Posts : UUIDTable() {
    val service = customEnumeration("service", "ServiceEnum", { value -> Service.valueOf(value as String) }, { PGEnum("ServiceEnum", it) })
}

SchemaUtils.create()を呼び出すときにexec()メソッドを呼び出して、CREATE TYPEを実行することで、新しいデータ型を定義する。

CREATE TYPEで指定するのは、customEnumeration()の呼び出し時にsql引数として渡した引数と同じ値。ここではServiceEnumを指定している。

AS ENUMで指定するのは、enum classで定義した内容と同じ値。

val database = Database.connect(
    url = jdbcURL,
    driver = driverClassName,
    user = user,
    password = password
)
transaction(database) { 
    exec(
        "DO $$ BEGIN " +
        "CREATE TYPE ServiceEnum AS ENUM ('INSTAGRAM', 'TWITTER'); " +
      "EXCEPTION " +
        "WHEN duplicate_object THEN null; " +
      "END $$;"
    )
  SchemaUtils.create(Posts)
}

ここで、注意しないといけないのが例外処理を行なっている点。

はじめて実行するときは、データ型がまだ定義されていない状態なので次のように書いたとしてもエラーは発生しない。

exec("CREATE TYPE ServiceEnum AS ENUM ('INSTAGRAM', 'TWITTER');")

ところが、2回目以降になると次のようなエラーが発生してしまう。

Exception in thread "main" org.jetbrains.exposed.exceptions.ExposedSQLException: org.postgresql.util.PSQLException: ERROR: type "serviceenum" already exists

これはすでに存在するデータ型を作成しようとしたために発生した例外。 なので、上記のコードではEXCEPTIONを使って例外処理を行いエラーの発生を制御している。

参考

DataTypes · JetBrains/Exposed Wiki · GitHub

Check if a user-defined type already exists in PostgreSQL - Stack Overflow

Exposed(DAO/DSL) で PostgreSQL からあれこれ SELECT するサンプル・覚書 - インドカレーファンクラブ

KtorとExposedの環境でちょっとデータベースにシードさせたいときのTips

Ktorでデータベースを使用したいので、com.takagimeow.infrastructure.database.daoDatabaseFactoryを実装してみます。

package com.takagimeow.infrastructure.database.dao

import com.takagimeow.infrastructure.database.models.Users
import kotlinx.coroutines.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.*
import org.jetbrains.exposed.sql.transactions.experimental.*

object DatabaseFactory {
    fun init() {
        val driverClassName = "org.postgresql.Driver"
        val jdbcURL = "jdbc:postgresql://localhost:5432/serverside"
        val user = "takagimeow"
        val password = "takagimeow_pass"
        val database = Database.connect(
            url = jdbcURL,
            driver = driverClassName,
            user = user,
            password = password
        )
        transaction(database) {
            SchemaUtils.create(Users)
        }
    }

    suspend fun <T> dbQuery(block: suspend () -> T): T =
        newSuspendedTransaction(Dispatchers.IO) { block() }
}

Application.ktで、このオブジェクトのinit()メソッドを呼び出すことでデータベースへ接続を行います。

fun Application.module() {
    DatabaseFactory.init()
    ...
}

そしてUsersDSLで定義します。

object Users : Table() {
    val id = integer("id").autoIncrement()
    val email = varchar("email", 128)
    val password = varchar("password", 128)
    val name = varchar("name", 128)
    val roleType = varchar("role_type", 128)

    override val primaryKey = PrimaryKey(id)
}

このとき、ユーザーの作成に関するルートは公開したくないけども、初期データとしてユーザーレコードをテーブルに登録したい場合を考えてみました。

プロジェクトではKoinを使いたいのでUserDaoFacadeインターフェースを作成します。

package com.takagimeow.infrastructure.database.dao

import com.takagimeow.infrastructure.database.models.*

interface UserDAOFacade {
    suspend fun addNewUser(email: String, password: String, name: String): Unit
}

そして、その実装であるUserDaoFacadeImplクラスを実装します。

package com.takagimeow.infrastructure.database.dao.impl

import com.takagimeow.infrastructure.database.dao.UserDAOFacade
import com.takagimeow.infrastructure.database.dao.DatabaseFactory.dbQuery
import com.takagimeow.infrastructure.database.models.Users
import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.*

class UserDAOFacadeImpl : UserDAOFacade {
    override suspend fun addNewUser(email: String, password: String, name: String): Unit = dbQuery {
        val insertStatement = Users.insert {
            it[Users.email] = email
            it[Users.password] = password
            it[Users.name] = name
            it[roleType] = "USER"
        }
        insertStatement.resultedValues?.singleOrNull()?.let(::resultRowToUser)
    }
}

実際は、各リポジトリでこのクラスを注入することでデータベースに対する操作を行なっていくのですが、今回はこのUserDAOFacadeImplを再利用してデータの挿入を行おうと思います。

com.takagimeow.infrastructure.databaseパッケージにseedパッケージを作成します。

このパッケージの中に、InserUser.ktを作成します。 そしてmain()を定義して、コードブロックの中でUserDaoFacadeImplクラスのaddNewUser()を呼び出します。

package com.takagimeow.infrastructure.database.seed

import com.takagimeow.infrastructure.database.dao.DatabaseFactory
import com.takagimeow.infrastructure.database.dao.impl.UserDAOFacadeImpl

suspend fun main() {
    DatabaseFactory.init()
    val dao = UserDAOFacadeImpl()
    dao.addNewUser(
        "takagimeow@example.com",
        "password",
        "takagi",
    )
}

あとは、IntelliJ IDEAで表示されているmain()の横の実行ボタンを押すだけ。

ターミナルには次の様に表示されました。

2022-11-16 02:15:03.836 [DefaultDispatcher-worker-3] DEBUG Exposed - INSERT INTO users (email, "name", "password", role_type) VALUES ('takagimeow@example.com', 'takagi', 'password', 'USER')

プロセスは終了コード 0 で終了しました

TablePlusを通してデータを確認してみました。ちゃんと登録されています。

Jetpack Composeでディープリンクを開くテストをしてみる

ディープリンクを開くには次の書式でコマンドを実行する。

adb shell am start -W -a android.intent.action.VIEW -d <ディープリンクのURI> <アプリのパッケージ名>

実際に入力するときは次の様な感じ。

adb shell am start -W -a android.intent.action.VIEW -d "testapp://home_route" com.example.testapplication

アプリのパッケージ名は、build.gradleのandroid.defaultConfig.applicationIdで確認することができる。

android {
    ...
    defaultConfig {
        applicationId "com.example.testapplication"

開きたいComposableのために、routedeepLinksを定義する。testappというディープリンクスキーマを表すSCHEMEは定数としてどこかで定義しておく。

object HomeDestination : TaNavigationDestination {
    override val route = "feedl_route"
    override val destination = "feed_destination"

    val deepLinks = listOf(
        navDeepLink {
            uriPattern =
                "$SCHEME://${route}"
        }
    )
}

navigation内で呼び出されるcomposableに対して先ほど定義したroutedeepLinksを渡す。

fun NavGraphBuilder.homeGraph(
    onUpPress: () -> Unit = {},
) {
    composable(
        route = HomeDestination.route,
        deepLinks = HomeDestination.deepLinks,
    ) { navBackStackEntry ->
        HomeRoute(
            onUpPress = onUpPress,
        )
    }
}

AndroidManifest.xmlcomposableに設定した内容に沿って<intent-filter>を追加する。

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="testapp" android:host="home_route" />
</intent-filter>
参考

アプリ コンテンツ用のディープリンクを作成する  |  Android デベロッパー  |  Android Developers

Androidの通知に表示したいアイコンを用意する

Androidでは、アプリのアイコンと通知用のアイコンは異なる。 特に異なる部分はカラーの部分で、通知用のアイコンには基本的に色をつけることはできない。

Android 5.0以降では通知機能が大幅に変更されたため、アイコンのカラーチャンネルがすべて無視される様になったのが関係しているらしい。

通知用のアイコンを考える場合、二つの要素に切り分けて考える。

1つ目は色のないアイコン。これは白色と透明な部分で構成されたsvg形式のファイルだと思って良い。アプリのロゴなどの目立たせたい部分を透明にしておくといった感じ。

もう一つは通知用のアイコンに対して動的に適用したい色の部分で、これは通知の作成時にsetColor関数に16進数の値を渡すことで設定することができる。

たとえば、次の様なアイコンを通知用に使いたい場合を考えてみる。

これをそのまま使おうとすると、Android側で色のついた部分はすべて白色に変換されてしまうため次の様に表示されてしまう。

なので、色付きだった箇所や強調したい部分を透明になるように調節した上で通知アイコンとして渡す必要がある。

ちなみに、setColor関数で色を制御できるのはあくまで通知ドロワーに表示される通知に対しての話で、ステータスバーに表示される通知アイコンの色の制御はできないので注意する必要がある。

通知を作成する際に、NotificationCompat.Builderでset系のメソッドを呼び出すタイミングでsetColor()メソッドを使って通知ドロワーに表示される際の色を指定する。

ここでは0xC0B0D7(紫)を指定している。

var builder =
        NotificationCompat.Builder(this, channelId)
            .setSmallIcon(com.example.app.R.drawable.ic_notification)
            .setColor(0xC0B0D7)
            .setContentTitle(contentTitle)
            .setContentText(contentText)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .build()

通知ドロワーでの表示は次の様な感じになる。

通知用のアイコンは、Android Studio上でResource Managerを開いて、プラスボタンクリックし、Import Drawablesをメニューから選択することで登録することができる。

svgファイルのインポートが完了すると、res/drawablexmlファイルが作成されるので、それをsetSmallIcon()の引数に指定する。

参考

Notifications Overview  |  Android Developers

通知の概要  |  Android デベロッパー  |  Android Developers

https://proandroiddev.com/android-iconography-notification-3acd3e00d6ec

Android Asset Studio - Notification icon generator

`FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory`がGitHub Actionsで発生した場合の対処法

問題

次のようなエラーが発生してしまい、GitHub Actions上で実行していたテストがFAILEDしてしまった。

<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
 1: 0xb02930 node::Abort() [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 2: 0xa18149 node::FatalError(char const*, char const*) [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 3: 0xcdd16e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 4: 0xcdd4e7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 5: 0xe94b55  [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 6: 0xe95636  [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 7: 0xea3b5e  [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 8: 0xea45a0 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/opt/hostedtoolcache/node/16.17.0/x64/bin/node]
 9: 0xea751e v8::internal::Heap::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) 
....

結論

NODE_OPTIONS: "--max-old-space-size=5120""をenvに追加する

...
- name: Run all the tests
  run: yarn test
  working-directory: ./path/into/app
  env:
    ...
    NODE_OPTIONS: "--max-old-space-size=5120"
...

参考

ヒープ領域制限によりnode.jsのビルドが通らない場合 - Qiita

JavaScript heap out of memory が発生したときに試したこと ++ Gaji-Laboブログ

tscの代わりにBabelにトランスパイルをまかせてみた

モノリポでパッケージを作る際にtscの代わりにbabelを使ってみたかったので、これを機に色々調べてみた。

参考にしたのはこのサイト。

Using Babel with TypeScript | Learn TypeScript

Babelってなんだ

Babelは主にECMAScript 2015+のコードを、古いブラウザや環境での後方互換性をもったバージョンのJavaScriptに変換するために使用されるツール。

Babelがtscに代わって使われる理由はJSXをJavaScriptに変換できるから。 tscは変換できない。なのでReactをプロジェクトで使っている場合はBabelが必要になる。

必要なパッケージのインストール

とりあえず、TypeScriptやデコレーターだったりを使いたいので、それらをトランスパイルするのに必要なパッケージをまとめてみた。

  • @babel/core
    • Babelのコアとなるライブラリ
  • @babel/preset-env
    • 最新のJavaScriptの機能を使いつつ、それらをサポートしていないブラウザをターゲットにするためのプラグインの集まり。
  • @babel/preset-typescript
    • TypeScriptのコードをJavaScriptに変換するためのプログラインの集まり
  • @babel/cli
  • babel-plugin-transform-typescript-metadata
  • @babel/plugin-proposal-decorators
  • @babel/plugin-proposal-class-properties
    • 静的なクラスプロパティとプロパティ初期化構文で宣言されたプロパティを変換するプラグイン
  • babel-plugin-module-resolver
  • tsc-alias
yarn add -D @babel/core @babel/preset-env @babel/preset-typescript @babel/cli @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators babel-plugin-module-resolver babel-plugin-transform-typescript-metadata

設定ファイルを作成する

.babelrcというファイルをルートディレクトリに作成する。

中身は次のようにする。

{
  "presets": ["@babel/preset-env", "@babel/preset-typescript"],
  "plugins": [
    "babel-plugin-transform-typescript-metadata",
    ["@babel/plugin-proposal-decorators", { "version": "legacy" }],
    "@babel/plugin-proposal-class-properties",
    [
      "module-resolver",
      {
        "root": ["."],
        "alias": {
          "~": "./src"
        }
      }
    ],
  ]
}

babel-plugin-transform-typescript-metadata@babel/plugin-proposal-decoratorsよりも前に記述しないといけないので注意する。

トランスパイルはBabelに任せて、tscでは型チェックのみを行いたいので、noEmitfalseに、declarationtrueに、そしてemitDeclarationOnlytrueに設定する。

...
"compilerOptions": {
  ...
  "outDir": "dist",
  ...
  "noEmit": false, // 型定義ファイルは出力したいのでfalse 
  "declaration": true, // 型定義ファイルの生成を指示する
  "emitDeclarationOnly": true, // 型定義ファイルのみ生成する
  ...
},
...

outDirはBabelの出力先と一致させておく。ここではdistを設定する。

スクリプトを追加する

buildスクリプトとしてpackage.jsonに次のコマンドを追加する。

...
"scripts": {
  "build": "tsc -p tsconfig.json -d && tsc-alias -p tsconfig.json && npx babel ./src --extensions '.ts,.tsx' --out-dir ./dist",
  ...
}
...

--out-file dist/index.jsではなく--out-dir ./distを設定することに注意する。

もし--out-file dist/index.jsを指定してしまうと、./src/index.tsのみがトランスパイルされて、distディレクトリに出力される。たとえ./src/index.ts内で./src/classes/Sample.tsをインポートしていたとしてもdist/classesSample.jsが出力されることはない。

tsc-aliasを使わないと~/classes/...のようなエイリアスパスがそのまま型定義ファイルに出力されてしまう。なのでそれを防ぐためにtscによる型定義ファイルの出力後に、tsc-aliasを使って型定義ファイル中のエイリアスパスを相対パスに置き換える必要がある。

module-resolverの設定

tsconfig.jsonで次のようにエイリアスを設定している場合

...
"paths": {
  "~/*": ["./src/*"],
},
"baseUrl": ".",
...

module-resolverの設定は次のようにする。

...
[
  "module-resolver",
  {
    "root": ["."],
    "alias": {
      "~": "./src"
    }
  }
],
...

まとめ

Reactなどのフレームワークを触り始めたばかりのころに、WebpackやらBabelやらという当時の自分にはわからない単語が多すぎたせいで、なぜか今でもBabelを敬遠してしまっていた。

今回改めて自分で設定ファイル書いたりプラグイン追加してみたりしたけど、案外難しいことはなかった。

やっぱり自分で使ってみるのが一番ですね。

参考

Using Babel with TypeScript | Learn TypeScript

TypeScript: Documentation - Using Babel with TypeScript

TypeScript で型を検査する|【React/Redux】カンバンボードを実装して Web フロントエンド上級者を目指そう!|Techpit

GitHub - microsoft/tsyringe: Lightweight dependency injection container for JavaScript/TypeScript

GitHub - tleunen/babel-plugin-module-resolver: Custom module resolver plugin for Babel

Next.js + TypeScript + デコレータ + reflect-metadata を動かす

GitHub - justkey007/tsc-alias: Replace alias paths with relative paths after typescript compilation

React Native製のアプリで受け取ったプッシュ通知をタップして特定の画面に遷移させる方法

目次

  • FirebaseコンソールからServer Keyを取得する
  • Server KeyをExpoのサーバーにアップロードする
  • 必要なパッケージをインストールする

FirebaseコンソールからServer Keyを取得する

Expoが認証情報を使って開発者の代わりにExpoのサーバーからプッシュ通知を送信するためには、Server KeyをExpoにアップロードする必要がある。

このキーはFirebaseコンソールから確認することができる。

Firebase プロジェクトの設定ページにアクセスして、Cloud Messagingタブをクリックする。

Server KeyはCloud Messaging API (Legacy)でのみ利用可能なので、デフォルトでは無効になっています。なので3点メニューをクリックして、Google Cloud ConsoleでAPIを管理をクリックします。

Google CloudコンソールのCloud Messagingの詳細ページが開かれたら、有効にするボタンをクリックする。

有効になったら、自動的に次のようなページに遷移する。

Cloud Messagingタブのページに戻ったらCloud Messaging API有効になっているのが確認できる。その下にサーバーキーが表示されているのでコピーして控えておく。

Server KeyをExpoのサーバーにアップロードする

先ほど控えておいたServer KeyをExpoのサーバーにアップロードする。<your-token-here>の部分を実際のトークンに置き換えて次のコマンドを実行する。

expo push:android:upload --api-key <your-token-here>

これでアプリをインストールしているユーザーは、プロジェクトの認証情報を使用してFCMから通知を受け取ることができるようになる。

必要なパッケージをインストールする

プッシュ通知に対するハンドリングを行うのに必要なパッケージは次の2つ。

  • expo-device
  • expo-notifications
expo install expo-device expo-notifications

プッシュ通知の送信に必要なトークンを取得するための関数を実装する

プッシュ通知の送信にはメッセージを送信する宛先(Expo Push Token)の情報が必要になる。

そのトークンは実機上でしか取得できないので、クライアント上でgetPermissionsAsyncrequestPermissionsAsyncを呼び出して権限の可否を確認して、getExpoPushTokenAsyncを使って実際のトークンを取得する。

import * as Device from "expo-device";
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";

export async function registerForPushNotifications() {
  let token = "";
  // 端末上でこの関数が実行されているかを確認する
  if (Device.isDevice) {
    // 通知の権限の状態を取得する
    const { status: existingStatus } =
      await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;
    // 通知が拒否されている場合
    if (existingStatus !== "granted") {
      // アラートを表示して、通知の許可を取得する
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    // 結局通知を拒否された場合
    if (finalStatus !== "granted") {
      alert("Failed to get push token for push notification!");
      return;
    }
    token = (await Notifications.getExpoPushTokenAsync()).data;
  } else {
    alert("Must use physical device for Push Notifications");
  }

  if (Platform.OS === "android") {
    Notifications.setNotificationChannelAsync("default", {
      name: "default",
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: "#FF231F7C",
    });
  }

  return token;
}

OSがAndroidの場合は、setNotificationChannelAsyncメソッドを呼び出して、第一引数で指定したチャンネルIDにチャンネル構成を割り当てる。 こにれより、サーバー側でExpoPushMessage型のオブジェクトを組み立てる時に、channelIdにここで指定したチャンネルIDを指定すると、ここで設定したチャンネル構成で通知が届くようになる。

トークンの取得に成功したら、任意のタイミングでサーバーに送信して保存するようにしてみる。

// screens/SetupWizard/NotificationSetupScreen.tsx
...
const expoPushTokenMutation = useFunctionsCall<
  UpdateConfigWithExpoPushTokenRequestData,
  UpdateConfigWithExpoPushTokenResponseData
>(functions, "updateConfigWithExpoPushToken");
...
// 通知権限の許可とExpo Push Tokenを取得する
const token = await registerForPushNotifications();
// トークンをexpo_push_tokensカラムの配列に追加する
if (token) {
  expoPushTokenMutation.mutate({
    expo_push_token: token,
  });
}
...

通知がタップされた場合の挙動を定義するためのフックを実装する

expo-notificationsから公開されているuseLastNotificationResponseフックを使うことで、直近でタップした通知の情報をレスポンスとして取得することができる。 この内容をuseEffectで処理することで、アプリがフォアグラウンドだろうと、閉じていた状態から通知をタップしてアプリが起動した状態だろうと、そのタップした通知のdataから求められている遷移先をurlとして取得することができる。

// hooks/useExpoNotifications.ts
import * as Notifications from "expo-notifications";
import { useEffect } from "react";
import * as Linking from "expo-linking";

export function useExpoNotifications() {
  const lastNotificationResponse = Notifications.useLastNotificationResponse();
  useEffect(() => {
    // data(JSON)の例
    // { "url": "scheme://path/into/app" }
    if (
      lastNotificationResponse &&
      lastNotificationResponse.actionIdentifier ===
        Notifications.DEFAULT_ACTION_IDENTIFIER
    ) {
      if (
        lastNotificationResponse.notification.request.content.data.url &&
        typeof lastNotificationResponse.notification.request.content.data
          .url === "string"
      ) {
        Linking.openURL(
          lastNotificationResponse.notification.request.content.data.url
        );
      }
    }
  }, [lastNotificationResponse]);

  return {
    response: lastNotificationResponse,
  };
}

あとはこのフックをRootNavigatorなどで呼び出すと良い。すると通知がタップされれば任意のアクションを実行することができる。 ここでは特定の画面に遷移するようにしている。

// navigation/index.tsx
...
const Stack = createNativeStackNavigator<RootStackParamList>();
...
function RootNavigator() {
  ...
  const { response } = useExpoNotifications();
  ...
  return (
    <Stack.Navigator>
    ...
    </Stack.Navigator>
  )
}

まとめ

今回の記事は前回公開したこちらの記事と一緒に読んでいただけるとより理解が深まるのではと思います。

takagimeow.hatenablog.com

expo-notificationsの詳細についてはこちらの公式ドキュメントを参照していただけると助かります。

Notifications - Expo Documentation