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 を、Vue3 でどの様に取り扱えばよいかについての話をする。

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.0.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 生成処理も含み、 Getter に最初のアクセスが来るまで 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 メソッドだった。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

この記事は async-mutex という npm に登録済みの Package のソースコードを参考に書かれているものだ。記事で紹介されているコードを流用してテストしたところ、必要な振る舞いをすることが判明した。ひとまず、設計の全面的な見直しは免れたようである。

動作したのはいいのだが、ソースコードを読むと 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;
  }
}
ChatGPT が回答した Mutex の使用例
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 を生成
      marker.$el // 生成した DOM のルートポインター
    }
  }
})
</script>

しかし Vue3 では new Vue() も $mount も削除となっている。

Vue3 の公式移行ガイドには、Mount API は破壊的に変更され、振る舞いとメソッド名が変わったことが示されていた。

https://v3-migration.vuejs.org/ja/breaking-changes/mount-changes

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

Vue2 公式ドキュメント vm.$mount (RootComponent ではなく MyComponent としており、 生成したコンポーネントの DOM を appendChild で Root に追加している) https://v2.vuejs.org/v2/api/#vm-mount

Vue2 では、SFC で作成したコンポーネントの DOM を Script で生成できたのに対し、Vue3 ではできなくなったことへの説明があるべきだろう。 createApp はすでに生成済みの 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 で管理されていないという事態は回避された。