目次
1. はじめに
2. アプリ概要
3. 使用技術
4. アプリケーション構成図
5. DB設計
6. 開発スケジュール
7. 工夫した点・気をつけた点
8. 反省点
9. +予定している機能拡張
10. 終わりに
はじめに
自己紹介
- 文系公立大学25卒
- 長期のエンジニアインターンシップを2つ経験(計約1年半)
- 現在は休学してベトナムで海外の長期インターンシップに参加中です。
- 主なフィールドはサーバーサイドでPHP, Pythonあたりを使用しています。
- フロントエンドもほんの少しやってます。
記事の内容
- 基本的には自分の作成したアプリケーションの設計、工夫、苦労した点などを書きます。
- 技術についても要所要所で触れますが、細かいところは他でまとめようかなと思います。
ポートフォリオや個人的に簡単なアプリケーションの開発をしてみたい人の参考になれば良いかなと思います。
アプリ概要
追加QR
できたら追加して遊んでみてください!
意見などお待ちしています。
デモ
請求書送信
今日ハッと思いついて、友達に貸した金の請求書を送り付ける機能を作ってみました。
— yuta@25卒サーバーサイド (@yuta1984421) December 19, 2023
直接返しては言いづらいのを解消するために、少しユーモアにしてみたく笑
テストも兼ねて弟にたくさん送りました#個人開発 pic.twitter.com/Iarg2dGbX5
機能
- お金貸し借り記録の登録・確認・編集・削除
- 計算機能があるので、精算額がすぐにわかる
- 請求書を作成して、LINEの友達に共有できる
- LIFFアプリからもLINE BOTのメッセージからも精算額を確認できる
作成動機
- 現在シェアハウス中でお金の貸し借りが増えたから (よく返し忘れるし、もらい忘れる)
- 既存アプリはあるが、容量に余裕がないのでできればインストールしたくなかった
- 多くの人にとって身近であるLINEだったらかなり使い勝手が良いかなと思った
- 探した範囲ではこの手のLINE BOTはまだなかった。誰も作ってないものが作りたかった。
- (家が自営業をしていてそこのアカウントとかに応用できるかなと思い、Line BOT関連の技術で何か作ろうと思った)
- (なにかデプロイまでしたかった)
強み
- 広告がでない
- 精算額自動計算でいくら返せば良いかすぐわかる
- アプリインストールの必要&ユーザー登録がなく、みんなよく使うLINEでできるのでスタートの心理的ハードルが低い
- SPAでさくさく動く
- 請求書を送付する機能をつけたことで、返してというハードルが軽くなる(ユーモア)
Githubリンク
フロントエンド
サーバーサイド
使用技術
技術一覧
サーバーサイド
- PHP (Laravel)
フロントエンド
フロントエンドは大して業務での経験もないのですが、Next.jsのapp routerを使ってみようということで今回は挑戦してみました。
- TypeScript (Next.js)
- Tailwind CSS
- Line Frontend Framework
データベース
- MySQL
デプロイ先
- Xserver
- Vercel
その他
- Git
- Github
- Github Actions
- Line Messaging API
- Line Login API
- Docker-compose (ローカルのみ)
- Ngrok
アプリケーション構成図
以下はこのアプリケーションの流れを示した図になります。
これをみてもらうとわかると思いますが、全ての動作に関してLINEのプラットフォームが絡んでくることになります。
詳しくは説明しませんが、そうすることでuserの情報をセキュアに管理することが可能になっています。
DB設計
こんな感じです。ユーザーの情報はLIFF経由で都度LINEプラットフォームから取得するのでテーブルにそこまで保存するデータはないです。
開発スケジュール
11/10 - 要件定義等
11/10 - Laravelで開発開始 (初期開発時はサーバーサイドLaravel + フロントエンドはBladeで作成)
11/18 - Next.jsでフロントエンド開発開始
11/27 - 形になったのでリリース
上記のスケジュールで開発しました。
暦上では約17日間ほどで作りました。現在フルタイムのインターンシップに参加しているので、夕方以降に開発作業していました。なのでフルでコミットできれば4-5日あれば終わるボリュームではあるかなと思います。
Next.jsやTypeScriptなんかは特にこれまであまり触れてませんでしたが、その割には結構スムーズだったかなと思います。
UIの構築にGPT等のAIツールが使えるようになったのは大きいなと思います。
機能追加も考えているので、これから多分また時間をかけて色々作っていきます。
工夫した点・気をつけた点
清算がしやすいUX (server side & client side)
- メッセージからも、LIFFアプリからも清算は可能
作成は一度にたくさん行うものではないですが、清算は相手ごとにまとめて行うケースはかなりあるかなと思います。
そこでこの機能に関してはLIFFだけでなく、メッセージ内でも処理が実行できるようにしました。
セキュリティ (server side & client side)
- Liffで取得した情報のサーバーへのリクエスト周り
Liff(LINEのトークルーム内などで開くブラウザくらいに考えてください)上ではアクセスしているユーザーの様々な情報を扱うことができます。
ex) LINEの登録名、トップ画像 etc
ただ、それらの画像を自分のサーバーに送ることは以下の画面からもわかるように推奨されていません。
超端的に説明すると、LIFFブラウザ内で取得したデータを、サーバーにそのまま送るのは危険であるため、accessTokenを都度LINEのプラットフォームから取得することでそのセキュリティ的な問題を回避しようと言ったものです。
ここら辺は初めて触る場合は、しっかりドキュメントを見ないとわからないところではあるかなと思います。
また、最初自分も確認を怠っていたので自作でliff用のtokenを作成していました(笑)
アクセスしたユーザーの情報取得をmiddlewareで行う (server side)
上記の記述を見ればわかるように、ユーザーの情報は直接サーバーに送信せず、userに割り当てられているアクセストークンをわたし、それを用いてLINE Login APIにリクエストすることでLINEのユーザーを取得します。また、その情報からさらに、データベースに保存しているユーザーの情報を取得する処理が毎回行われます。
そこで以下のようなmiddlewareをLIFFからのリクエストが来るルートに割り当てることで、冗長な記述を回避しています。
また、LineLoginApiへのリクエスト等はtraitで切り離しています。
accesstokenによる認証の成功 && DB内のuserデータとの照合が完了した場合は、requestオブジェクトにuserのデータを加えます。
このようにすることで、controller内では$request→attributes→get(’user’)でuser情報を取得してくることができます。
app/Http/Middleware/AuthenticateWithAccessToken.php
use LineLoginApiAuthTrait;
public function handle(Request $request, Closure $next): Response
{
$accessToken = $request->query('accessToken');
$lineUserId = $this->getLineUserIdFromAccessToken($accessToken);
$user = User::where('line_user_id', $lineUserId)->first();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->attributes->add(['user' => $user]);
return $next($request);
}
アプリでLIFF initは1度のみの実行で大丈夫にする (client side)
Liff initは一応何度実行しても問題ありませんが、実行するたびにでLIFFの読み込みscriptが増幅していく仕組みになっています。
できれば避けて通りたいところです。
そこでContextで一度initしたliffオブジェクトをステートで持たせてそれを全てのページで使うことで問題を解消しました。
app/_context/LiffProvider.tsx
'use client'
import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
import liff, { Liff } from '@line/liff'
type LiffProviderProps = {
children: ReactNode;
};
const LiffContext = createContext<Liff | null>(null)
export const LiffProvider: React.FC<LiffProviderProps> = ({ children }) => {
const [liffObject, setLiffObject] = useState<Liff | null>(null)
useEffect(() => {
liff
.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! })
.then(() => {
setLiffObject(liff)
})
// 省略
}, [])
return (
<LiffContext.Provider value={liffObject}>
{children}
</LiffContext.Provider>
)
}
export const useLiff = () => {
return useContext(LiffContext);
}
accessTokenをキャッシュ (client side)
セキュリティの箇所でサーバーサイドにリクエストする時は直接ユーザー情報を渡すのではなく、accessTokenを使うということを書きました。
ということはブラウザ内でjavaScriptを用いてaccessToken取得のリクエストをする必要が出てきます。
ただ、リクエストのたびにliff上でaccessToken取得をリクエストすると、同じデータをとってくるのに余計なリクエストが多くなることになります。(LINEプラットフォームによるaccessTokenの更新は12時間おきに実行されます)
それを回避するために最初のリクエストの際にキャッシュをして、2度目以降のサーバーサイドへのリクエストにはそれを使うようにしました。
app/_libs/data.ts
type AccessTokenCache = {
token: string | null;
timestamp: number | null;
};
let accessTokenCache: AccessTokenCache = {
token: null,
timestamp: null
};
// アクセストークン取得
async function getAccessToken(liff: Liff, cacheDuration = 300000) {
const currentTime = new Date().getTime();
if (accessTokenCache.token && accessTokenCache.timestamp && (currentTime - accessTokenCache.timestamp) < cacheDuration) {
return accessTokenCache.token;
}
const accessToken = await liff.getAccessToken();
if (!accessToken) {
throw new Error("Access token not found");
}
accessTokenCache = {
token: accessToken,
timestamp: currentTime
};
return accessToken;
}
// サーバーサイドへのリクエスト
export async function getOpponents(liff: Liff) {
const accessToken = await getAccessToken(liff);
const params = {accessToken : accessToken};
const query = new URLSearchParams(params);
const response = await fetch(`${API_DOMAIN}/api/liff/opponents?${query}`);
if (response.status !== 200) {
throw new Error("API Error");
}
const opponents = await response.json();
return opponents.data;
}
Messaging APIのリクエスト処理振り分け (server side)
Messaging APIからのリクエストはWebhookに登録しているエンドポイントに全て飛んできます。
なので、画像が送られようが、スタンプを送られようが、同一エンドポイントにきます。
そのため、渡されたデータ(Event)の種類に基づいて、ルーティング(処理の振り分け)が必要になります。
私は以下のようにして、Eventのオブジェクトのインスタンスを判定して、それごとに処理を分けるようにしました。
また、postback(ユーザーからのアクションがあると、あらかじめ設定しておいたdataが帰ってくる)の場合は、data属性に&method=opponent&page=1などと設定しておくことで、methodの名前などによって処理を振り分けることが可能になります。これは結構機能するなと思いました。
app/Http/Controllers/Api/LineBotController.php
public function callback(MessagingApiApi $bot, Request $request)
{
// 色々省略
// リクエストされたイベントをもとに処理をハンドラーに委譲する
foreach ($parsedEvents->getEvents() as $event) {
$handler = null;
switch (true) {
// フォローイベント
case $event instanceof FollowEvent:
$handler = new FollowEventHandler($bot, $event);
break;
// 省略
// テンプレートメニューをクリックした時に発火する
case $event instanceof PostbackEvent:
$postback = $event->getPostback();
$data = $postback->getData();
parse_str($data, $params);
if ($params['action_type'] === 'lending_and_borrowing') {
$handler = new LendingAndBorrowingHandler($bot, $event, $params);
}
if ($params['action_type'] === 'explanation') {
$handler = new ExplainHandler($bot, $event, $params);
}
if ($params['action_type'] === 'cancel') {
$handler = new CancelHandler($bot, $event);
}
break;
}
if (is_null($handler)) {
$handler = new InvalidEventHandler($bot, $event);
}
$handler->handle();
}
}
また、各Handlerクラスにはhandleメソッドを作るのを強制させるためにインターフェースを用いて定義しています。
LINEのBOTオブジェクト生成を隠蔽 (server side) (サービスコンテナ)
LINEのMessagingAPIを使うときに、返信などを実行するためにオブジェクトを以下のような手順で作成する必要があります。
$client = new Client();
$config = new Configuration();
$config->setAccessToken(config('line.channel_access_token'));
$bot = new MessagingApiApi(client: $client, config: $config);
ただこの記述は開発者が変更する機会はないため、処理を隠蔽しようと考えました。
また、botを使用するときにメソッドインジェクション等でできたらよい & テスト実装はしないが、モックできるようにしておこうと思いサービスコンテナに登録しました。
そうすることで以下のように簡潔な記述にできました。
app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(MessagingApiApi::class, function ($app) {
$client = new Client();
$config = new Configuration();
$config->setAccessToken(config('line.channel_access_token'));
return new MessagingApiApi(
client: $client,
config: $config,
);
});
}
(使用側)
public function callback(MessagingApiApi $bot, Request $request) // タイプヒントするだけでインスタンスが渡ってくる
カルーセルメニューの表示制限を回避 (server side)
カルーセルメニューは10個までの表示に限定されています。
そのためメッセージからの未清算貸し借りの確認を行う際に、場合によってはエラーを返してしまいます。
今回は自作のページネーションを作って対応しました。
無駄に毎回前取得をしたりしないようにLaravel Eloquentのcount(), skip(), take()あたりを用いて作りました。
反省点
Server Componentを活かせなかった (Next.js)
Next.jsのapp routerを技術として選んだ時は、Server Componentの機能が気になっておりましたし、このアプリケーションでも使おうと考えていましたが、Liffのinitメソッドをどうしてもクライアント側でアプリケーション起動の最初に実行したいといった事由があったので使用をほとんど断念してしまいました。
Liffのinitを実行しないと、サーバーサイドへのリクエストで毎回必ず使用するuserのaccessTokenも使えないことになってしまうので、致し方ないことですが、技術を選ぶときには全く気がついていなかったので反省点ではあります。
Liffアプリでも活かす方法があれば知りたいです。いまだに思い浮かびません。。
環境選びの際にSupervisorが動かせないことの考慮が漏れていた (Laravel & Xserver)
当初Queue・JOBを使って清算から3日が経ったものを自動削除するつもりでしたが、レンタルサーバーではSupervisorを動かせず、artisan queue:workコマンドの永続化を断念したという経緯があります。完全に考慮ミスです。
代わりに毎日同じ時間に、清算から3日以上経ったものを削除するという自作コマンドをCronを使って叩いています。
Dockerの強みを活かしきれてない
Dockerをローカルでしか使ってないので、あんま使いこなせてはいないかなと思います。
今度デプロイするものはECSなんか使えたら良いなと思います。ただ今回はレンタルサーバーでちょうど良い規模感だったのかなとも思います。
赤字
アプリだと広告とか出るので、それがないだけユーザー目線では良い気がしますが、運用側からすると反省すべきことではあると思います。
ただ、作るのも公開するのも趣味みたいなものだったので、心のダメージは特にありません。いつかお金を払ってもらえるような生活必需品を作れたら良いなと思います。
+ 予定している機能拡張
実現できるかはまだ調べていませんが、グループチャットで使う場合は、グループ内での貸し借りを管理できるようにできたらしたいなと思っています。
同一チャットグループ内での割り勘はよくあることだと思うので、それを実現できたら良いなと思います。
終わりに
GPTとか使うと苦手なUIの構築も、なんとなく思い通りに行って楽しかったです。(昔サイト制作もやってたので多少指示の出し方もよかったかも?)
大規模ではないですが、自分が心からあったら良いなと思えるものだったので作ってみてよかったです。
これから友人とかに試してもらいつつUXの向上と、できれば宣伝LPなんかも作ろうかなと思います。
また、Line Messaging APIやLIFFなどを使いこなせるともっと面白いアプリや、オリジナリティあるものが作れるのではないかなと思いました。
続けて色々作っていきたいと思います!