It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

Firebase AuthのsignInWithRedirectがSafariで動かない件のメモ

概要

FirebaseAuthの signInWithRedirect をそのまま使うと、Safariで動かないという問題があったので事象の理解と対応のメモ

問題のサマリー

問題のissueはhttps://github.com/firebase/firebase-js-sdk/issues/6716に上がっている。

要約すると、

  • リダイレクト先のデフォルトは <project>.firebaseapp.com というドメインになる
  • FirebaseAuthのログイン処理にiframeを使っているが、クロスドメインになると一部のブラウザ(Safari, Firefox)でセキュリティエラーになるため使用できない

解決方法

issueにも書かれているが、Googleが正式にドメインを提示している。

https://firebase.google.com/docs/auth/web/redirect-best-practices?hl=ja

詳しくは、記事を見てもらえば良いが、主な解決は以下。

  • クロスドメインにならないように、デフォルトの<project>.firebaseapp.comではなく自アプリのドメインを使うようにする
  • signInWithRedirect を使うのをやめ signInWithPopup を使用する
  • signInWithRedirectもsignInWithPopupも使わず自分で認証処理を書く

ReactのSuspenseコンポーネントことはじめ

概要

React18から正式に使えるようになったSuspenseという機能について、自身であまり積極的につかってこなかったため改めて、理解をまとめる。

公式ドキュメント

https://react.dev/reference/react/Suspense

整理

使い方

<Suspense fallback={<Loading />}>
  <Component />
</Suspense>

中断されるコンポーネントとはどんなものか

中断されるコンポーネントは、レンダリング中に「Promise を throw」する。

Promiseをthrowする とはどういう状態なのかというと、以下のように文字通り。Promiseインスタンスをthrow構文でスコープ外にスローする。

throw new Promise()

throw はエラー以外も投げられる

throw構文は、一般的にエラーに対する例外処理として使われ、頭がその作りだったため、「Promiseをthrowする」という設計について、理解が追いついていなかったが、Javascriptの構文上は、throwではエラーインスタンス以外のデータ型も使える。

よって当然、Promiseのインスタンスもthrowして、親コンポーネントで捉えることができる。

function throwNum() {
  throw 1;
}

function throwString() {
  throw "hoge";
}

function throwObj() {
  throw {
    type: "type",
    data: "data"
  };
}

function catchTest() {
  try {
    throwNum();
  } catch (e) {
    console.log(e);
  }
  try {
    throwString();
  } catch (e) {
    console.log(e);
  }
  try {
    throwObj();
  } catch (e) {
    console.log(e);
  }
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/throw

Promiseをthrowするデータ取得例

import { Suspense } from "react";

let executingPromise: PromiseWrapper<any> | undefined;

type State<T> =
  | {
      type: "pending";
      promise: Promise<T>;
    }
  | {
      type: "fulfilled";
      result: T;
    }
  | {
      type: "rejected";
      error: unknown;
    };

// Promiseの状態によって、Error, Promiseを適切にthrowするためのラッパークラス
class PromiseWrapper<T> {
  #state: State<T>;

  constructor(promise: Promise<T>) {
    const p = promise
      .then((result) => {
        this.#state = {
          type: "fulfilled",
          result: result
        };
        return result;
      })
      .catch((e) => {
        this.#state = {
          type: "rejected",
          error: e
        };
        throw e;
      });
    this.#state = {
      type: "pending",
      promise: p
    };
  }

  get(): T {
    switch (this.#state.type) {
      case "pending": {
        throw this.#state.promise;
      }
      case "fulfilled": {
        return this.#state.result;
      }
      case "rejected": {
        throw this.#state.error;
      }
    }
  }
}

// 実際の非同期データ処理
async function fetchTestData(): Promise<string> {
  // テストのため3秒遅延
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return "test data";
}

// データ取得処理
// Promiseの状態をみて、
// 処理中 => Promiseをthrowする
// 成功 => データを返す
// 失敗 => Errorをthrowする
function fetchData(): string {
  if (!executingPromise) {
    executingPromise = new PromiseWrapper(fetchTestData());
  }
  return executingPromise.get();
}

// 中断される可能性のあるコンポーネント
function SuspendedComponent() {
  const data = fetchData();
  return <div>{data}</div>;
}

export default function App() {
  return (
    <div className="App">
      <Suspense fallback={"loading..."}>
        <SuspendedComponent />
      </Suspense>
    </div>
  );
}

https://codesandbox.io/s/suspensenodetaqu-de-li-ddxjdk?file=/src/App.tsx

まず、中断される可能性があるコンポーネントSuspendedComponent でこれは、Suspenseで囲われる。

function SuspendedComponent() {
  const data = fetchData();
  return <div>{data}</div>;
}

SuspendedComponentは、useEffectなどは使わず直接関数コンポーネントのなかでfetchDataを呼び出す。

これが可能なのは、fetchDataが非同期ではなく同期的関数のため。

async function fetchTestData(): Promise<string> {
  // テストのため3秒遅延
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return "test data";
}

しかし、実際のデータ取得関数は、Promiseを返す非同期関数となっている。これはAPIからデータ取得を行う多くのケースでそうだと思う。

この非同期関数から返されるPromiseを同期的関数に変換するための仕組みとして PromiseWrapper というクラスが存在する。

class PromiseWrapper<T> {
  ...
  get(): T {
    switch (this.#state.type) {
      // 未完了ならpromiseをthrow
      case "pending": {
        throw this.#state.promise;
      }
      // 完了済みならそのまま結果を返す
      case "fulfilled": {
        return this.#state.result;
      }
      // 失敗していたらエラーをthrow
      case "rejected": {
        throw this.#state.error;
      }
    }
  }
}

このようなクラスでPromiseを保持しておき、同期的に取得可能な結果が得られない場合は、PromiseまたはErrorをthrowするという仕組みになっている。

こうすることで、fetchDataでは必ず同期的に結果が得られるようになり、もし非同期処理が終わっていない場合は、SuspendコンポーネントがthrowされたPromiseを引き続き監視し、fallbackのコンポーネントを表示してくれる。

https://codesandbox.io/s/s9zlw3?file=/Albums.js&utm_medium=sandpack

function use(promise) {
  if (promise.status === 'fulfilled') {
    return promise.value;
  } else if (promise.status === 'rejected') {
    throw promise.reason;
  } else if (promise.status === 'pending') {
    throw promise;
  } else {
    promise.status = 'pending';
    promise.then(
      result => {
        promise.status = 'fulfilled';
        promise.value = result;
      },
      reason => {
        promise.status = 'rejected';
        promise.reason = reason;
      },      
    );
    throw promise;
  }
}

Reactの公式サンプルでは、useという関数で同様なことをしており、処理されるPromise自体はMapでキャッシュされている。

※ 同じような概念かもしれないが、Reactでは、ライブラリ実装として、use というhooksが提供されているが、これとは別物。こちらもいずれ深掘りしたい。

Errorがthrowされたらどうするのか?

https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary

<ErrorBoundary fallback={"エラーが発生しました"}>
  <Suspense fallback={<Loading />}>
    <Component />
  </Suspense>
</ErrorBoundary>

既存の非同期処理をSuspenseに置き換えるには

上記の通りで、既存のコードでuseEffect等でasync関数によりデータ取得を行っている場合、そのままSuspenseコンポーネントに置き換えることはできない。Promiseをthrowする仕組みを作る必要がある。

この仕組みを実装するのは割りとコストにみえるので、対応しているライブラリを使うのが一般的ななのかもしれない?

https://github.com/tanstack/query

https://swr.vercel.app/ja/docs/suspense

参考

https://qiita.com/uhyo/items/255760315ca61544fe33

throw Promiseの概念がしっくりこなかったのでとても参考になりました。

サンプルコードの大半もこちらを参考にさせていただいています。

今後深掘りしたいこと

  • ErrorBoundary
  • useDeferredValue
  • startTransition

【CSS】CSSアニメーションで画面下部からスライドインするアニメーションを実装する

概要

CSSのアニメーションを使って、画面の最下部からスライドアップのアニメーションでメニューを表示する方法について紹介します。

イメージとしては、以下のような初めから表示されているメニューの開閉ボタンを押下すると、画面外に隠れていたサブメニューが表示される。というものです。

コード

React + TypeScriptを使って記載したコードは以下のようになります。

Reactコンポーネント

import { useState } from "react";
import "./styles.css";

export default function App() {
  const [showSubMenu, setShowSubMenu] = useState(false);
  return (
    <div className="room">
      <div className="content">Main Content</div>

      <div className="menu-wrapper">
        <div className={`sub-menu-wrapper ${showSubMenu ? "show" : ""}`}>
          <div className="sub-menu">Menu</div>
        </div>
        <div className="menu">
          <button type="button" onClick={() => setShowSubMenu((prev) => !prev)}>
            Open Menu
          </button>
        </div>
      </div>
    </div>
  );
}

CSS

.room {
  position: relative;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.content {
  width: 100%;
  height: 100%;
  overflow-y: scroll;
  flex-grow: 1;
  background-color: aqua;
  display: flex;
  flex-direction: column;
}

.menu-wrapper {
  background-color: aqua;
}

.menu {
  position: relative;
  height: 100px;
  background-color: coral;
  z-index: 100;
}

.sub-menu {
  height: 300px;
  background-color: darkgreen;
}

.sub-menu-wrapper {
  width: 100%;
  height: 0;
  transform: translateY(100%);
  transition: transform 0.25s, height 0.25s;
  overflow: hidden;
}

.sub-menu-wrapper.show {
  height: 300px;
  transform: translateY(0);
}

実装しているメインのアニメーションでは、表示したいサブメニューを予め transform: translateY(100%) でメニューの裏側に隠しておき、ボタンが押されたタイミングで translateY(0)に変更しています。

ポイントとしては、 sub-menu-wrapper の高さを非表示時には0に設定し、この高さもアニメーションでサブメニューの高さに変更しています。 また sub-menu-wrapperoverflow: hidden; を設定しています。

この実装の理由は、サブメニューが position: absolute; を使わずにメニューの上に表示される要素であり、高さを0にしておかないと画面からはみ出してしまうからです。

position: absolute を使わない方法で実装している理由は、メニューの上に相対的に別のボタンを配置したいという要件があるためです。今回の方法では、サブメニューが表示されていればサブメニューの上に、サブメニューが非表示であればメニューの上にボタンを表示することが簡単にできることから、このような実装を選択しました。

実際のコードは以下になります。

誰かの参考になれば幸いです。

ExposedにおけるDSLとDAOの使い分け

概要

最近、サーバサイドKotlinを触り始めていて、データベース管理のライブラリにJetBrains製のExposedを使っています。

Exposedの使用方法として大きくDSL方式とDAO方式の2種類あるのですが、この使い分けについて個人的な悩みがあったので記載しました。

https://github.com/JetBrains/Exposed

結論

  • データベースレイヤーとアプリケーションレイヤーを切り分けて管理したい場合は、DSLを中心に使う
  • アプリケーションレイヤーでデータベースについて管理しても問題ない場合は、DAOを中心に使う

ドキュメント

ExposedのドキュメントはgithubWikiがあります。 必要十分ではありますが、お世辞にも手厚いわけではないとうのが、個人的な感想です。しかし、基本的な使い方については、ここを参照するのが一番わかり易いので、まずは一読するのが良いかと思います。

https://github.com/JetBrains/Exposed/wiki

DSLとDAO

Exposedを使って、データベースを操作するには大きく DSL(Domain Specific Language)DAO(Data Access Object) という2つの方法があります。

説明するまでもないかもしれませんが、簡単にこの2つの違いを説明しておきます。

DSLは、SQLのクエリビルダ的な位置づけで、SQLシンタックスに寄せられたAPIを使うことでSQL発行をオブジェクト経由で行うことができます。

Users.select { Users.name eq "user name" }
    .limit(10)
    .orderBy(Users.id to SortOrder.ASC)
    .firstOrNull()

usersというテーブルがあるとして、このようなDSLを実行すると以下の様なSQLに変換され実行されます。 完全にSQLと同じわけではありませんが、ある程度SQLを触ったことがあれば操作できるようなSQLDSLとしての位置づけのAPIです。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE users.name = 'user name' ORDER BY users.id ASC LIMIT 10

一方、DAOはオブジェクトを中心にオブジェクトのAPIを実行することで、内部でSQLを発行してくれるという形式になります。代表される類似のものとしては、RubyOnRailsのActiveRecordなどがこれに該当します。

User.find { Users.name eq "user name" }
  .sortedBy { it.id }

先程のusersテーブルについてDAO経由でアクセスするとこのようになります。API名も特にSQLなどは意識されておらず、直感的に理解できる形になっています。

これは以下のようなSQLに変換されます。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE WHERE users.name = 'user name'

考え方としては、SQLを中心にSQLを組み立てるためのオブジェクトが用意されているのがDSL。 オブジェクトによる直感的なデータ操作を中心に、対応する操作のSQLを発行できるのがDAOという感じです。

リレーションの違い

Exposedは基本的にRDBMSを対象としたライブラリなので、テーブル間のリレーションを操作します。 このときのDSLとDAOの違いをみていきます。

テーブル

データベースにおいて、以下のような関係があるテーブルがあるとします。(MySQLを想定)

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

CREATE TABLE `employees` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `company_id` bigint unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `company_id` (`company_id`),
  CONSTRAINT `employees_fk` FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`)
) ENGINE=InnoDB COMMENT='従業員';

この2つのテーブルは、employeesテーブルがcampaniesテーブルのキーを外部キーとして参照することで関係しています。

DSL

まずはDSLの操作から説明します。DSLに使うテーブルオブジェクトは以下になります。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)
    val companyId = integer("company_id")
}

データ作成は以下のようになります。

// NOTE: insertAndGetIdというAPIを使うと主キーを取得できる
val companyId = Companies.insertAndGetId {
    it[name] = "〇〇株式会社"
}
Employees.insert {
    it[Employees.name] = "佐藤拓郎"
    // 外部キーをセットする
    it[Employees.companyId] = companyId.value
}

全てのカラムをそのままセットする形です。発行されるSQLは以下のとおりです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (2, '佐藤拓郎')

次にデータ取得になります。 複数のテーブルを結合するにはSQLと同じくjoinというAPIを使用します。

 val result = Employees.join(
    otherTable = Companies,
    joinType = JoinType.INNER,
    onColumn = Employees.companyId,
    otherColumn = Companies.id
).select {
    Employees.name eq "佐藤拓郎"
}.firstOrNull()

発行されるSQLは以下のとおりです。

SELECT employees.id, employees.`name`, employees.company_id, companies.id, companies.`name` FROM employees INNER JOIN companies ON employees.company_id = companies.id WHERE employees.`name` = '佐藤拓郎';

DAO

次はDAOの操作について説明します。DAOを使う場合でも、DSLで使用するテーブルのオブジェクト定義は必要になります。

DAOで外部テーブルとの関連を表現するためにはテーブルオブジェクトで reference を使って外部キーを指定します。

またDAO側のオブジェクトは、referencedOn を使って直接Companyを参照します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)

    // 外部キーを指定して、Companyと関連を作成する
    val company = reference("company_id", Companies)
}

class Employee(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Employee>(Employees)

    var name by Employees.name
    // referencedOnを使ってCompanyへの参照を作成する
    var company by Company referencedOn Employees.company
}

データ作成は以下のようになります。 EmployeeのcompanyにはCompany.newで作成されたCompanyオブジェクトを直接設定しています。

val company = Company.new {
    this.name = "〇〇株式会社"
}
val employee = Employee.new {
    this.name = "佐藤拓郎"
    // 関連するCompanyオブジェクトをセットする
    this.company = company
}

発行されるSQLDSLと同じです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (8, '佐藤拓郎')

次にデータ取得になります。 DAOの場合JOINをする必要はありません。employee.companyにアクセスするタイミングでcompanyを取得するためのSQLが実行されます。

val employee = Employee.findById(10)
println(employee?.company?.readValues)

発行されるSQLは、JOINされたものではなくテーブル毎に実行されます。

SELECT employees.id, employees.`name`, employees.company_id FROM employees WHERE employees.id = 10
SELECT companies.id, companies.`name` FROM companies WHERE companies.id = 17

データベース側のデフォルト値

細かい話ではあるのですが、DAOを使用している場合は、データベースのDDLでデフォルト値を使用している場合、DAOを使っているとうまく処理できない場合があります。

例えば以下の様なテーブル定義になっているとします。

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT "デフォルト会社名",
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

このとき、データベース側にデフォルト指定があるからといって、以下のように書くとエラーになります。

val company = Company.new {}
Can't add a new batch because columns: companies.`name` don't have default values. DB defaults don't support in batch inserts

DAOを使ったアクセス時はデータベースのデフォルト値を考慮してくれないようです。 こういったケースではデータベース側にデフォルト値を設定するのではなく、Exposedのテーブルオブジェクトにデフォルト値を設定します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

DSLでインサートを行う場合には、テーブルオブジェクトに default は不要です。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

// エラーは発生しない
Companies.insert {}

DAOを使う場合は、基本的にデフォルト設定はExposed側で行う必要があるようです。

バッチインサート

https://github.com/JetBrains/Exposed/wiki/DSL#batch-insert

Exposedには、大量にデータ挿入が発生したときに、SQLのbulk insertに変換するためのbatch-insertという機能があります。

この機能についても、データベース側でデフォルト値が設定されているとエラーが発生して使用できませんでした。

まとめ

ExposedはDAOとDSLという2種類の使用方法がありますが、完全に分離されているわけではなく、DAOを中心に使いたいのであれば、テーブル定義オブジェクトもDAO用に使う必要がありそうです。
その際は、データベース側でデフォルト値を設定しているとうまく使用できないことがあるので注意が必要です。
(他にも制限があるかもしれません)

この様な挙動は、Exposed自体のテーブル作成機能を使った場合は発生しないと思うので、フォローされていないのかもしれませんが、データベースのマイグレーション管理を別に行っている場合やExposedへの移行を検討している場合は気をつけた方が良いかもしれません。

個人的な運用になりますが、私はデータベースレイヤーとアプリケーションレイヤーは、ある程度は切り離して考えたく、マイグレーションもExposedに依存していないため、現状はDSLしか使わない運用で開発しています。
(reference機能も使わず、データベースレイヤーからモデルへのレイヤー変換処理を書いています。)
(バルクインサートも使いたい箇所があったのですが、今は使っていません)

これは、設計に対しての考え方にもよると思いますので、ある程度アプリケーションでデータベース定義を管理しても問題ないと考えている場合は、素直にDAO中心に記載したほうが工数は少なくなるかとも思います。

この辺り、うまく運用されている事例や、認識齟齬などあれば気軽にコメントいただければ幸いです。

サーバサイドKotlinでのGraphQLのグローバルIDの変換処理

やりたいこと・背景

サーバサイドKotlin + GraphQLでAPIを作成しています。 GraphQLを書いているとアプリケーション内部のドメインに紐づくモデルを、GraphQLスキーマに変換することは日常的にあるかなと思いますが、このときモデルのIDをGraphQLのグローバルIDに変換するのが手間だと思います。 Kotlinはこのあたり柔軟に書けるのでコードとして書いてみました。

GraphQLのグローバルIDについて、詳細を知りたい方は以下を参照。

GraphQL Global Object Identification Specification

グローバルIDのフォーマット

今回のコードでは、GraphQLのグローバルフォーマットは、<Model名>:<Id(int)> としてこれをBase64エンコードしたものをクライアントに返しています。

例:

  • Userモデル id=123の場合

エンコード前文字列: User:123 エンコード後文字列: VXNlcjoxMjM=

使用しているライブラリ

※ 基本的なコードの構成は、ライブラリに依存するものではありませんが、該当のライブラリのクラスやメソッドをつかっているため記載しています。

コード全体

// GraphQLのライブラリとしてgraphql-kotlinを使用しているためグローバルIDは当該のクラスを使用する
import com.expediagroup.graphql.generator.scalars.ID
// ktorにbase64エンコード/デコード処理が公開されていたため使用(何でも良い)
import io.ktor.util.decodeBase64String
import io.ktor.util.encodeBase64
import <独自のパッケージ名>.ModelId

// ドメインに紐づくモデルのID
// 各モデルごとに継承して使う
open class ModelId<T>(val value: T) {
    override fun toString() = value.toString()

    override fun hashCode(): Int = value.hashCode()

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ModelId<*>) return false
        return other.value == value
    }
}

// Userモデル用に継承したID
class UserId(value: Int) : ModelId<Int>(value)

data class User(
    val id: UserId,
    val mane: String,
)

// モデルID => GraphQLのグローバルID変換
fun ModelId<Int>.toId(): ID {
    try {
        // モデル毎 のクラス名を取得(ex: Userモデルなら UserId)
        val cName = this::class.simpleName!!
    // suffixが "Id"であることを決め打ちしてモデル名を取得
        // ※ このあたりはサンプルなので適当にしています。
        val modelName = cName.substring(0, cName.length - 2)
    // 「モデル名:id値」で文字列にしてエンコードする
        return ID("$modelName:${this.value}".encodeBase64())
    } catch (e: Throwable) {
        throw IllegalStateException("Encode ID Failure.")
    }
}

// GraphQLのグローバルID => モデルID変換
fun <T : ModelId<Int>> ID.toModelId(): T {
    try {
        // base64からモデルをデコード
    val decodedId = value.decodeBase64String()
    // :で分割してモデル名を取得
        val modelAndId = decodedId.split(":")
    // モデル名 + Id でモデル毎のIdクラスの名前を作る(ex: Userモデルなら UserId)
        val cName = "${modelAndId.first()}Id"
    // リフレクションでインスタンスを生成する
        val fullClass = Class.forName("<独自のモデルのパッケージ名>.$cName")
        val constructor = fullClass.getConstructor(Int::class.java)
        return constructor.newInstance(modelAndId.last().toInt()) as T
    } catch (e: Throwable) {
        throw IllegalArgumentException("Parse ModelId Failure.")
    }
}

使い方

internal class ModelIdExtensionsKtTest : StringSpec({
    "toId: Should return Id by Model." {
        UserId(123).toId() shouldBe ID("UserId:123".encodeBase64())
    }

    "toModelId: Should return ModelId from ID." {
        val modelId = ID("UserId:123".encodeBase64()).toModelId<UserId>()
        modelId.value shouldBe 123
    }
})

感想

このあたり、TypeScriptなど、拡張が手軽にできない言語だと、サクッと書けないのでKotlinは便利です

【サーバーサイドKotlin】ことはじめ

背景・スキルなど

クライアントサイドの開発(6割)

サーバーサイドの開発(4割) - Ruby On Rails - NodeJS(Express系)

サーバーサイドは、Java/Kotlinともに未経験であり、SpringBootなど名前くらいは知っていますが、特性などは何もわかりません。

サーバーサイドKotlinを選んだきっかけ

普段は比較的小 ~ 中規模のアプリケーション開発が多く、APIのWebインタフェースには最近graphQLを用いることが多いです。しかしながら、バックエンドでTypeScriptを使用していると、シンタックスが弱く冗長な書き方をすることが多く、ストレスを感じていました。

Android開発ではKotlinを使用しており、言語の特性は理解していますが、Kotlinは最近のモダンな機能を概ね網羅しており、ストレスなく書くことができると考えたかというのが理由です。

Webアプリケーションの選定

Webアプリケーションのフレームワークとしては、有名所としては以下のようなものがあります。

JVM系ということでJavaの資産が使えるため、もっと沢山ありそうですが、さっと調べた感じはこのあたりでした。 Spring BootはJava時代からの大御所ということで、情報にこまることはなさそうですが、個人的にJavaのコードを多く有している、Javaシンタックスに引っ張られているというのは少し気になります。

Kotlin実装のものでJetBrains製ということもあり、Ktorを第一候補をして考えています。

GraphQL ライブラリの選定

GraphQLのパース等を時前でやるのは大変なのでこの辺のライブラリも使うのが一般的だと思います。

こちらはあまり数がなさそうですが、この辺が利用者が多そうです。 他にもgraphql-javaというJavaベースのものが存在していますが、graphql-kotolinは、graphql-java依存のライブラリなのでこちらは選択肢には含めませんでした。 選択というほど選択ではないですが、包括機能を考えると、graphql-kotlin が良いかなと思いました。

まとめ

とりあえず、JVM系(コンパイル言語系)のサーバーサイドははじめてなので、未知数ではありますが、

  • webフレームワーク: ktor
  • graphqlライブラリ: graphql-kotlin

この辺を使って、サーバサイドKotlinを勉強したいと思います。

【SwiftUI】ピンチによるViewの拡大と縮小

概要

SwiftUIを使ってピンチイン・ピンチアウトによってView(画像に関わらずViewコンポーネント)を拡大・縮小する方法について記載します。

環境

  • Swift: 5.2.4
  • Xcode: 11.6

サンプル環境

f:id:inon29:20200815130637p:plain

struct ContentView: View {
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
        }
    }
}

今回は、画面に円を一つ配置し、この円をピンチジェスチャーで拡大/縮小します。

ピンチジェスチャーの取得

SwiftUIのViewコンポーネントには、gesture というModifierが用意されています。
このgestureというModifierを使うとタップやドラッグ、ピンチといったジェスチャーを簡単にトラッキングすることができます。

struct ContentView: View {
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        print("onChanged: ", value)
                    }
                    .onEnded { value in
                        print("onEnded: ", value)
                    }
                )
        }
    }
}

ピンチのジェスチャーを取得するには、 MagnificationGesture()gesture Modifierに渡します。
Gestureコンポーネントには、「値の変更」をコールバックする onChanged、「イベントの完了」をコールバックする onEndedなどイベント検知のModifierが用意されており、これらをセットすることによりジェスチャーの値を取得できます。
MagnificationGestureを使用した場合、onChanged、onEndedに渡される value には、現在の拡大倍率を基準とした拡大率(縮小率)がCGFloat型で取得できます。

Viewを拡大・縮小する

struct ContentView: View {
    @State var scale: CGFloat = 1.0
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        self.scale = value
                    }
                    .onEnded { value in
                        print("onEnded: ", value)
                    }
                )
        }
    }
}

拡大率は取得できたのでこれを使用して、円のView自体をピンチジェスチャーに合わせて拡大・縮小します。
Viewの拡大・縮小を行うには、scaleEffect というModifierを使います。scaleEffectに元の大きさを1.0とした拡大率をセットすることでViewを拡大(縮小)することができます。
先程記述した、MagnificationGestureのonChangedで変更倍率を受け取り、@Stateで宣言したscale変数に値をセットします。
そして、scale変数をscaleEffectにセットすることで、ピンチジェスチャーの倍率変更に合わせてViewを拡大(縮小)することができます。

変更倍率を保持する

上記までの処理を動かすと一見上手く動いているように見えますが、今は ピンチジェスチャーを行うたびに画像の大きさが1.0倍に一旦戻ってしまうという状態になっています。

これは、onChanged, onEnded で取得できる value が 「現在のサイズからの拡大率」であるためです。
(元画像から2倍に拡大されていても、ピンチジェスチャーが一度終わっていれば次のピンチジェスチャーでは1.0から始まる)

これだと使いにくいため、ピンチジェスチャーが複数回行われても、元画像の拡大率を維持した状態になるように変更します。

struct ContentView: View {
    @State var lastValue: CGFloat = 1.0
    @State var scale: CGFloat = 1.0
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        // 前回の値から拡大率を計算する
                        let delta = value / self.lastValue
                        // 現在の拡大率を再設定する
                        self.scale = self.scale * delta
                        // 現在の拡大率を覚えておく
                        self.lastValue = value
                    }
                    .onEnded { value in
                        // 次のジェスチャーイベントではvalueはまた1.0から始まるため
                        // ジェスチャーイベント完了時に1.0に戻しておく
                        self.lastValue = 1.0
                    }
                )
        }
    }
}

上記が変更後のコードです。

onChangevalueをそのままscaleにセットするのではなく、(同一ジェスチャーイベントでの)前回のvalue(lastValue)との拡大率の変更を計算してscaleにセットしています。
またジェスチャーイベントで取得できるvalueは、毎回1.0から始まるため、onEndedlastValue変数を1.0に初期化しています。

このようにすることで、拡大率をリセットさせずにピンチジェスチャーによる拡大・縮小を行えます。