dtaniguchi.com

blog - AppleMaps

Vue3 と Apple MapKit JS Ver.5 ② – Singleton Instance の Mutex

d.taniguchi

2024年8月15日 23:35

Post / コメント

  • AppleMaps
  • ChatGPT
  • Tips
  • TypeScript
  • Vue3

前回の続きである。

前回は地図を使用する UI 開発で、 Apple Maps の地図上に表示する Marker コンポーネントを、 Vue3 とどのように組み合わせて実装し、 Storybook に追加したかを書いた。

今回は、地図 UI を取り扱ういくつかのコンポーネントが、最初に呼び出す Apple Maps の SDK Object を、フレームワークとどの様に組み合わせればいいのかについて話をする。

Apple Maps ではまず、 Web ブラウザで地図表示を行うために必要な部品一式が実装されている、 MapKit JS と呼ばれる SDK Object の読み込みを済ませておく必要があった。具体的には公式で公開されている以下のコードである。

// This promise resolves when the browser finishes downloading and evaluating MapKit JS.
const mapKitJsLoadedPromise = new Promise(resolve => {
    const element = document.createElement("script");
    element.addEventListener("load", resolve, { once : true });
    element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
    element.crossOrigin = "anonymous";
    document.head.appendChild(element);
});

このコード、SDK Object が定義されている js ファイルを動的制御で読み込む場合に使用する。読み込み処理自体は非同期だが、完了するまで次の処理を進めることができないため、実質は直列処理になる。この方法は MapKit JS を Full bundle でしか読み込めないため、処理速度の観点から公式で本番環境非推奨となっている。

普通 js ファイル読み込みには、以下のような script タグを head タグ内に宣言する方法をとる。

<script src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.core.js"
    crossorigin async
    data-callback="initMapKit"
    data-libraries="services,full-map,geojson"></script>

この場合、js ファイルの読み込み順序がフレームワークの読み込みより先に実行されるよう制御できる。複数のコンポーネントがバラバラな順序で SDK Object へアクセスしても、 Instance が存在しない可能性を考慮する必要がないし、Minimum bundle のライブラリを指定できるため、読み込み時間を短縮できる。

しかし今回は、以下の理由から動的制御で直列処理による SDK Object の読み込みが最善だと判断していた。

  • 特定の Map サービスに依存しない UI 設計を実現しているため、 DI するオブジェクトの差し替えで Map サービスの切り替えが可能な、ベンダーロックインしない構成が望ましい
  • 可読性や可変性維持のため疎結合と凝集度の向上を実現するには、ユーザーが地図情報にアクセスしたタイミングで、関連するライブラリの読み込みが実行される実装が最善
  • MapKit JS は Full bundle でも十分高速に読み込み可能で、ライブラリ使用直前の直列処理でも UX は悪化しない
  • 地図サービスによって、どのタイミングで費用が発生するか分からないため、ユーザーが地図機能を使用しなかった場合、費用が発生する可能性そのものが生じない設計こそ、懐とリードエンジニアの心理的安全に優しい

これらを踏まえると、実装は以下のようになった。(記事の趣旨に合わせて一部省略)

/lib/map/index.d.ts
interface NavigationMap {
  launch(latLng: LatLng): Promise<void>
  // 他の地図制御メソッド 省略
  dispose(): void
}
  
interface MarkerIndicationMap {
  launch(latLng: LatLng): Promise<void>
  // 省略
  dispose(): void
}
/lib/map/apple-maps/index.ts
import AppleNavigationMap from "./navigation-map"
import AppleMarkerIndicationMap from "./marker-indication-map"

export const getNavigationMap: (element: HTMLElement) => NavigationMap = 
  (element: HTMLElement) => new AppleNavigationMap(element)

export const getMarkerIndicationMap: (element: HTMLElement) => MarkerIndicationMap =
  (element: HTMLElement) => new AppleMarkerIndicationMap(element)
/lib/map/apple-maps/apple-maps.ts
type Mapkit = any

export default abstract class AppleMaps {

  private static mapKit: Mapkit

  protected static async getMapKit(): Promise<Mapkit> {

    if (this.mapKit) {
      return this.mapKit
    }

    await new Promise(resolve => {
      const element = document.createElement("script")
      element.addEventListener("load", resolve, { once : true })
      element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
      element.crossOrigin = "anonymous"
      document.head.appendChild(element)
    })

    this.mapKit = await new Promise((resolve, reject) => {
      const mapKit = (globalThis as any).mapkit

      mapKit.init({
        // libraries: ["services", "full-map", "annotations"],
        authorizationCallback: function(done: any) {
          done("-- TOKEN --")
        },
        language: "ja"
      })

      mapKit.addEventListener("configuration-change", (event: any) => {
        switch (event.status) {
          case "Initialized":
            resolve(mapKit)
            return
          // case "Refreshed":
          // default:
        }
        reject(event)
      })

      mapKit.addEventListener("error", (error: any) => reject(error))
    })

    return this.mapKit
  }
}
/lib/map/apple-maps/marker-indication-map.ts
import AppleMaps from "./apple-maps"

export default class AppleNavigationMap extends AppleMaps implements NavigationMap {

  private element: HTMLElement
  private map: any = undefined

  constructor(element: HTMLElement) {
    super()
    this.element = element
  }

  private async getMap() {
    if (this.map) {
      return this.map
    }
    const mapKit = await AppleMaps.getMapKit()
    this.map = new mapKit.Map(this.element, {
      // 省略
    })
    return this.map
  }

  async launch(latLng: LatLng) {
    const mapKit = await AppleMaps.getMapKit()
    const map = await this.getMap()
    map.region = new mapKit.CoordinateRegion(
      // 省略
    )
  }

  // 他の地図制御メソッド 省略

  dispose() {
    this.map?.destroy()
  }
}
/lib/map/apple-map/navigation-map.ts
// marker-indication-map.ts と同様

最初の Interface は、地図 UI を担当するコンポーネントから呼び出すことを想定している。コンポーネントの分だけ定義され、地図の制御に必要なメソッドを用意しておく。地図制御に係る Map ベンダー固有のコードを、コンポーネントの実装から分離できるため、DI の設定次第で異なる Map ベンダーに切り替えることが可能になる。

そして、地図ベンダーのサービスに依存するコードをまとめる Concrete class に続く。

Abstract class を定義、ここに SDK Object である MapKit の Getter を用意する。この Getter には MapKit の Instance 生成処理も含み、 最初のアクセスが来るまで Instance 生成を保留する。 Lazy initialization だ。一度生成された Instance はその後ずっと再利用される。

継承する class からしか使用しない関数なので、アクセス修飾子は Protected 、Singleton instance なので、static を指定する。

特段問題なさそうに見える。試しに動かしてみると、以下の Warning が出た。

[MapKit] Mapkit namespace already exists; did you import MapKit more than once?

嫌な予感はあった。問題の発生箇所はやはり Abstract class の getMapKit メソッドだった。SDK Object の読み込み処理が複数回実行されている。

Singleton にしたい SDK Object の Instance 生成に時間を要すため、生成完了より前に別のコンポーネントの表示制御が、別スレッドで生成処理をキックしてしまう。getMapKit メソッドを Mutex にしなければ Singleton を保証できないのだ。

しかし、どの様なコードを書けばいいのだろうか?

例えば、 Java であれば Lazy initialization には定番の書き方がある。 synchronized を使う方法がすぐに思い浮かぶ。

Java
abstract class AppleMaps {

  private static MapKit mapKit;

  static synchronized MapKit getMapKit() {
    if (mapKit != null) {
      return mapKit;
    }
    // MapKit 生成処理
  }
}

Kotlin に至っては Syntax が存在する。

Kotlin
abstract class AppleMaps {

  protected companion object {
    val mapKit by lazy {
      // MapKit 生成処理
    }
  }
}

Java や Kotlin であれば上記のコードで問題は解決する。

しかし JavaScript でスレッド制御や Lazy initialization に関する Syntax は聞いたことがない。Promise を使えば作れそうな気もするが、少し考えただけではコードを思いつくことができなかった。 Google 検索で実現方法を調べてみる。

検索で上位にでてきた記事は、 Third-party 製のライブラリ紹介だった。主に 2 つが紹介されている。

これらは、JavaScript で Semaphore や Mutex を使用する場合、よく使用される Package のようだ。試しにプロジェクトに追加してみると動作しない。いずれもソースコード内で process object を使用しており、 Vite を使用している Project で使用する場合、単純な Package 追加だけでは動作しないことが分かった。

実際に問題なく動作する Mutex の実装を掲載しているページを発見することができたのは、調査をはじめてしばらく経過してからだった。記事の内容を基にテストすると、必要な振る舞いをすることが確認できた。

Danny Kim / Simple TypeScript Mutex Implementation
https://dev.to/0916dhkim/simple-typescript-mutex-implementation-5544

ひとまず、設計の見直しは免れたようである。

動作したのはいいのだが、ソースコードを読むと Semaphore をシングルスレッドで動作させることで Mutex を実現しているように見える。実装がやや複雑で、何をしているのかすぐに理解することが難しい。記事の基になっている Package を追加して使う前に、必要なコードだけ抜き出して振る舞いを理解しようとした時、ふと ChatGPT の存在を思い出した。最近、変数の命名で迷った時、相談に来ていたのだ。ChatGPT に Mutex を質問したら、どの様な返答をしてくれるだろう。

ChatGPT が回答した TypeScript による Mutex の実装と使用例
class Mutex {
  private mutex = Promise.resolve();

  async lock<T>(fn: () => Promise<T>): Promise<T> {
    let release: () => void;
    const releasePromise = new Promise<void>(resolve => release = resolve);

    // finally の代わりに then と catch を使ってロックを解放
    const result = this.mutex.then(() => fn())
      .then(
        (value) => {
          release(); // 正常終了時にリリース
          return value;
        },
        (error) => {
          release(); // エラー時にも必ずリリース
          throw error;
        }
      );

    this.mutex = releasePromise;

    return result;
  }
}

const mutex = new Mutex();

async function criticalSection(id: number) {
  console.log(`Task ${id} is waiting to enter the critical section.`);
  await mutex.lock(async () => {
    console.log(`Task ${id} has entered the critical section.`);
    // クリティカルセクションに入った後、非同期の処理をシミュレート
    await new Promise(resolve => setTimeout(resolve, 1000)); 
    console.log(`Task ${id} is leaving the critical section.`);
  });
}

async function runTasks() {
  await Promise.all([
    criticalSection(1),
    criticalSection(2),
    criticalSection(3)
  ]);
}

runTasks();

1 箇所 ES2018 以降でないと使用できない Syntax が含まれていたが、一発で正解を出してきた。これこそ、欲しかった答えだった。

ChatGPT のコードは Promise を 2 つ、巧みに組み合わせることでスレッド制御を行い、 Mutex を実現していた。シンプルで可読性の高いコードである。これをプロジェクトに採用することにした。修正した getMapKit メソッドは以下のようになった。

/lib/map/apple-maps/apple-maps.ts
type Mapkit = any

export default abstract class AppleMaps {

  private static mapKit: Mapkit
  private static mutex = new Mutex()

  protected static async getMapKit(): Promise<Mapkit> {
 
    await this.mutex.lock(async () => {

      if (this.mapKit) {
        return
      }
      
      await new Promise(resolve => {
        const element = document.createElement("script")
        element.addEventListener("load", resolve, { once : true })
        element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
        element.crossOrigin = "anonymous"
        document.head.appendChild(element)
      })
  
      this.mapKit = await new Promise((resolve, reject) => {
        const mapKit = (globalThis as any).mapkit;
        (globalThis as any).mapkit = undefined
        
        mapKit.init({
          // libraries: ["services", "full-map", "annotations"],
          authorizationCallback: function(done: any) {
            done("-- TOKEN --")
          },
          language: "ja"
        })
        
        mapKit.addEventListener("configuration-change", (event: any) => {
          switch (event.status) {
            case "Initialized":
              resolve(mapKit)
              return
            // case "Refreshed":
            // default:
          }
          reject(event)
        })
        
        mapKit.addEventListener("error", (error: any) => reject(error))
      })
    })
    
    return this.mapKit
  }
}

ChatGPT への質問は Google 検索と比較して、どのぐらい効率よく答えにたどりつけたのだろう。このテーマに関する Google 検索の成果は、あまり芳しいものではなかった。欲しい答えの一部にたどり着いた段階で、かなりの時間と労力を費やしていた。

キーワードから関連しそうなネット記事の候補を一覧表示するだけなのか、疑問に対してそのものズバリの答えを返すのか、この差を考えれば当然の結果ではある。

なんということだろう。今回、技術的な調査で最初に用いるソリューションを更新することになった。ググるという言葉は、もう過去のものになろうとしている。

Vue3 と Apple MapKit JS Ver.5 ① – Storybook に createElement で生成した Marker (Annotation) コンポーネントを追加したい

d.taniguchi

2024年7月8日 23:40

Post / コメント

  • AppleMaps
  • Storybook
  • Tips
  • Vue3

今回の新規開発プロジェクトで、 Apple Maps を採用した。

このプロジェクトでは、立ち上げ初期から地図を使った UI をサービスコアバリューの 1 つとしてユーザーに提供することを計画していた。

地図の使用を想定している箇所は 2 つ。 1 つは、住所に対する補足情報としての地図表示。これは定番の UI で、ほとんどが Google Maps Embed を利用して作成されている。Google Maps Embed であれば費用は無料、API Key さえ取得してしまえば、あとは緯度経度を指定して呼び出すだけだ。

もう 1 つは Marker 表示。地図上に Marker を表示したい。地図は自由に移動でき、 Marker は条件に応じて自在に追加、削除できる必要性がある。

これも、地図を使用するアプリケーションで見かける UI の 1 つと言える。私も一度だけ開発の経験があった。この時は、デザイナーが作成した地図上に Marker を表示する UI だったため、 OpenLayers を使用した。

今回は、最新の地図上に Marker を配置することが求められる。要件を満たす地図サービスをリストアップし、デザインや価格、開発のしやすさなどを基準に 1 社を選定することにした。

採用の検討したサービスは以下の通り。

  • Google Maps
  • Apple Maps
  • Mapbox
  • Bing Maps
  • MapFan
  • Zenrin いつもNAVI

最も高価だったのが Google Maps だった。ライブラリやサンプルコードの充実、そしてストリートビューや口コミ等の情報へのアクセスのしやすさなど、価格に見合う機能の充実は、他サービスから一線を画している。

最も安価だったのが Apple Maps である。 1 日 25 万回の地図表示と 2 万 5 千回のサービスコールができ、年会費 99 ドル固定。冗談のような安さである。Apple は地図で商売をする気がないようだ。この価格で iOS 標準搭載の地図が自由に使えるのはお得というほかない。デザインもよく、機能面も問題がなさそうなので、とりあえず Apple Maps で開発を進めて、問題が生じるようであれば別のサービスを検討することになった。

コンポーネントの実装に Map ベンダー依存のコードが混入しないよう、境界部はすべて Interface で呼び出すよう設計、 DI する Object を差し替えるだけで、別の Map ベンダーのサービスに切り替えられるよう開発した。

さて、問題は Marker だ。

この Marker は地図上の任意の座標に配置し、アイコンやボタン、カウンターなどが含まれる Organisms レベルのコンポーネントである。今後の開発でさらに複雑なデザインになる可能性があった。

Vue3 で開発している以上、当然このコンポーネントも SFC で作成、Storybook で管理したい。ところが Vue3 は Vue2 で可能だった、SFC の動的なインスタンス化と DOM の生成、生成された DOM へのアクセスのための仕組みが削除されているのだ。

Marker は地図上に任意の数表示できる必要がある。コンポーネントの Factory Method を Map ベンダーが提供するライブラリの Callback に渡すことで、 自由に DOM 生成を行うことができなければならない。

例えば、Vue2 では SFC から動的に DOM を生成、アクセスすることができた。

/components/organisms/map/GuideViewer.vue
<script lang="ts">
import Vue from 'vue'
import Marker from '@/components/organisms/map/Marker.vue'

export default Vue.extend({
  components: {
    Marker
  },
  // 省略
  methods: {
    // 省略
    createMarker() {
      const marker = new Marker();// Vue2 の SFC をインスタンス化
      marker.$mount(); // インスタンス化した SFC から DOM を生成
      console.log(marker.$el) // 生成した DOM のルートポインターを確認
    }
  }
})
</script>

しかし Vue3 では new Vue() が削除されている。$mount には破壊的変更が入り、振る舞いが変わったことが公式移行ガイドに示されていた。

Vue3 公式移行ガイド
https://v3-migration.vuejs.org/ja/breaking-changes/mount-changes

しかし、このドキュメントの説明はおかしい。Vue2 の $mount は、個別のコンポーネントの DOM 生成と紐付けを手動で行うことができる関数であり、Vue3 の mount はルートコンポーネントをどの DOM と紐付けするかを設定する関数である。用途も作用も異なる 2 つのメソッドが、なぜ同一線上で語られているのだろうか。そして、なぜこの API は破壊的変更をする必要があったのだろうか。これが分からないことには、 移行中のシステムのコード修正がフレームワークの設計思想に沿ったものなのか判断がつかない。

Vue2 公式ドキュメント vm.$mount (RootComponent ではなく MyComponent としており、4つ目の Example に関して移行ガイドの言及がない) https://v2.vuejs.org/v2/api/#vm-mount

Vue2 では、SFC で作成したコンポーネントの DOM を Script で生成できたのに対し、Vue3 ではできなくなったことへの言及があるべきだろう。 Vue3 の mount はすでに生成済みの DOM の innerHTML を、ルートコンポーネントと差し替えるだけである。差し替える DOM が存在しない場合、DOM が生成されることはない。

結局、 Vue3 では必要な API が存在しないため、 SFC で実装したコンポーネントの Factory Method は作成できないという結論になった。 document.createElement メソッドを使用するしかない。

仕方がないので ts ファイルでコンポーネントを定義する。

/components/organisms/map/Marker.ts
export type Props = {
  count: number
  onClickButton: (e: Event) => void
}

export default ({
  count,
  onClickButton
}: Props) => {
  const marker = createMarker()
  const counter = createCounter(count)
  const button = createButton()
  button.onclick = onClickButton
  marker.appendChild(counter)
  marker.appendChild(button)
  return marker
}

const createMarker = () => document.createElement("div")

const createCounter = (count :number) => {
  const counter= document.createElement("p")
  counter.textContent = String(count)
  return counter
}

const createButton = () => document.createElement("button")

さて、ようやくここで表題の話になる。このコンポーネント、一体どのように Storybook に追加すればよいのだろうか?

Storybook は Vue や React にとどまらず、Svelte や Web Components など、幅広いフレームワークに対応している。調べてみたところ、今回 ts ファイルで作成したコンポーネントの場合 “HTML” で対応するようだった。

試しに空のプロジェクトを作成してみる。

mkdir storybook
cd storybook
npm init
npx storybook@latest init --type html

npm init コマンドで初期プロジェクトの作成をしないと、storybook の init コマンドに渡した引数は無視され、 “HTML” でのプロジェクトインストールに失敗した。

サンプルで作成されているコンポーネントを見る限り “HTML” で間違いなさそうだ。

開発中のプロジェクトに移動して init コマンドを実行してみる。

npx storybook@latest init --type html

このコマンドは正常に実行されインストールは完了したが、プロジェクトは破壊されてしまった。残念ながら、現状 Storybook は複数のフレームワーク同時対応ができないようである。

https://github.com/storybookjs/storybook/discussions/24693

もちろん同一プロジェクト内で複数の Storybook の使用もしたくない。 Vue3 向けの API の中で解決するより他に方法はなさそうだ。

試行錯誤の結果、以下のコードで ts ファイルによるコンポーネントの Story 追加に成功した。

import type { Meta, StoryObj } from "@storybook/vue3"
import createMarker, { type Props as MarkerProps } from "@/components/organisms/map/Marker"

const meta: Meta<MarkerProps> = {
  title: "organisms/Marker",
}

export default meta
type Story = StoryObj<MarkerProps>;

export const Default: Story = {
  args: {
    counter: 1,
    onClickButton: () => {}
  },
  render: (args) => ({
    setup: () => {
      setTimeout(() => {
        const marker = createMarker(args)
        document.getElementById("marker")!.appendChild(marker)
        return args
      })
    },
    template: "<div id='marker' />"
  })
}

Vue3 の SFC と、 createElement メソッドを使用した ts ファイル、いずれのコンポーネントも同じ Storybook 上で取り扱い可能だった。

プロジェクトで使われているコンポーネントの一部が Storybook で管理されていないという事態は回避された。