前回の続きである。
前回は地図を使用する 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 は悪化しない
- 地図サービスによって、どのタイミングで費用が発生するか分からないため、ユーザーが地図機能を使用しなかった場合、費用が発生する可能性そのものが生じない設計こそ、懐とリードエンジニアの心理的安全に優しい
これらを踏まえるて、実装は以下のようになった。(記事の趣旨に合わせて一部省略)
interface NavigationMap {
launch(latLng: LatLng): Promise<void>
// 他の地図制御メソッド 省略
dispose(): void
}
interface MarkerIndicationMap {
launch(latLng: LatLng): Promise<void>
// 省略
dispose(): void
}
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)
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
}
}
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()
}
}
// 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 を使う方法がすぐに思い浮かぶ。
abstract class AppleMaps {
private static MapKit mapKit;
static synchronized MapKit getMapKit() {
if (mapKit != null) {
return mapKit;
}
// MapKit 生成処理
}
}
Kotlin に至っては Syntax が存在する。何といい言語なんだろう。
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 を質問したら、どの様な返答をしてくれるのだろう。
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 メソッドは以下のようになった。
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 検索の成果は、あまり芳しいものではなかった。欲しい答えの一部にたどり着いた段階で、かなりの時間と労力を費やしている。
キーワードから関連しそうなネット記事の候補を一覧表示するだけなのか、疑問に対してそのものズバリの答えを返すのか、この差を考えれば当然の結果ではある。
なんということだろう。今回、技術的な調査で最初に用いるソリューションを更新することになった。ググるという言葉は、もう過去のものになろうとしている。