Rustでwasm用カスタムアロケータを書く
Wasm とホスト間での関数呼び出しでは数値しか受け渡すことができないので、文字列や配列をやり取りするときには wasm インスタンスのメモリを経由する必要がある。例えば文字列をホスト(JavaScript)から wasm に渡したいときは一度 wasm インスタンスのメモリ領域に文字列を書いてから、書いた場所のアドレスとサイズを関数に渡す。これは数値以外の情報をやり取りする場合は wasm 側にメモリアロケータが必要であることを意味している。
Rust が生成する wasm がメモリ割り当てをどうやっているのかを調べて、自分でナイーブなアロケータを作るところまでやったので、その過程で理解したことをエントリにしておこうと思う。
ここではホスト環境としてブラウザ(JavaScript)を想定している。また wasm のインスタンス化の際にはメモリを import せず wasm 内で管理することを前提とする。
WebAssembly のメモリ
WebAssembly は単純なメモリモデルを採用していて、メモリは連続したバイト列として表現される。初期化時にある一定のサイズが確保されて、必要に応じて領域を広げることができる。ただし一度確保した領域を縮小することはできない。メモリ領域の伸張は 64KB のページ単位で行う。
Wasm インスタンスのメモリは JavaScript からは ArrayBuffer として見える。例として[1,2,3]
という配列を JS から wasm に&[u32]
として渡したいとしよう。JS 側のコードはこんな感じになる。
// `wasm` is an WebAssembly.Instance.
const arg = [1, 2, 3];
const arr = new Uint32Array(wasm.memory.buffer);
const addr = wasm.allocBytes(arg.length * 4);
arr.set(arg, addr / 4);
wasm.someFunc(addr, arg.length);
wasm.freeBytes(addr);
wasm.memory.buffer
から見える ArrayBuffer が wasm インスタンスのメモリ。wasm 側のアロケータの仕事は、wasm.memory.buffer
をヒープ領域として扱い、そのヒープを適宜伸張しながら malloc()/free()相当の機能を提供すること。上記ではそれらをwasm.allocBytes()
やwasm.freeBytes()
として定義している。
wasm からメモリ領域を伸張するにはmemory.grow
命令を使う。この命令は引数を二つとる。一つは新たに確保するページの数。もう一つは対象となるメモリ領域を指定するインデックス。このインデックスには常に 0 を指定する。仕様の上では wasm インスタンスは複数のメモリ領域を参照できるようになっているが、これは将来の拡張のために用意されているもの。現時点では一つの wasm インスタンスにつきメモリ領域は一つしか存在しない。
アロケータの実装
Rust 側での実装に話を進めていく。Rust から WebAssembly のmemory.grow
命令を呼ぶにはwasm32::memory_grow()
を使う。以下の Rust のコードをコンパイルすると、
#![cfg_attr(target_arch = "wasm32", feature(stdsimd))]
use core::arch::wasm32;
fn f() -> usize {
const MEMORY_INDEX: usize = 0;
unsafe { wasm32::memory_grow(MEMORY_INDEX, 1) }
}
以下の wasm が生成される。
(func (;1;) (type 1) (result i32)
i32.const 1
memory.grow)
memory_grow()
は成功すると伸張前のメモリのサイズをページ単位で返す。言い換えれば、新規に確保されたメモリの開始アドレスは戻り値に 64KB を掛けることで求められる。また伸長に失敗したときはusize::max_value()
を返す。以下のようなラッパーを書くと malloc()に近い使い勝手になる。
unsafe fn grow_pages(num_pages: usize) -> *mut u8 {
let num_pages = wasm32::memory_grow(MEMORY_INDEX, num_pages);
if num_pages == usize::max_value() {
process::abort();
}
(num_pages * PAGE_SIZE) as *mut u8
}
下準備ができたのでアロケータを書いていく。今回はアロケータを作るうえで必要なパーツを説明するのが主眼なので、Memory Allocators 101という記事を参照にして単純な first-fit のアロケータを作成した。コードは Github にあげているのでここではインタフェースのみ載せておく。
struct Heap {
// ...
}
impl Heap {
unsafe fn alloc(&mut self, size: usize) -> *mut u8 { /* ... */ }
unsafe fn dealloc(&mut self, ptr: *mut u8) { /* ... */ }
}
ポインタを安全でない方法でキャストしたりするので割り切って全体を unsafe にしている。もっと Rust 力があれば安全に書けるかもしれないが、後述する GlobalAlloc が unsafe なのであまり頑張っても意味がないかもしれない。
GlobalAlloc
さらに、さきほど作成したアロケータを Rust のVec<T>
やString
などの標準ライブラリのメモリ割り当てにも使うようにする。ホスト環境とのデータのやり取りのみに自作のアロケータを使う場合はこの作業は不要だが、デフォルトのアロケータはバイナリサイズが大きく(10KB ぐらいになる)、使用するアロケータは一つに統一しておいたほうが良い。
標準ライブラリのアロケータを自前のものに差し替えるには以下の二つを行う。
- GlobalAllocトレイトを実装する型を作る
- その型の static なグローバル変数を
#[global_allocator]
属性をつけて定義する
GlobalAlloc
を実装するには以下の二つのメソッドを実装する。Layout
はサイズとアライメントを保持する構造体。
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
これら二つのメソッド、&mut self
ではなく&self
を取る。これは GlobalAlloc を実装する型が static なグローバル変数として使われることからくる制約だろう。
とはいえメモリの割り当てと解放にはアロケータの状態を変更しなければならない。こういう場合の Rust のイディオムは内部可変性を使うことだろう。ここではUnsafeCell<T>
を使う。内部可変性を提供する型を書く際は排他制御をして各種の競合に注意しなければならないが、Rust は WebAssembly 向けのスレッドはまだサポートしていないので今回はシングルスレッドを前提にする。
struct CustomAlloc {
heap: UnsafeCell<Heap>,
}
impl GlobalAlloc for CustomAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
(*self.heap.get()).alloc(layout.size())
}
unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
(*self.heap.get()).dealloc(ptr)
}
}
unsafe impl Sync for CustomAlloc {}
static ALLOC: CustomAlloc = CustomAlloc {
heap: UnsafeCell::new(Heap { /* ... */ }),
};
UnsafeCell<T>
はSync
を実装しないので、シングルスレッドで使う前提でSync
を明示的に実装している。これを忘れるとCustomAlloc
を static な変数として使おうとしたときにコンパイラに怒られる。
これで実装は一通り完了したのであとは実際に使ってみる。
wasm-bindgen
冒頭に説明したように wasm とホスト環境で文字列や配列をやり取りする際には面倒な手順を踏む必要がある。文字列を渡す場合を例に具体的なステップを見てみると、(1) ホスト側からメモリ領域を確保するよう wasm インスタンスに要求する、(2)ホスト側で wasm のメモリ領域に文字列の内容を書く、(3)データを書いたアドレスとサイズを wasm の関数に渡す、(4)wasm の関数では渡されたアドレスとサイズから String なり &str に変換する、という作業が必要になる。
この部分も自作しようかと考えたが、結構煩雑になるのでこの変換部分は wasm-bindgen に頼ることにした。wasm-bindgen を使うとホスト(JavaScript)と wasm 側両方のラッパーを作ってくれるのでとても楽になる。
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn to_uppercase(s: &str) -> String {
s.to_uppercase()
}
あとはビルドして wasm-bindgen の CLI で後処理をすれば最終的な wasm/JS ができる。wasm-bindgen はデフォルトだと Webpack で処理する前提の ES modules 形式で出力するが、オプションを指定することでブラウザで直接読み込める形式にすることもできる。
$ cargo build --release --target wasm32-unknown-unknown
$ wasm-bindgen --browser --no-modules target/wasm32-unknown-unknown/release/wasm_custom_allocator_example.wasm --out-dir public/dist
wee_alloc
アロケータを差し替える主な動機はバイナリサイズの削減だろう。今回は理解を深める目的で自分でアロケータを書いてみたが、実用的には独自アロケータを書くよりもwee_allocを使うのが良いと思う。アロケータは unsafe なコードがどうしても多くなるし、unsafe なコードをきちんと書くのは相当難しい。
参考
正月休み
実家に帰ったり、おせち食べたり、三社参りしたり、わりと普通の年末年始を楽しんだ。年末に崩した体調も回復した。ToDoリストの進捗は芳しからず。辞書ビューアをひと段落させたかったがコーディングはあまり進まなかった。
年をまたいだことだしサイトのトップページのリストを年単位でグループ分けすることにした。日記的なエントリを書きやすくなると思う。
ひそかに期待していたsteps to phantasienの更新はないらしい。休みの期間にちょくちょくチェックしていたのだけど残念。
2018年の振り返り
今年は Github の草を生やすのを日課にしていた。内容は二の次で些細な変更、例えばタイポの修正とかでもいいから毎日コードを書く。対象はほぼプライベートリポジトリ。
旅行してたり体調不良の時以外は埋まっている。習慣化できたのは良かった。ただ草をはやすのを目標にすると、難しいコーディングを避けて簡単なバグ修正をしたり、小さい検証コードを書いたりしがちであった。十分な時間を確保できないと、まとまったコードを書く気にならないのが問題だったと思う。来年はもうちょっと意味のあるコードを書くようにしたい。
あと学生だった時以来のブログを再開した。文章を書く練習をしたい、というのと学習したことを記憶に定着させたい、というのが動機。勉強したことを忘れないように記事にする、というのは nhiroki さんが言っていて、実際に効果あるなあと感じている。技術的な内容に限定する気はないので今後は日常の事とか趣味のこととか書いていきたい。shinh さんの日記みたいな、思ったことをふらっと書くスタイルとかやってみたいし、森田さんのブログみたいなちょっと詩的な感じのやつとか憧れたりする。
Rust
技術的なところでは今年一番時間を使ったのが Rust の勉強。本が 2 冊出たのと、Raph Levienが最近凝っているらしいというので勉強を続けている。自作の辞書ビューアを Rust で書き直したりしているが、まだ手になじまない。実際のアプリを作ろうとしたときに制約がきつくてつらい、イディオムがわからない、みたいな気持ちになる。Rust を学習すること自体は楽しいので来年も続けると思う。
LeetCode
コーディング面接対策で有名なサイト。面接で聞かれそうな問題が豊富にある。競技プログラミングは早々に挫折した身なのだけど、こちらは基礎的なアルゴリズムやデータ構造を知っていれば自分にも解ける問題が多い。動的計画法が苦手だという意識があったので 4 月 5 月あたりに集中的に解いていた。これまでのところ Solved は 113 個。今後もちょっとづつ解いていこうと思っている。
英語
昨年の 12 月から英会話スクールに通っている。ちょうど一年過ぎたぐらい。この間スクールが定めるレベルを一つクリアした。割と効果を実感していて、壊れた英語でもしゃべること自体の抵抗感がなくなった。受講料が高いのでコストパフォーマンスに見合うかどうかは正直わからないが、来年も続けようと思っている。あとひとつレベルをクリアしたい。
散歩とポッドキャスト
健康と趣味(町散策)を兼ねてできるだけ歩くようにしている。ここ数年は一日 10km ぐらい歩くのを目標にしていて、今年もだいたい達成できた。散歩しているときはいつもポッドキャストを聞いている。最近はポッドキャストの番組が増えてうれしい。今年よく聞いた番組たち:
- Misreading Chat
- mozaic.fm
- backspace.fm
- Freakonomics
- omoiyari.fm
- EM . FM
- ajitofm
- Rebuild
- 愚者の宮殿
- dex.fm
- engineer meeting podcast
- yatteiki.fm
写真
長いこと買うか悩んでいた Sony α7R III を冬前に購入した。写真撮るのが一段と楽しくなった。街撮りメインだけどポートレートとかも練習したい。
RustのSend
疑問: RustのライブラリAPIの定義を見ていたら制約にT: Send
と書いてあった。なぜこのAPIはSend
を要求するのか。
短い答え: そのAPIはスレッドをまたいで値を渡す。その値の所有権をスレッド越しに渡してもデータ競合を起こさない、ということを要求するため。Send
でない型はたいていRc<T>
かポインタ。
これはAPIドキュメントを見て疑問の思ったことを調べて、使う側からどう解釈すべきか、という視点から理解しようとするシリーズの二番目の記事となる。前回に引き続き、お題はthread::spawn
。自分の浅い理解から書いているので間違いを含んでいるかもしれない。
thread::spawn()
に渡すクロージャの制約はFnOnce() + Send +'static
なので前回の記事で言及したFnOnce()
に加えてSend
を実装していないといけない。APIドキュメントには値を安全に別スレッドに送るためにSend
が必要だ、と書いてある。でもRustには所有権の概念があるから値を渡せばそれで安全になるんじゃないのか。逆に言うとスレッド越しに渡すと安全じゃない値ってなんなんだろうか。調べた感じだと、大体二つに分類される。
ひとつはRc<T>
。Rc<T>
な値は参照カウンタとともにヒープ上に確保される。レイアウトはこんな感じ:
+--------------+------------+-------+
|strong ref cnt|weak ref cnt| value |
+--------------+------------+-------+
Rc::clone()
を呼ぶと値を複製できる。つまり所有権を持つ変数を複数作れる。Rc::clone()
はヒープ上の参照カウントを増やすのだけど、この参照カウンタはアトミック変数じゃないし、排他制御も行わない。これらの値を異なるスレッドで生成したりドロップしたりしたら参照カウンタのデータ競合が起こる。なのでRc<T>
はSend
ではない。
一方、Rc<T>
と同じように内部可変性(interior mutability)を使っているCell<T>
やRefCell<T>
は、T
がSend
であればCell/RefCellもSend
になる。Cell/RefCellがスレッド安全でなくなるのは、Cell/RefCellへの参照をスレッド間で渡したときであって、所有権を渡すこと自体は安全な操作となる。
もうひとつのSend
でない型はポインタ。*const u8
とか*mut u8
とか。これらがSend
ではない理由はRc<T>
ほど自明ではない。ポインタをスレッド間で渡すこと自体はデータ競合を起こさない。安全でなくなるのはポインタをdereferenceするときだけど、そのときはどのみちunsafeでくくらなければいけない。だったらポインタをSend
である、としても安全性は損なわないような…などと思いつつ調べていたらNomiconに言及があった。主に書き手に注意喚起する目的らしい。言語設計上の選択だったんだろう。
ちなみに参照&T
のほうはSend
を実装している。理由はコンパイラが借用ルールを使って安全でない参照の利用を検知できるから。
自分で定義した型はどうか。型がSend
であるかどうかは基本的にコンパイラがよしなに判断してくれる。内包する型が全部Send
を実装していれば、その型もSend
になる。内包する型のうち一つでもSend
でない型があれば、定義した型もSendを満たすことができない。
コンパイラにお任せできないこともある。メソッドをスレッド安全でない方法で実装したときなどがこれに該当する。この場合はimpl !Send for T
みたいにして明示的にSendを実装しない、と表明する必要がある。
thread::spawn()
に話を戻す。thread::spawn()
はクロージャを受け取る。API定義はそのクロージャがSend
を満たしていないといけない、と言っている。ではクロージャがSend
かどうかはどう決まるのか。それはクロージャの作り方によって決まる。クロージャがSend
でない値をキャプチャしていたら、そのクロージャはSend
ではない。ただし、Send
ではない値をクロージャの内部で使っていても、クロージャ自体はSend
になることができる。
// Error
fn f1() {
let v = Rc::new(42);
thread::spawn(move || {
let _v2 = Rc::clone(&v);
});
}
// OK
fn f2() {
thread::spawn(|| {
let _v = Rc::new(42);
})
}
API定義でSend
を見かけたときの心構えとしてはどう考えればいいか。ほとんどの型がSend
であることを考えれば、スレッド越しに値を渡すよということぐらいで特に気にしなくても良さそう、というのが今の理解。
余談だけど、スレッド安全に関するもう一つのトレイトとしてSync
がある。こちらはthread::spawn()
と直接関係がなかったのであまり追っておらず、理解があやふやなので言及を避けた。
Rust の Fn, FnMut, FnOnce の使い分け
Rustでスレッドを起動するにはthread::spawn()を使う。この関数は引数にクロージャをとるのだけど、引数の定義を見ると、 F: FnOnce() -> T + Send + 'static
となっていて初見では理解するのが難しい。ここではFnOnce()
、つまりクロージャの型について、どういう風に使い分けるのか、といった視点で見ていこうと思う。
Rustにはクロージャを表す型が3つある。
- Fn()
- FnMut()
- FnOnce()
型が3つ必要なのはRustが値の所有権や借用、生存期間をきちんと把握するためである。
Fn()
は使う側、引数として受け取る側としては使い勝手が一番よい。何度でも呼び出せるし、クロージャ自体が可変じゃなくてもいい。これは立場を変えると、クロージャを渡す側としてはFn()
は制約が一番厳しいことを意味する。Fn()
として渡されるクロージャは、何度実行されても所有権や借用のルールを破らないようにしなければならない。例えば、値の所有権をとってそれを消費するクロージャはFn()
としては渡せない。一度値を消費してしまったら二度は使えないから。以下のコードはコンパイルエラーで通らない。
fn do_something<F: Fn()>(f: F) {
f();
f();
}
fn main() {
let nums = vec![1,2,3];
do_something(move || {
for n in nums { ... } // Consume `nums`
});
}
FnOnce()
の使い勝手はFn()
の逆と言える。使う側としての制約は一番厳しい。一度しか実行できないし、実行にはそのクロージャの(借用ではなく)所有権が必要となる。一方、渡すクロージャの自由度は大きい。クロージャは一度しか呼ばれないことが保証されているので、そのクロージャ内で値を消費してもいいし、生存期間さえ守っていれば可変参照を取って値を変更することもできる。
fn do_something<F: FnOnce()>(f: F) {
f();
// Can't use `f` more than once
}
fn main() {
let nums = vec![0,1,2];
let mut greeting = "Hello".to_string();
let greeting_ref = &mut greeting;
do_something(move || {
for n in nums { ... } // Consume `nums`
greeting_ref.push_str(", World"); // Mutate value via mutable ref
});
println!("{}", greeting);
}
この流れで行くとFnMut()
はFn()
とFnOnce()
の中間的な立ち位置になる。値の消費はしないけれど可変参照経由で変更はする、みたいなクロージャはFnMut()
として取り扱うことができる。FnMut()
は何度も呼べるけれど、内部に可変参照をもっているのでそのクロージャを実行するにはmutを必要とする。
fn do_something<F: FnMut()>(mut f: F) {
f();
f();
}
fn main() {
let mut greeting = "Hello".to_string();
let greeting_ref = &mut greeting;
do_something(move || {
greeting_ref.push_str(", World");
});
println!("{}", greeting);
}
ここでthread::spawn()
に立ち戻ってみよう。ライブラリ関数としては利用方法にできるだけ制限をかけたくない。ライブラリ利用者側、つまりクロージャを渡す側にとって一番制約がゆるいのはFnOnce()
である。おそらくこれがspawn()
がFnOnce()
なクロージャを受け付けるようになっている理由だろう。spawn()
は渡されたクロージャを一度実行すればよいだけなので、利用者側の制約が厳しいFn()
を要求する必要はない。
自分がAPIを提供する立場だとしたら、引数として受け取るクロージャの型には注意しておきたい。ライブラリの中身を実装している最中に、コンパイラを通すためだけに安易にFn()
を要求するAPIを作ってしまうと、あとで利用する側で困る、みたいなことになりかねない。
ただ、thread::spawn()
と似たようなAPIを自作して提供する場合にはひとつ落とし穴がある。公式ドキュメントで言及されているので、自分で作ってみようとする前に目を通しておくとよい。自分はこれに悩んで時間を浪費してしまった。
追記
Send
について記事を書いた。
参考文献
Secure Shell App でウェブフォントを使う
Windows では ssh クライアントに Secure Shell App を使っているのだが、デフォルトの設定だと自分の4Kディスプレイではフォントの見栄えがよくない。ウェブフォントを使って自分の好みに変更してみる。
Secure Shell App のオプションページを開くと、user-css-text
という項目があって、ここに任意の CSS を書くことができる。ここで Google Fonts で提供されているフォントを@import
を使って取り込むようにする。今回は Roboto Mono を使うように設定した。
@import url("https://fonts.googleapis.com/css?family=Roboto+Mono:400");
さらに Secure Shell App の設定項目のfont-family
も更新しておく。
"Roboto Mono", Consolas, monospace
少し前に日本語の Noto Sans が Google Fonts で使えるようになったのでこちらも試してみたが、なんだか jaggedly であった。日本語に関してはメイリオの方ががよさそう。
Chromeのメモリ割り当てフック機構
Misreading Chat ep. 30はAddressSanitizer (ASAN)の話。operator new
の差し替えとかどうしているんだろうね、という話題に反応してみる。
Chromeのコードにもoperator new
を差し替えている場所はいくつかある。例えばUSING_FAST_MALLOCというマクロでアノテートされたクラスはPartitionAllocという独自アロケータを使ってoperator new
を実装している。このPartitionAlloc、レンダリングエンジンの内部では結構使っていて文字列やベクタ、ArrayBufferなんかにも使われている。PartitionAllocは内部でmmap
使っているんだけど、ASANがこの辺をチェックしてくれるのかどうか自分はよくわかっていない。
malloc
とかを差し替えたい動機としては、PartitionAllocみたいな最適化したメモリアロケータを使いたい、っていうのの他に、メモリの割り当てと解放をフックしたい、というのがある。これらをフックすればどういう風にメモリが使われているのかを調べることができる。例えば、よく作られるオブジェクト(Stringとか)の一部がすごく大きいのだけど、どこで作られているのか実行時でないと分からないとする。この場合はそのオブジェクトのnew
をフックしてスタックトレースを調べれば、どういうパスを通ってそのオブジェクトが作られたのかが分かる。
Chromeにはこのフックを入れる汎用的な仕組みがあって、Allocator shimと呼ばれている。この仕組を使ってヒーププロファイラやメモリトレースの機能が実装されている。Allocator shimはmalloc
とoperator new
を差し替えるので、ASANと共存することは残念ながらできない。
というわけで、ASANをネタにChromeのAllocator shimの紹介をしてみた。このあたりはドキュメントが充実しているので読んでみると面白いかもしれない。
夕立
午後六時半。九月も半ばを過ぎて辺りはもう真っ暗になっている。
先ほどから雷音と一緒に強い雨が降ってきた。あ、夕立だ、と思った。
夕立という言葉は季節感があって好きだ。最近よく耳にするゲリラ豪雨という言葉には無い含意がある。
夕立というには陽もとっくに暮れているけれど、自分にとってはこれは夕立。
Splatoon2 の漢字フォント
スプラトゥーン2は1に引き続きフォントを自作しているそうだ。
スプラトゥーンのイカしたUIはこうして作られた─担当デザイナーが語る秘話 | 超ゲームウォーカー!
ゲームによくマッチしているし、特徴的でかっこいいフォントだと思う。
自作といっても全部作るのはさすがに無理っぽくて、漢字は既存のフォントを使っているそう。
採用情報:仕事を読み解くキーワード - 世の中にないフォントを作る
じゃあ漢字部分のフォントは何だろう、と思って調べてみた。
スプラトゥーン2の公式アプリであるイカリング2にアクセスすると、Web フォントが二つロードされる。78 KBのフォントと 587 KBのフォント。サイズから言って後者のフォントが漢字を含んでいるフォントだろう。後者のメタデータを見ればフォント名が分かるんではないか。
サイズの大きいフォントをshowttf
で見てみる。NAME テーブルは見づらいので CFF テーブルのほうを見るとこんな感じだった。
...
Dump of top dictionary for KurokaneStd-EB
Version=0 .notdef
Notice=393 Copyright 2008-2013 Fontworks Inc. All Rights Reserved.
fullname=394 KurokaneStd-EB
familyname=395 KurokaneStd
weight=396 EB
...
というわけで、漢字の部分はフォントワークスの「くろかね EB」というフォントかな?
くろかね EB|書体見本|FONTWORKS | フォントワークス
ちなみに前者のフォントには「Splatoon2」という名前がついていた。ひねりなしの素直な名前。
最後にお約束事だけど、商用フォントの不正利用はご法度です。
世界時計 Web アプリ
ちょっと前から iPhone / Android みたいな世界時計の Web 版があるといいな、と思って作っている。
たまに海外に出ることがある。海外にいると大抵今日本は何時だっけ、ええと、となる。日本にいるときも海外のある都市の時間を知りたくなるときがある。
これまではスマホの世界時計を使って知りたい場所の時間を調べていた。だけど PC 上で作業しているときにスマホを取り出すのは PC 世代の自分には若干かったるい。ぱっとブラウザのタブを開いて興味のある都市の時間を調べたい。
要件定義は次の 3 つ。
- メインの時計は現在位置の時間を表示する。
- 任意の都市の現地時間を追加/削除できるようにする。追加する際は都市名で検索できるようにする。
- 追加した都市の時間は再度タブを開いたときも表示する。
1 は JS の API を使うだけ。3 もブラウザが提供する localStorage / IndexedDB で実装できる。2 に関しては Google Place API を使うことにした。
とりあえず欲しいものはできたけど、もう少しいじろうと思っている。レイアウトの調整と直感的でない操作の改善がひとつ。あとは React や Vue みたいなフレームワークを使ってみる、とか。