Archive for the ‘html5’ Category

[オフラインで使いたい]スマホで Indexed Database API まだですか [iOSに実装はいつ?]


2013
05.09

皆様、おはようございます。今回は Indexed Database API についてです。

そう言えば今年は蛇年ですね、蛇。

最近すっかり見なくなってしまいましたが、昔はシマヘビなんかを見かける度に追いかけ回したものです。

 

Indexed Database API ってどうなの?

さて、久々にガッチリと プログラム関係の技術情報 で行きたいと思います。初心忘れるべからずです。

当社は今年も引き続きスマホやタブレットの web アプリを推して行きたいと思っているのですが、必ずネックになる部分があります。

それはやはり「 ローカルでデータベースを操作する 」、これでしょう。

当サイトでも色々検証しました マニフェスト と組み合わせれば、オーディオ / ビデオ系の再生を除けばかなり ネイティブアプリ のようにゴキゲンな動作をするアプリが作成可能になります。

 

黎明期は JS から「 SQLite 」を扱う事が出来る Web SQL Database API 一択だったので、何も悩むこと無く使っていたのですが、 2010 年だか 2011 年だかにこの API が HTML5 の仕様策定範囲から外されてしまいました。(出典 : Livecast 様)

よって去年( 2012 年)は今後メンテナンスが無く、いつ消えても文句は言えない状態の Web SQL Database API なんて使ってらんないぜ!推奨されている Indexed Database API ってのに乗り換えよう!

と色んな解説サイトを巡りながらサンプルを作ってみたのは良かったのですが…

iOS で動かないのよ、コレが。

http://labs.vividworks.jp/indexedDB/

Indexed Database API

プログラムの中身はこんな感じ

※jQueryを読み込んでます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
var IDB = window.indexedDB || window.webkitIndexedDB;
var cursorIDB = window.IDBCursor || window.webkitIDBCursor;
var transactionIDB = window.IDBTransaction || window.webkitIDBTransaction;
var keyrangeIDB = window.IDBKeyRange|| window.webkitIDBKeyRange;
var DB_NAME = 'TEST';
var DB_VERSION = '1.0';
var DB_TODO = 'todo';
var DB_SORT = false;
var odb;

alert(IDB);

//=================================================
// DBのバージョン別 Object Store 初期化オブジェクト
//=================================================
var DB_OBJECT_STORES = {
    '1.0': function(odb) {
        var objectStore = initObjectStore(odb, DB_TODO, { keyPath: "id", autoIncrement: true });
        objectStore.createIndex('flg', 'flg');
    }
};

//=================================================
// Object Storeのイニシャライズ
//-------------------------------------------------
// DBスキーマが変更されていたら既存の Object Storeを破棄して再生成する。
// 存在し無い場合は新規で生成する。
//-------------------------------------------------
// @param:  object
// @param:  string
// @param:  object
//-------------------------------------------------
// @return: object
//=================================================
var initObjectStore = function(odb, name, option) {
    var check = odb.objectStoreNames;
   
    for(var key in check) {
        if(check[key] == name) odb.deleteObjectStore(name);
    }
   
    var objectStore = odb.createObjectStore(name, option);
    return objectStore;
};


//=================================================
// DBのバージョンを比較
//-------------------------------------------------
// @param:  string
// @param:  string
//-------------------------------------------------
// @return: bool
//=================================================
var checkVersion = function(current, latest) {
    if(current != latest) return false;
    else return true;
};


//=================================================
// DBオープン
//-------------------------------------------------
// @param:  object
//=================================================
var openDB = function(callback) {
    var req = IDB.open(DB_NAME);
   
    // オープンに成功
    req.onsuccess = function(event) {
        alert("success");
        odb = req.result;
        var current = odb.version || 0;
        var latest = DB_VERSION;
       
        if(!checkVersion(current, latest)) {
            odb.setVersion(latest).onsuccess = function(event) {
                for(var key in DB_OBJECT_STORES) DB_OBJECT_STORES[key](odb);
                typeof callback == "function" && callback();
            };
        } else {
            typeof callback == "function" && callback();
        }
       
        getTodo(function(todo) {
            showTodo(todo);
        }, DB_SORT);
    };
   
    // オープンに失敗
    req.onerror = function(event) {
        alert("error");
    };
};


//=================================================
// ObjectStore の取得
//-------------------------------------------------
// @param:  string
// @param:  string
//-------------------------------------------------
// @return: object
//=================================================
var getObjectStore = function(name, mode) {
    var transaction;
   
    switch(mode) {
        case 'READ_WRITE': transaction = odb.transaction(name, transactionIDB.READ_WRITE);  break;
    }
   
    return transaction.objectStore(name);
};


//=================================================
// データ書き込み
//=================================================
var putTodo = function() {
    if($('#title').val().length > 0) {
        var data = {
            flg: 0,
            title: $('#title').val()
        };
        var todoObjectStore = getObjectStore(DB_TODO, 'READ_WRITE');
        var put = todoObjectStore.put(data);
       
        put.onsuccess = function() {
            $('#title').val('');
            getTodo(function(todo) {
                showTodo(todo);
            }, DB_SORT);
            alert('登録しました。');
        };
       
        put.onerror = function() {
            alert('登録に失敗しました。');
        };
    }
};


//=================================================
// データ読み込み
//=================================================
var getTodo = function(callback) {
    var todoObjectStore = getObjectStore(DB_TODO, 'READ_WRITE');
    var res = new Array();
    var get;
   
    switch(DB_SORT) {
        case 0:
            get = todoObjectStore.index('flg').openCursor(0);
            break;
       
        case 1:
            get = todoObjectStore.index('flg').openCursor(1, cursorIDB.PREV);
            break;
       
        default:
            get = todoObjectStore.openCursor();
            break;
    }
   
    get.onsuccess = function() {
        var cursor = get.result;
       
        if(!cursor) {
            typeof callback == "function" && callback(res);
            return;
        }
       
        res.push(cursor.value);
        cursor.continue();
    };
   
    get.onerror = function() {
        alert('読み込みに失敗しました。');
    };
};


//=================================================
// データ更新
//=================================================
var updateTodo = function(id, mode) {
    var todoObjectStore = getObjectStore(DB_TODO, 'READ_WRITE');
    var update = todoObjectStore.openCursor(keyrangeIDB.lowerBound(id, false));
   
    update.onsuccess = function() {
        var cursor = update.result;
       
        if(cursor) {
            var data = cursor.value;
            data.flg = mode;
            cursor.update(data);
            cursor.continue();
        }
       
        getTodo(function(todo) {
            showTodo(todo);
        }, DB_SORT);
    };
   
    update.onerror = function() {
        alert('更新に失敗しました。');
    };
};


//=================================================
// データ削除
//=================================================
var deleteTodo = function(id) {
    var todoObjectStore = getObjectStore(DB_TODO, 'READ_WRITE');
    var del = todoObjectStore.delete(id);
   
    del.onsuccess = function() {
        getTodo(function(todo) {
            showTodo(todo);
        }, DB_SORT);
        alert('削除しました。');
    };
   
    del.onerror = function() {
        alert('削除に失敗しました。');
    };
};


//=================================================
// データ表示
//=================================================
var showTodo = function(todo) {
    var container = document.getElementById('todoList');
   
    for(var i = container.childNodes.length - 1; i >= 0; i--) {
        container.removeChild(container.childNodes[i]);
    }
   
    for(var key in todo) {
        var li = document.createElement('li');
        var update = document.createElement('input');
        var del = document.createElement('input');
        var html = '';
       
        update.type = 'button';
        update.value = '変更';
        update.id = 'todoUpdate_' + todo[key].id;
        del.type = 'button';
        del.value = '削除';
        del.id = 'todoDel_' + todo[key].id;
        del.onclick = function() {
            if(confirm("削除しますか?")) deleteTodo(parseInt(this.id.replace('todoDel_', '')));
        };
       
        if(todo[key].flg == 0) {
            html += '[未処理]';
            update.onclick = function() {
                updateTodo(parseInt(this.id.replace('todoUpdate_', '')), 1);
            };
        } else {
            html += '[処理済]';
            update.onclick = function() {
                updateTodo(parseInt(this.id.replace('todoUpdate_', '')), 0);
            };
        }
       
        html += todo[key].title;
       
        li.appendChild(document.createTextNode(html));
        li.appendChild(update);
        li.appendChild(del);
        container.appendChild(li)
    }
};


//=================================================
// ソート切り替え
//=================================================
var sortTodo = function(id) {
    switch(id) {
        case 'sort1':   DB_SORT = false;    break;
        case 'sort2':   DB_SORT = 0;        break;
        case 'sort3':   DB_SORT = 1;        break;
    }
   
    getTodo(function(todo) {
        showTodo(todo);
    }, DB_SORT);
};


//=================================================
// メイン処理
//=================================================
$(function() {
    $('#register').click(function() { putTodo(); });
    $('#sort1').click(function() { sortTodo($(this).attr('id')); });
    $('#sort2').click(function() { sortTodo($(this).attr('id')); });
    $('#sort3').click(function() { sortTodo($(this).attr('id')); });
    openDB(function() {
        //
    });
});

いやマジで、上記サンプルを iOS 系で開いてもらえば分かりますが、11 行目の DB Object 取得部分で既に undefined が返ってくる。( =未実装 )

こっち使えと言いつつ実際は使えないってどうなん?

ねぇねぇ?どうして欲しいの??

と、割と本気で思います。

 

2013 年には…と淡い期待も有ったのですがやはりダメなようです。

iPhone4 、 iPad Retina のそれぞれ最新の iOS ファームで確認( 2013/01/08 )。ちなみに Chrome for  iOS でも当然ダメだった、ベースの webkit が同じなんだから当たり前か。

勿論 PC や Mac の webkit 系なら動くよ!多分 IE 10 とかも大丈夫。

 

結局「 無いものは仕方がない 」と言う事で、引き続き SQLite でゴリゴリ作るしか無いのかなぁ。

私は別に代替えで推奨の物があるなら、別に SQL だろうが key / value 型であろうが何でも構わないと思っています。

だって、欲しいものは「 要件の動きが実現可能 」「 動作の安定感 」「 アップデートによる保守性 」じゃないですか?

 

今市場を見れば「 全力で HTML5 を使えるのは スマホ / タブレット だけ! 」と言うのは火を見るより明らかです、ブラウザが webkit 系しか無い訳だし。

じゃあ、さっさと何とかしてちょうだい!と思うのはワガママなのでしょうか。

 

どう考えても PC or  Mac より、スマホ or タブレットの方が ローカル DB の恩恵が大きい と言う事実が追い打ちをかけています。

据え置き機がオフライン状態でしか使えないなんて事、回線トラブルとか引越し直後以外に有るの?って感じですし。

個人が買うマシンは明らかに 過剰なオーバースペック になって行ってるし、回線だって ISDN が珍しくなってきているこのご時世に「 トラフィックを減らす 」「 高速ブラウジング 」なんてのは、本当に大きな大きなサイトを運営していて、かなりサーバがしんどいよ、きっついよって思ってる極々一握りの世界なんじゃ無いかって思うんです。

 

実際は色々無茶が出来る据え置き機で開発を重ね、仕様が安定してから iOS 等のコンパクト OS に移植するって事なんでしょうけどね。

 

今年も悶々としながら続報を待っています、もう少し勉強を進めるべきか。

なんとなく下書きが残っていたので投稿しました。

それではまた。

Indexed Database API について(へっぽこプログラマーの日記様)
Indexed Database API(HTML クイックリファレンス様)
Indexed Database APIによるデータベース(Libro.様)

などなど、参考にさせて頂きました。

[iPhone|iPad]iOSにおけるhtml5::manifestの削除について Vol.2


2011
06.02

さて皆さん、早速ですが manifest は結局どう言う仕様なのよ?と言う事を私なりに解釈した解説を実機スクリーンショットを交えて紹介して行こうと思います。

 

最初は公式シミュレータでやっていたのですが、Mac の HDD を参照してしまうので容量 1TB とかあってキャッシュして数値を動かすのが非常に困難だったので実機にしました。実機は 16.9GB みたいな感じで 100MB 単位で動かせるので都合が良いです。

 

まずはこの manifest とやらはどれぐらいのキャッシュを許可出来るんだろうかと言う所から、仕様書によるとデフォルトは 5M だけど拡張可能との事。

検証方法は 3.2MB のm4v ファイル(何でも良いんだけどね)を複製しておいて、それのパスを追加しては更新の繰り返しと言う作業です。

適当な要素に #console 的な id を書いて、イベント拾って出力しながら経過を見守ります。

 

まず最初に警告が出たのはキャッシュが 10MB を超えた時でした、

10MBを超えた時に即座にアラートが出現

なんだか良く解らないのですが、「増やす」を選択しても「キャンセル」を選択しても unknown error が出力されてキャッシュが止まってしまいました。

コンソールにログを非同期に出力

取りあえず例外が投げられたらキャッシュをやり直す処理を追記して、さらにパスを増やして行くと…

25MBでも確認した

今度は 25MB を超えた時点でアラートが発生した、同じくエラーが発生したようで再びキャッシュをし直す処理が走りました。

取りあえずもうチョット容量欲しいかなと思うので、更にパスを追記。

50MBも確認した

おお、50MB 行けたね!凄い凄い、普通のサイトならデフォルト 5M で余裕だけど WebApp だと結構リソース多いからね。取りあえずこれぐらい有れば十分かな?

ここから先は試していませんが、50MB は確実に行ける事が分かりました。

 

しかしこれ…10-25-50 の度にいちいち聞かれるんだろうか、アプリに合わせて任意に指定出来ればスマートなのになぁ。

 

さて、ココからが本題。HDD どうなった?な部分ですが、48MB キャッシュした時点で下記のように変化がありました。

befor

after

使用可能の項目の値が 12.9GB から 12.8GB に減少しているのが確認出来ます、まぁローカルにキャッシュしたのだから当然ですね。

で、ここで放置してしまうとユーザーはこの消せないキャッシュの積み重ねでいつか苦しむ事になります。

なんとかこの値を 12.9GB に戻す事が今回のミッションと言う事です。

 

まず manifest から先ほどから追記しまくった m4v のパスを全て消しました、これでそのページにはキャッシュしなくても良いファイルに変わる筈です。更新を完了させてから容量を見ても…変わって無い。

Safari をタスクから切って OS を再起動しても駄目でした、一体どこに保存されているのやら。

通常キャッシュとは別物だと思っていたのですが、設定から Safari のキャッシュをクリアして、Safari を再起動してみたら…

キャッシュを消去

容量復活!

見事容量が復活しました!

これで何とかユーザーを救えそうです、キャッシュクリアの操作をさせるのは難しい事ですが manifest からパスを外してあげるアンインストール的な操作方法を用意してあげるのが良いのでは無いでしょうか。

キャッシュ自体はほっとけば何かの拍子消えるモノですし、多分。(もしかして消えない?)若しくはアプリサイトにその旨を記載しておくとかでも体裁は保てるのではないかと思います。

 

まとめ

  • manifest 自体は削除出来ない(今の所)
  • manifest からパスを外したファイルは通常キャッシュ領域に移動される、又は同じキャッシュ領域だが manifest に書かれていると削除されない仕様のどちらかである
  • manifest からパスを切ったファイルは Safari のキャッシュクリアで消せる
  • manifest 対応サイト乱立によるユーザー資源の浪費を食い止める操作を提供してあげないと大変な事になる
  • Apple が設定画面から消せるような機能を追加してくれるのに期待する

と言った感じですかね、細々したファイルなら然程気にしなくても良さそうですがやはり消せないゴミファイルが溜まって行くのは気持ちが良いモノではありません。

大きなキャッシュを残したいのであれば、削除出来る機能を提供してあげるのがマナーとなりそうですね。

今回は中々興味深い結果となりました、今後の仕様変更に注目して行きたいですね。

 

それではまた。

 

1 | 2

 

[iPhone|iPad]iOSにおけるhtml5::manifestの削除について Vol.1


2011
06.02

暑くなったり肌寒くなったりで体調を崩しそうな大阪ですが、皆様の周りは如何な具合でしょう?

 

さて、今回は結構重要な話しです。

 

今後隆盛を迎えるであろう WebApp はもちろんの事、これから先は PC ブラウザで閲覧する html5 コンテンツも快適性を追求して当たり前のように使われる事になると思われる manifest の仕様についてのレポートを作りました。

実は私は色々開発はしているものの、オフラインで動かすための manifest 指定と言うモノに少々気持ち悪さを覚えています。

理由は単純に「良く解らない」からです、これが凄く気持ち悪い。

 

manifest とは、非常に強力で有用な技術です。簡単に言えば超強力なキャッシュ機能と言うとしっくり来ますね。manifest ファイルにそのページで使われるリソースのパスを書いておくと、ローカルにそれらを保存して Web にアクセスする事無くそれらを利用する事が出来るようになります。

その性能はと言うと、大げさでも無くネイティブアプリに迫る高速読み込みが行え、おまけにオフラインでも何の問題も無いと言う高性能っぷり。

 

初めて知った時は「これはもう使うっきゃ無いよね!」と思いました、だって凄いでしょう?

所が使うに当たって当然出てくる疑問があります、それは「これってどうやって消すの?」と言うシンプルな疑問。

導入方法は数多くのサイトで語られていますが、manifest を消す方法、特に iOS では未だに確立されていません。唯一消す方法は復元を実行すると言う超力技だけと言う現状。

 

本当に消えない、と言うか消せないんです。後述しますが、私も実験中に順番を誤り個人的に所持している iPhone から二度と更新出来ない URL を作ってしまいました。

※これを回避する為の準備も紹介します、絶対にこれは守らないと泣きを見ます。

PC の方は結構なんでもありなので消せる見たいですが、我々が目を向けなければならないのは一般ユーザーの方達です。

 

例えば、こんなケースが想定出来ます。

  • 色々なサイトで manifest を使った高速ブラウジングが次々と実装される
  • ユーザーは色んなサイトを閲覧し続ける
  • なんだか iPhone/iPad の HDD 容量が凄い事になってる
  • 消せない助けて

 

如何でしょう、何とも無惨な事になると思いませんか?ここで彼等に「復元しろ」とか「初期出荷状態に戻せ」なんて言える筈ありません。

この問題の指摘は Apple 社にもメールしておこうと思います、設定の Safari から manifest を個別削除出来る機能をつけないとえらい事になるんじゃないのか?と。

 

さて、取りあえず今の所 manifest 自体は「消す事が出来ない」仕様のようなので、せめて「容量を解放する方法はあるのか?」と言う方向で検証してみましょう。

 

まず manifest を触る際に、絶対に準備しなければならない事があります。

それは Javascript による manifest のチェック機能です、これを必ず最初に作る所から始めましょう。

これが無いと、そのページを開いた瞬間「消す事が出来ないキャッシュが生成されて、以後そのキャッシュしか見ない」と言う状況が発生します。

そうです、永久にそのデバイスからはその URL の情報を更新出来なくなります。先ほど少し触れましたが、私は個人所有の実機でこれをやらかしました :(

 

Javascript ソース

1
2
3
4
5
6
7
8
9
10
var appCache = window.applicationCache;
var checkUpdate = function() {
    if(navigator.onLine) appCache.update();
    else alert('Please update for online.');
}

appCache.addEventListener('updateready', function() {
    appCache.swapCache();
    location.reload();
}, false);

html ソース

1
<input onclick="checkUpdate()" type="button" value="update" />

 

簡単に要所だけ書くとこんな感じのソースになります、ここでは挙動を調べる事に集中するので詳しい設置方法や、細かいイベントの返り値なんかは、凄く丁寧に解説して下さっているブログがあるのでそちらを参照してください。

ちなみにこの appCache.update() はロード完了後に自動的に一回だけ呼ばれますが、記事を読んでると絶対じゃないそうなので念のため適当なボタンからアップデートをチェックする関数を実装しています。

 

そして manifest ファイルを準備して html 要素に manifest=”ファイル名” を追記すれば晴れて「何度でも更新出来る manifest 付きページ」の完成です。やったね!

 

次回は実機のスクリーンショットを交えて、HDD の容量変化を見ながら実際に manifest キャッシュを行ったモノを紹介します。

 

それではまた。

 

1 | 2

 

[iPhone/iPad]WebAppのクラス設計について


2011
05.17

 

皆さんこんにちわ、お久しぶりです。

GW 以降バタバタしておりましたが一段落したと言いたい所ですが、この業界の三大職業病「肩こり」「腰痛」「イボG」のウチ、堂々の二冠達成と言う偉業を現在進行形でございます。

 

ちょっと単発ネタです。

先週は在宅勤務で引きこもっていたのですが、何もただ単に引きこもっていた訳ではありません。

仕事や家事や PSP でミクさんを愛でる時間の合間をぬって、iPhone / iPad 用の WebApp の開発を行っていました。今回構築しているアプリのベース部分が完成して、初代 3G なんかも引っぱり出して来てニヨニヨとしていたのですが…ここからエフェクト系を山盛り入れるには、とてもじゃないけどメモリが足りない事に気づきました。

 

だって既にちょっと重いもん。

 

画像や音声ファイルのロードは最初からネックになる事は解っていたのですが、JS で生成しているオブジェクトと我らの味方の jQuery が重たくてしょうがないのです。

 

安定性を欠くようではリリースなど片腹痛い訳で、これはもう基礎部分から再設計を余儀なくされた状況です。

リソースをふんだんに使うタイプの WebApp 開発にあたり、今回解った事は下記。

  • jQuery は使わない方が良さそう
  • prototype 定義を最大限使わないとキツい
  • Ajax による リソースの loader 部分がかなりマゾい
  • ダイナミックな動きは全部 CSS3 で賄う

1つ目は単純にライブラリよりネイティブの方が早い、当然ですね。クロスブラウザ対策が必要ないのでそこまで恩恵がある訳では無いので外す方向で行こうと思った。
そうなると要素に対して CSS3 を適応させる部分は大丈夫なんだろうか、とかの不安が残る。

 

2つ目は調子に乗って何個かインスタンスを作っているとメモリがアワワな事になる、昔 prototype 汚染にトラウマ級の悪さをされて以来食わず嫌いになっていたがそんな事言ってられなくなった。
これは私の設計の甘さもあるのですが…ガベージコレクションを使う手もあるんだろうか。

 

3つ目は WiFi 環境なら余裕で無視出来るレベルなのですが、貧弱な 3G 回線だとメディアファイルの load 時間がかなりストレスになる。しかし全てのファイルを始めに読み込んでおくのは 3G や 3GS のメモリ量だと厳しい感じがするので、先読みアルゴリズムを極限まで詰めて解放/読み込みをこまめにやるべき…?
逆にキャッシュの限界量に合わせてリソース側に制限をかけるべき…?

 

4つ目は jQuery を解雇するなら当然変わりにアニメーションを担当するものが必要になる、これは使ってみた感想だけど CSS3 でアニメーションさせると jQuery の何倍も何十倍も綺麗に動くので他に選択の余地は無いと思った。
canvas を使う手もあるのだけど、html5 で組むからこそ意味があると思っているので FLASH 的な動き(むにゃむにゃ動く背景とか)の時だけ使うのが良いと思う。

 

世界的にまだまだ開拓途中のこの WebApp と言う世界、今後のスマートフォンの主力コンテンツとしての活躍が期待されているのでやり甲斐があって楽しいのですが…

 

WebApp は開発サイクルが短くて予算節約になりますよ!

 

と言う売り文句を言えるようになるまでが大変そうです、セオリーは俺が作る!ぐらいの気概は必要ですね。

 

それではまた。