12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

位置情報共有Webアプリを作ってた話

Posted at

サ終ってま???

某スマートフォン向け位置情報共有アイスクリームアプリ、親会社の影響でどうやらサ終するみたいなんですよね。
んで、自分自身もZenlyって結構使ってたし、周りでも沢山の人が使ってたので、サ終しちゃうのはちょっと悲しいです。

...

代わりになるの作ればよくね???
ってなわけで代わりになりそうな位置情報共有アプリを作る計画が始まったのでした。

なお、この記事は突発的に見切り発車で書き始めたので多分どうしようもない記事になってると思います。

まずは基本構想

まず、アプリケーション・サービスを作るのであれば、仕様書を書くとまではいかずとも、構想は考えるべきです。
さて、出来上がった仕様書はこちらです。
image.png
(流石にネタです)

(カクカクシカジカありまして) クロスプラットフォームで使える アプリケーションを作成することに決まりました。

さて、いざクロスプラットフォームで作るとなって、どの言語・フレームワークで作って行けばいいのでしょうか。
まず、前提としてあんまりお金は掛けたくないのでApple Developer Programには入りたくありません。

となると、だいぶ選択肢が限られてきます。
Expoや、サイドロードなど、色々な単語が頭をよぎりましたが、最終的には Webアプリ になりました。

Webアプリってなぁに?

みなさんの中にもいると思います。
「プログラミング? ちょっと何言ってるかわかんないけどホームページ制作ぐらいなら出来るよ」って人。
Webアプリというのは、その名の通り「Webの仕組みを利用したアプリケーション」です。(広義)
Webの仕組みとは、HTMLやJavaScript、CSSとかそこら辺が含まれて、いつも通りWebページを作る感覚でアプリケーションを作るといったような感じです。
例えば、Amazonのページ。あれなんかも私の中ではWebアプリケーションだと思っています。
説明が難しいので先行っちゃいましょう。

私の中で、Webアプリには「めちゃくちゃクロスプラットフォームな点」と「デザインめちゃくちゃ楽な点」というメリットが存在します。
なので、存分に活かしていきましょう。

サーバー? なにそれ美味しいの?

今回は、「位置情報を共有するサービス」を作りたいんです。
なので、その位置情報を何処かにおいておいたりしなければなりません。
というわけで、サーバーも必要になります。
このとき、Webアプリ側は「フロントエンド」、サーバー側は「バックエンド」と呼ばれるようになります。

そして、フロントエンドの環境は特に考える必要はないので、バックエンドの環境を考えます。
バックエンドはどの言語を使ってもよく、一番得意なPythonを使おうとしましたが、PythonとWebSocketを掛け合わせたときに挫折しかけた経験が頭をよぎり、結果的にTypeScriptを使うことになりました。

これが初めてのちゃんとしたTypeScript経験になります。

最初はJavaScriptだったんですよ...

JavaScriptで書いてるうちに、「なんかー型ヒント欲しいなー」って思ったんですよ。
また、「JSじゃなくてTSだよなぁ?」という圧を何処かから頂いたので、TypeScriptに変えることにしました。
まぁ、どうせちょっと型ヒントつけるだけでコード変換ぐらいすぐ終わるだろって思ってたんですよ。

残念! さやかちゃんでした!

requireで書いてたのに全部importに置き換えろって!
いやまぁimportの方がPythonでなれてるしまぁいいやって思ってたのに、なんかただimport文に変えただけじゃ動かない!?
ドキュメントみても全然わかんない!?
ん!?!?
ってなってました。

言語を途中で変えるなんて奇行はやめたほうがいいです。(戒め)

そしてちゃんと構想へ...

そして、ベースとなる構想を図解するとこのようになります。
image.png
基本はこの流れで、この上にユーザー認証やいろんなものがついてきます。

作成開始

マップを表示する

何と言っても位置情報を表示しなければならないので、マップが必要になります。
例えばiPhoneのZenlyなんかは、AppleのMapKitを使用してマップを表示しています。

なぜMapKitを使わなかったか

この記事書いてるときに「あれ? じゃあこれ使えばよくね...?」とか思いました。
実際、Apple Mapはかなりシンプルかつ見やすく、多分使いやすいものだと思いました。
そして、iOS/iPadOS/macOS以外(JavaScript環境?)でも使えるMapKit JSというものの存在も知りました。
「じゃあ、これ使ってみようよ」となりました。
Apple Developer Programへの加入が必要でした。
Apple Developer Programへ加入していれば、MapKit JS使えて、AppStoreにアプリ出せたんですね...うーん悲しい...

そして、無料かつ使いやすそうなマップ表示のAPIを探していった結果、 MapBoxというものを使うことに決めました。
日本語記事もいくつか見つかったので、これならなんとかなるなと思いました。

また、自身や他者の位置情報を表示する「マーカー」の設置についても公式にドキュメントがあって親切でした。

では、実際にフロントエンドを作っていきましょう...とまぁ、長いのは嫌いなので、気になった方はスパゲッティーコードなレポジトリみてください。

位置情報を取得する

ブラウザJavaScriptには「Geolocation API」というのがあります。
もうこれも先に参考記事載せちゃいます...

簡単にまとめると

location.js
// 一度だけ位置情報を取得する
navigator.geolocation.getCurrentPosition( 成功時動作 , 失敗時動作 , オプション );

// 継続的に位置情報を取得する
navigator.geolocation.watchPosition( 成功時動作 , 失敗時動作 , オプション );

このようになります。

このnavigator.geolocation.watchPositionを使用することで、「位置情報を取得した上で、マップに表示されているマーカーの位置を変更する」といったようなことが凄く円滑に行うことができます。

と、ここまでの「マップを表示する→マーカーを付ける→位置情報を取得する→位置情報に合わせてマーカーを移動させる」という流れが、バックエンドに取り掛かるまでのフロントエンドでの作業です。

バックエンドに取り掛かる

宣告通り、バックエンドはTypeScriptで作っていきます。

さて、位置情報は常に変わり続けるものです。
その為、頻繁にフロントエンドとバックエンドで通信が行わます。
このとき、毎度毎度HTTPでのリクエストを行っていてはなんか大変なことになってしまいそうです。
ということで、WebSocketというものを使います。
WebSocketとは、ブラウザとウェブサーバーで双方向通信を行うことが出来る、ソケット通信と呼ばれるものです。
WebSocketを使えば、ずっとTCPコネクションが張られているので、位置情報の送受信の際に毎度毎度コネクションを張る必要がなく、高速化などが期待できると思いました。

ということで実際に作りましょう。

index.ts
import express from 'express';
import WebSocket, { WebSocketServer } from 'ws';

const WebSocketPort = 8080;
const HTTPPort = 8081;

const app = express();
const wss = new WebSocketServer({ port: WebSocketPort });

wss.on('connection', function connection(ws: WebSocket) {
    ws.on('message', function incoming(message: WebSocket.RawData) {
        // WebSocketでの通信の処理部分
        console.log(message.toString());
   }
}

app.listen(HTTPPort);

下処理をしたものがこちらです。
あとはWebSocketでの通信の処理部分に色々なことを書き込んで行けばすぐできます。
やはりTypeScriptにしておいてよかった...

位置情報のやり取り

ここからはもうハイスピードです。

と、その前に、フロントエンドでWebSocket通信を行う準備をしましょう。
やり方はすごい簡単です。

WebSocketの定義
const socket = new WebSocket("ws://localhost");

あとは、socket.send("I'm Frontend!")とでもしてやれば、バックエンドにメッセージが送られます。

では取り掛かりましょう。
まずはフロントエンドで位置情報を取得したら、それをバックエンドに送る処理!
navigator.geolocation.watchPositionとかで取れる位置情報オブジェクトには「緯度経度」「高度」「速度」「精度」などの情報が入っています。
これらから必要なものだけを抜粋して、ソケットに乗せて届けます。

その前に、「アカウント」について考えましょう。

アカウント管理/位置情報送信

アカウントはバックエンド内で管理します。
バックエンドに送るデータには必ず「ユーザーID」「パスワード」をつけて、これらが正しいかをバックエンドで照合した後に作業を行います。
例えば位置情報をバックエンドに送る関数はこの様になっています。

socket.send(
    JSON.stringify(
        {
            command: "POST",
            uid: account.uid,
            password: account.password,
            location: [loc.coords.latitude, loc.coords.longitude, loc.coords.speed, time.getTime()]
        }
    )
);
キー 意味
command バックエンドでやってほしい作業を表すコマンド
uid ユーザーID
password パスワード
location 位置情報データ

このように、位置情報を送るのにもユーザーIDとパスワードを送信しています。
そしてバックエンドではまず認証を行います。

const auth = (data: SocketData) => {
    if (!("uid" in data) || !("password" in data))
    {
        throw "BadRequest";
    }
    const uid = data.uid;
    const password = data.password;
    if (users[uid] === undefined)
    {
        throw "Forbidden";
    }
    else if (users[uid] !== undefined && users[uid].password === password)
    {
        return;
    }
    else
    {
        throw "Forbidden";
    }
}

このauth関数が必ず実行されて、問題がなければそのまま処理が実行されるという感じです。
なお、パスワードはバックエンド・フロントエンドともにハッシュで管理しています。
(あと、本番環境はちゃんとSSL/TLS認証されています。)

(今更「SSLとかTLSとかわかんない!」って人向け)

そして、位置情報送信に戻りますが、認証に問題がなければバックエンドでデータ内にて自身のデータの位置情報が更新されます。

さて、送信しただけではなんの意味もないので受信をしなければなりません。
かといって、全員の位置情報を受信できてはいけないので、位置情報を受け取れる人を限定する「フレンド機能」的なものを作りましょう。

フレンド機能/位置情報受信

ゲームとかでもありますよね?
フレンドになってればパーティーに参加できたり、フレンドじゃなければできないとか...
まさにそのまんまで、位置情報を受け取れる人を限定するのにうってつけなものです。

image.png

フレンド機能については、大雑把に上記のような構造で、「申請をする」「申請を許可→晴れてフレンド/拒否する→何もなかったようにもとに戻る」というような流れです。

より細かくすると、

  1. ユーザーAの「承認待機列」にユーザーBのIDが入り、ユーザーBの「受信待機列」にユーザーAのIDが入る。
  2. ユーザーBが次にアプリを起動したときに、受信待機列を受け取って、フレンドになるかどうかを決める。
  3. フレンドの許可・拒否問わず、双方の待機列から双方のユーザーIDが消える。
    A. フレンドを許可した場合は、待機列から消えてうえで、双方の「フレンドリスト」に双方のユーザーIDが入る。
    B. フレンドを拒否した場合は、待機列から消えただけで何も起きない。

というような流れです。
これらが両フロントエンドとバックエンドでやり取りされていきます。
懐かしいなぁ...フレンド削除できないバグ...

さて、これで晴れて、ふたりはプリk...フレンドになれました。
位置情報を受け取りましょう。

今回の受け取り方法は、ユーザーごとに位置情報を受け取るのではなく、定期的にフレンド全員の位置情報を取得して、それを一気にマーカーに設定するような方法でやります。
といっても、詳しいところは割愛しますが、UIDがキーのオブジェクト形式で送信されたものを、フロントエンドで分解してマーカーに設定するというような形です。

アイコン変更機能をつけよう

フレンドが出来たことで、自身のマップにマーカーが2つ以上存在する状況になりました。

「...アイコン同じだからわかんねぇ!!!」

ってなわけでアイコン変更の機能をつけましょう。
というわけで、クライアントで画像を受け付けて、それをバックエンドに送る必要があります。

<input type="file" id="iconfile" name="iconfile" accept="image/jpeg, image/png">

このようなinputを置いておけば、アイコンのアップロードとかが出来るようになります。

さて、サーバーではどのように画像を受け付けましょうか。
WebSocketで画像のデータをBase64エンコードとかして送ればいいかなーとか考えましたが、流石にめんどくさそうなので、バックエンドで画像のアップロードが出来るHTTPエンドポイントを作りました。

app.post('/upload_icon', upload.single('iconfile'), (req, res) => {
    const file = req.file
});

こんな感じで、Expressappにエンドポイントを作る形でゴニョゴニョやれば、ファイルを受け取ることが出来ます。
あとは、fsとかでファイルをどこかに配置したりして、ユーザーデータでアイコンのURLを変えたりすれば完成ですね。

とりあえず今はここまで

必要最低限な機能はつけました。
あとはHTMLのデザインですが、どちらかというと専門外ゲホンゲホンなのでゆっくりやることにしていきます。

いざ公開!...とはいかず。

PWA(Progressive Web Apps)というものをご存じでしょうか。
これは、「Webアプリを出来る限りネイティブアプリに近づける事ができるWebでの仕組み」です。(正確には間違ってるような気がしますが目をつむってください...)
ネイティブアプリとは、Google PlayAppStoreMicrosoft Storeなどのアプリケーションストアからインストールするアプリで、Webアプリよりたくさんの機能を持っています。
例えば、Webアプリは毎度ブラウザから開かなければなりませんが、ネイティブアプリではホーム画面とかにアプリアイコンがあり、そこから開いたり出来ますよね。
ここらへんの差異を出来るだけ減らして、ネイティブアプリに近づけよう~!みたいなのがPWAです。

項目 ネイティブアプリ PWA
ホーム画面への配置
アプリストアでの配信 △(※1)
Webサイトからのインストール △(※2)
全画面表示
通知 △(※3)
位置情報取得
オフライン動作
バックグラウンド動作 △(※4)

※1:Google PlayではPWAアプリの配信が出来るようです。
※2:まぁapk配布は楽だけど、ipa配布はちょっとめんどくさいよね...
※3:Android/PCでは可能ですがiOSではまだ出来ません。ですが、2023年にはiOSでも出来るようになるとWWDCで発表されていますし、iOS16の実験的なWebKitの機能の一覧には...?
※4:下で説明

とまぁ、なんか結構ネイティブアプリに近づけることが出来るPWAという機能です。
今回私は「バックグラウンド同期」という言葉に引かれてPWAにすることに決めました。
しかし、PWA(サービスワーカー)は、基本的にユーザーからのアクションで各種機能が実行されるというような機能なので、バックグラウンドでずっと実行しまくるっていうのは難しいらしいです。
(詳しくは調べてませんが、Push通知の機能を活用すればバッググラウンドで動作出来そうな希ガス...)
そもそも、バックグラウンドで位置情報は取れないらしい。
うーん、これには流石に涙目。

(バックグラウンド同期についての参考記事)

でもまぁ仕方ないってことで、(将来誰かがPWAで完璧なバックグラウンド動作を可能にすること || 将来私がApple Developer Programへ加入してネイティブアプリを作る)ことを祈って、とりあえずこのまんま作っていきましょう。

PWAの実装

簡単で
す。

(詳しい説明は他の記事を見ていってください..)

まず、フロントエンドのルートディレクトリにmanifest.jsonというものを作ります。

manifest.json
{
    "name": "アプリの正式名称",
    "short_name": "アプリの名前",
    "description": "アプリの説明文",
    "icons": [
        {
            "src": "icon.png",
            "sizes": "192x192",
            "type": "image/png"
        }
    ],
    "start_url": "/",
    "display": "fullscreen",
    "theme_color": "テーマカラー",
    "background_color": "背景色"
}

こんな感じで。
そしたら、HTMLに

index.html
<link rel="manifest" href="manifest.json">

というふうな記述をつけておきましょう。

そうしたら、次にサービスワーカーのファイルを作りましょう。
サービスワーカーとは、PWAでの動作を司るようなファイルです。

sw.js
self.addEventListener('install', event => {
});

(これは最低限の処理です。)
そしたら、このサービスワーカーファイルを読み取る処理を書かなければなりません。
どこかにJavaScriptとして下記の記述をしておきましょう

register.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js').then(function(registration) {
    console.log('成功!');
  }).catch(function(err) {
    console.log('失敗!');
  });
}

そして、このJavaScriptを読み込めば、サービスワーカーがブラウザに登録されると思います。

あとはユーザーがインストールするだけです。
iOS/iPadOSでは、Safariにてページを開いてる状態で、共有メニューから「ホーム画面に追加」を行うことでPWAのインストールが出来ます。

これで、PWAは一旦完成です。

なんか煮えきらないけどまとめ!!!

これはPWAの技術がもうちょっと強くなればなと願うしかない...
とまぁ、小さいプログラマーのちょっとした奮闘記でした。

皆様はZenlyの代わりに何を使いますか?
iOSであれば「探す」が確かに優秀だと思いますが、まぁ世界はそんなiPhone民ばっかじゃないんで、必然的に他のものを探さなきゃいけなくなりますね。
え!? 「この記事で書いてるやつを使ってみたい!?」 だって!?(流れるような宣伝)

...ってなわけで、私のネタツイのせいでのおかげで、Team-i2021のプロジェクトとして、Celarという位置情報共有Webアプリを作り始める羽目ことになりました。
まぁこの記事の通り、当分は絶対完成しないんですが()
まぁあくまでも未完成なWebアプリとして眺めてくれれば嬉しいです。

・フロントエンドレポジトリ

・バックエンドレポジトリ

(ちなみに、Apple Developer Programでは、非営利団体かつアプリを無料で提供すれば、無料でDeveloper Programにアクセスできることが書いてありますので、グループで登録すればなんとかなりそうな気がします。詳しくはわかりません。まぁめんどくさいのでやりませんが)

では、またいつかどこかでお会いしましょう...

あぁあと...Zenlyさん勝手に名前出してごめんなさい()

あとがき

Zenlyサ終前にこの記事書いたんですけど、下書きで眠ってたら、気づけばZenlyサ終してました(てへぺろ)
完成もしてませんし、これ以降手をつけることもないでしょう...()

12
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?