前回の続きである。

前回は地図を使用する 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 検索の成果は、あまり芳しいものではなかった。欲しい答えの一部にたどり着いた段階で、かなりの時間と労力を費やしていた。

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

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