はじめに
制作物
現在スマホで操作するとイベント編集ボタンをクリックできないバグが発生しております。現在対応中です
github(一部抜粋)
github
個人情報を含むファイルは削除しています。
こだわりポイント
- 傾斜を設定できる
- 結果のテキストを保存できる
- グループ作成時にはメンバーを指定する必要がない
- 支払い者が複数のイベントも追加できる
自己紹介
神奈川在住の大学二年生。エンジニア系のインターンに受かることを目標に2022年9月からJavascriptの勉強を開始。独習JavaScript読破後、ハンズオンNode-jsでNode.jsの勉強にうつり、10月中盤にwebアプリの基本を抑えたのでwebアプリ制作を開始。HTMLとCSSはもともとぼちぼち知っていた。
制作の経緯
waricaとか割り勘webアプリめっちゃ便利だけど、傾斜機能(先輩が多く払うなど)がないから、後輩とかとドライブ行く時とかは結局使えないんだよねー。じゃあ作るか!
制作方法
友人と2人で。友人はフロントエンドのデザイン、私はバックエンドとフロントエンドの通信、データ処理部分を担当。僕はデザインが、友人はデータ処理部分が苦手だったので、いい勉強になった。またGitHubでの共同開発についても知見が深まった。
制作準備
機能を考える
機能を考えるためにまずは以下の用語を定義しておく。
用語定義
用語 | 定義 |
---|---|
グループ | 割り勘をする集団 メンバーを設定し、イベントを追加していく |
メンバー | グループに参加する人 |
イベント | お金を立て替えた出来事(居酒屋、カラオケなど) |
最低限必要な機能
グループ、メンバー、イベントをそれぞれ追加、編集、削除できる。
欲しい機能
- みんなで編集できる
- 結果の共有が簡単
- 傾斜の詳細設定ができる
使う技術
フロントエンド
HTML,CSS,JavaScriptで制作。友人がデザイン面でJQueryを利用していたので、DOM操作にも多少利用した。データのやりとりはfetchを利用した。
デプロイ方法
本当はAWSで公開したかったが難しそうだったので、githubの連携で簡単に公開できるrender.comを選択。render.comではpostgresqlを無料で利用できるので、データベースとしてpostgreSQLを選択した。またパッケージマネージャーはrender.comと相性の良いyarnを利用。
バックエンド
Expressフレームワークを選択。またその他パッケージとして
- cookie-parser (レスポンスでクッキーを含める)
- ejs (エラーページにエラーメッセージをのせるため)
- pg (PostgreSQLの操作)
を使用。
ページ、URL設計
waricaを参考に制作した。
URL
パス | メソッド | ページの内容 |
---|---|---|
/home | GET | ホーム画面 過去の作成したグループの表示 |
/home/addGroup | GET | グループ作成ページ |
/home/groupHome/:groupId | GET | グループ、割り勘結果の表示 |
/home/groupHome/:groupId/addEvent | GET | イベントの追加 |
/home/groupHome/:groupId/groupSettings | GET | 割り勘方法の設定 |
ページ設計
WebAPiのURL
パス | メソッド | 処理内容 | 利用方法 |
---|---|---|---|
/home/addGroup | POST | グループ追加 | fetch |
/home/groupHome/:groupId | POST | グループ編集 | fetch |
/home/groupHome/:groupId | POST | メンバー追加 | fetch |
/home/groupHome/:groupId | POST | メンバー削除 | fetch |
/home/groupHome/:groupId | POST | イベント削除 | fetch |
/home/groupHome/:groupId/showGroup | GET | グループ表示 | fetch |
/home/groupHome/:groupId/addEvent | POST | イベント追加 | fetch |
/home/groupHome/:groupId/addEvent | POST | イベント編集 | fetch |
/home/groupHome/:groupId/groupSettings | POST | グループ編集 | fetch |
反省
同じパスに複数のメソッドを投げる設計は適切でないかも。
データベース設計
ユーザーのグループを作成時、groupidを作成して、allgroupにデータが追加する。
allgroup
Column | Type | 補足 |
---|---|---|
groupid | PK varchar(255) | 長さ8のランダムなアルファベット列 |
groupname | varchar(255) | |
grouptype | json | 割り勘方法や最小単位といったグループの情報 |
lastlogin | timestamp with time zone |
その後groupidをもとに2つのテーブルを作成。(テーブル名にgroupidを含める)
(groupid)member
Column | Type | 補足 |
---|---|---|
memberid | serial integer | |
name | varchar(255) | メンバー名 |
membertype | varchar(255) | 男、女などメンバーの属性 |
(groupid)event
Column | Type | 補足 |
---|---|---|
eventid | serial integer | |
name | varchar(255) | イベント名 |
payerlist | json[] | 支払い者のmemberidと支払い金額情報 |
receiverlist | interger[] | 未支払い者のmemberidの配列 |
グループで追加したメンバーやイベントはこれらのテーブルに保存。
反省
問題点
- ログインのないグループのデータとテーブルは自動で削除するようにしているが、テーブルがグループ数だけ増えていく仕様になっている。
- 対策用のミドルウェアを実装しているが、table名が動的に変化するためSQLインジェクションの可能性がある。
allmember、alleventというテーブルを制作し、Columnにgroupidを追加する方法で制作した方が良いのかな??データベース設計についてよくわからないことが多いので、体系的に勉強したい。
バックエンド制作
はじめに
ディレクトリ構造
├── app.js
├── db.js
├── node_modules
├── package.json
| ├──...
├── public
│ ├── debug
│ │ ├──...
│ ├── docs
│ │ ├── css
| | | ├──...
│ │ ├── html
| | | ├──...
│ │ └── js
| | ├──...
│ └── img
| ├──...
|
├── yarn.lock
必要モジュールインストール
const express = require("express");
const cookie = require("cookie-parser");
const pool = require("./db");
const app = express();
app.use(express.json());
app.use(cookie());
app.use(express.static("public"));
app.set("view engine", "ejs");
リクエストからレスポンスまでの流れ例
groupidをURLに含む場合
groupidチェックミドルウェア
Router
//groupIdをチェックするミドルウェア
app.use("/home/groupHome/:groupId", (req, res, next) => {
checkGroupId(req.params.groupId, next);
});
groupIDをチェックする関数
function checkGroupId(groupId, next) {
//groupIdのインジェクションを防ぐ
if (!isGroupIdSafe(groupId)) {
next(new Error("invalidGropuId")); //errorへ
}
//groupIdの存在を確認する
else {
pool
.query("select * from allgroup where groupid = $1", [groupId])
.then((results) => {
if (!results.rows.length) {
next(new Error("noGroup")); //errorへ
} else {
next();
}
});
}
}
function isGroupIdSafe(groupId) {
//groupIdのインジェクションを防ぐ
const alphabet = "abcdefghijklmnopqrstuvwxyz";
if (groupId.length != 8) {
return false;
}
for (const word of groupId) {
if (!alphabet.includes(word)) {
return false;
}
}
return true;
}
リクエストハンドラ
groupHomeリクエストハンドラ
app
.route("/home/groupHome/:groupId")
.get((req, res, next) => {
//静的ファイル送信
res.sendFile(__dirname + "/public/docs/html/groupHome.html");
})
.post((req, res, next) => {
const obj = req.body;
//SQL操作関数へ
if (obj.method === "editGroup") {
editGroup(req.params.groupId, obj.arguments, res, next);
}
if (obj.method === "addMember") {
addMember(req.params.groupId, obj.arguments, res, next);
}
if (obj.method === "deleteMember") {
deleteMember(req.params.groupId, obj.arguments, res, next);
}
if (obj.method === "deleteEvent") {
deleteEvent(req.params.groupId, obj.arguments, res, next);
}
});
その他のURLについても同様のため省略
反省
先にも述べた通り、ひとつのURLで複数のリクエストを処理するのは良くない設計の可能性がある。
SQL関連
SQLとの接続
const Pool = require("pg").Pool;
const connectionString = process.env.PORT
? "Internal Database URL"
: "External Database URL";
const pool = new Pool({
connectionString: connectionString,
});
module.exports = pool;
反省
render.comは外部からデータベースにアクセスする時とrender.comでデブロイしたアプリ内部でデータベースにアクセスするときでURLが違うが、それを区別する方法としてより適切な方法がありそう。
SQL操作関数
グループ追加
function addGroup({ name }, res, next) {
//ランダムな8桁の文字列を生成してgroupIdとする
const alphabet = "abcdefghijklmnopqrstuvwxyz";
let groupId = "";
for (let i = 0; i < 8; i++) {
groupId += alphabet[Math.floor(Math.random() * 26)];
}
//alldata table に nameを追加する
//table (groupId)member,(groupId)event を作成する
const createMemberTable =
"create table " +
groupId +
"member (memberid serial,name varchar(255),membertype varchar(255))";
const createEventTable =
"create table " +
groupId +
`event (eventid serial,name varchar(255),payerlist json[],receiverlist integer[])`;
Promise.all([
pool.query(
"insert into allgroup (groupid,groupname,grouptype,lastlogin) values ($1,$2,$3,$4)",
[
groupId,
name,
JSON.stringify({
balance: "equal",
ratelist: [1, 1.5, 2.0],
forXYen: 10,
}),
new Date(),
]
),
pool.query(createMemberTable),
pool.query(createEventTable),
])
.then(() => {
//適切に作成されたら、groupIdとクッキーを返す
res
.status(200)
.cookie(groupId, name, { maxAge: 1000 * 3600 * 24 * 4 })
.json(groupId);
})
.catch((err) => {
//errorハンドリングミドルウェアへ
next(err);
});
}
反省
-
groupidをテーブル名に使用している以上、PostgreSQLのuuid型で作成したものは利用できなかった(table名の最初はalphabetのみのため)
groupidの長さは揃えた方がよいためこの形に落ち着いた -
transactionを利用して、一連の操作でエラーが出た場合は操作を取り消す仕組みを追加するべきだった。
-
res,nextを引数に入れないほうがシンプルだったかも?
その他のeditGroup,deleteGroup,addMember....関数に関しては同様のため省略。
errorハンドリング
//errorハンドリングミドルウェア
app.use((err, req, res, next) => {
if (err == "Error: noGroup") {
errMessage = "お探しのグループは存在しません";
res.status(404);
} else if ((err = "Error: invalidGroupuId")) {
errMessage = "グループIDが無効です";
res.status(406);
} else {
errMessage = "サーバー上で問題が発生しました";
res.status(500);
}
res.render(__dirname + "/public/docs/html/error.ejs", {
err: errMessage,
});
});
反省
errorの分類の仕方が流石にダサすぎる。うまい方法があるんだろうけど思いつかなかった。
その他の機能
更新のないグループのデータの自動削除
function deleteOldData() {
pool.query("select * from allgroup").then((result) => {
const now = new Date().getTime();
result.rows.forEach(({ groupid, lastlogin }) => {
if (now - lastlogin.getTime() > 1000 * 3600 * 24 * 4) {
//4日間編集なしなら
deleteGroup(
groupid,
{
status: () => {
return { json: () => {} };
},
},
console.log
); //いなし方雑
}
});
});
}
deleteGroup
function deleteGroup(groupId, res, next) {
//alldata table のgroupId を削除する
const dropMemberTable = "drop table " + groupId + "member";
const dropEventTable = "drop table " + groupId + "event";
Promise.all([
pool.query("delete from allgroup where groupid = $1", [groupId]),
pool.query(dropMemberTable),
pool.query(dropEventTable),
])
.then(() => {
res.status(200).json("group has deleted");
})
.catch((err) => {
next(err);
});
}
反省
SQL操作関数のdeleteGroupを使用しようとしたら、res,nextを組み込んだ設計だったため、簡単には流用できず、結果的にerrorにならないような適当な関数を用意していなした。雑い...
フロントエンド制作
以後紹介するのは各ページのJSファイルの一部抜粋です。
ページ設計(再掲)
home
Cookieから過去に編集したグループのデータを取得し表示
//cokkieの情報をゲットする
const oldData = document.cookie.split("; ");
//cookie空確認
if (Boolean(oldData[0])) {
oldData.forEach((str) => {
const [groupid, name] = str.split("=");
const cloneItem = groupListItem.cloneNode(true);
cloneItem.id = groupid;
//日本語の文字化けを直す
cloneItem.querySelector(".group-title").textContent = decodeURI(name);
groupListContainer.append(cloneItem);
});
}
groupListItem.remove();
//過去データクリックしたらグループホームへ
$(".group-list-item").on("click", function () {
location.href = location.origin + "/home/groupHome/" + $(this)[0].id;
});
反省
この制作ではページ遷移の方法としてlocation.hrefを変える方法を使用しているが、適切であるかわからない。
addGroup
入力した名前をグループ名に保存しグループ作成
groupButton.addEventListener("click", addGroup);
async function addGroup(event) {
event.preventDefault();
const name = groupName.value;
if (name && name.length < 10) {
groupButton.removeEventListener("click", addGroup);
const obj = {
method: "addGroup",
arguments: { groupname: name },
};
//POST
fetch(location.pathname, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj),
}).then((result) => {
result.json().then((groupId) => {
location.href = location.origin + "/home/groupHome/" + groupId;
});
});
load();
} else {
alert("グループ名が不適切です");
}
}
//ロード画面を表示=>ページ遷移まで
function load() {
const loaderWrapper = document.querySelector(".loader-wrapper");
loaderWrapper.classList.add("active");
}
groupHome
groupHomeのロード順
groupHomeは機能が多く、処理順序が重要であるため以下にまとめる。
不適切なイベントチェック
既に削除されたメンバーを含みイベントをエラー表示する
function checkalertEvent() {
//全てのイベントのpayerlist,receiverlistに削除されたメンバーがいないか確認
const memberidList = JSON.parse(sessionStorage.getItem("member")).map(
({ memberid }) => memberid
);
const eventList = JSON.parse(sessionStorage.getItem("event"));
eventList.forEach(({ eventid, payerlist, receiverlist }) => {
//payerlist,receiverlistに削除されたメンバーがいないか確認
if (
receiverlist.find((memberid) => !memberidList.includes(memberid)) ||
payerlist.find(({ memberid }) => !memberidList.includes(Number(memberid)))
) {
//eventエレメントの見た目を変更
const str = "#event-" + eventid;
const item = document.querySelector(str);
if (!item.classList.contains("alert")) {
item.classList.toggle("alert");
//メッセージ表示
item.querySelector(".alert-message").hidden = false;
}
}
});
}
計算の流れ
イベントごとに男子と女子の支払い金額を計算しているのは、イベントごとにメンバーが変化する可能性があり、まとめて計算すると不利なひとが生じ得るから。また最後のお金の受け渡しのアルゴリズムによって支払い者がお金を渡す必要がある人数を少なく抑えている。
実際のコード(全文)
window.addEventListener("allLoaded", tryCalculation);
window.addEventListener("calculate", tryCalculation);
//クリップボードにコピー用
let results = "";
function tryCalculation() {
try {
caluculate();
} catch (err) {
console.log(err);
}
}
function caluculate() {
//pay初期化
const memberList = JSON.parse(sessionStorage.getItem("member")).map(
(memberObj) => {
return { ...memberObj, pay: 0 };
}
);
const eventList = JSON.parse(sessionStorage.getItem("event"));
const { groupname, grouptype } = JSON.parse(sessionStorage.getItem("group"));
//results初期化
results =
location.href +
"\n" +
(groupname || "グループ") +
" " +
(grouptype.balance == "boy+"
? "男子少し多め\n"
: grouptype.balance == "boy++"
? "男子かなり多め\n"
: "") +
memberList.length +
"人で計算";
eventList.forEach(({ payerlist, receiverlist }) => {
const totalCost = payerlist.reduce(
(total, { cost }) => total + Number(cost),
0
);
let girlPay = 0;
let boyPay = 0;
//girlPay,boyPayを求める
if (grouptype.balance == "equal") {
girlPay = totalCost / receiverlist.length;
boyPay = girlPay;
} else {
const rate = grouptype.ratelist[grouptype.balance == "boy++" ? 2 : 1];
let boyGirl = [0, 0];
receiverlist.forEach((receiverid) =>
memberList.find(({ memberid }) => memberid == receiverid).membertype ==
"male"
? boyGirl[0]++
: boyGirl[1]++
);
girlPay = totalCost / (boyGirl[0] * rate + boyGirl[1]);
boyPay = girlPay * rate;
}
//memberのイベントの収支を計算
memberList.forEach(({ memberid, membertype }, index) => {
if (receiverlist.includes(memberid)) {
memberList[index].pay += membertype == "male" ? boyPay : girlPay;
}
memberList[index].pay -= Number(
(
payerlist.find((payerData) => payerData.memberid == memberid) || {
cost: 0,
}
).cost
);
});
});
//memberListをcost順に並び替える
memberList.sort((a, b) => b.pay - a.pay);
//割り勘計算
//ここでのpayerは精算時のpayer(逆に注意)
let payerIndex = 0;
let receiverIndex = memberList.length - 1;
//showCalculationで必要
const paymentItemContainer = document.querySelector(
".payment-item-container"
);
const paymentItem = paymentItemContainer.querySelector(".payment-item");
paymentItemContainer.innerHTML = "";
const forXYen = grouptype.forXYen;
while (payerIndex < receiverIndex) {
if ((memberList[receiverIndex].pay += memberList[payerIndex].pay) >= 0) {
showCalculation(
memberList[payerIndex].name,
memberList[receiverIndex].name,
Number(memberList[payerIndex].pay - memberList[receiverIndex].pay)
);
memberList[payerIndex].pay = memberList[receiverIndex].pay;
if (memberList[payerIndex].pay == 0) {
payerIndex++;
}
memberList[receiverIndex].pay = 0;
receiverIndex--;
} else {
showCalculation(
memberList[payerIndex].name,
memberList[receiverIndex].name,
memberList[payerIndex].pay
);
memberList[payerIndex].pay = 0;
payerIndex++;
}
}
function showCalculation(payerName, receiverName, money) {
//何円単位で割り勘を行うかを加味した実際払うお金
const fixedMoney =
money - Math.floor(money / forXYen) * forXYen >
forXYen - (money - Math.floor(money / forXYen) * forXYen)
? (1 + Math.floor(money / forXYen)) * forXYen
: Math.floor(money / forXYen) * forXYen;
if (fixedMoney) {
//domでの処理
const cloneItem = paymentItem.cloneNode(true);
cloneItem.querySelector(".payer").textContent = payerName;
cloneItem.querySelector(".receiver").textContent = receiverName;
cloneItem.querySelector(".payment-cost").textContent = fixedMoney + "円";
paymentItemContainer.append(cloneItem);
//resultsに対応
results +=
"\n" + payerName + "\n=>" + receiverName + " " + fixedMoney + "円";
}
}
paymentItem.remove();
}
//クリップボードにコピー
$(".copy-button").on("click", function () {
if (results[results.length - 1] == "円") {
//空じゃない
navigator.clipboard.writeText(results);
$(this)[0].textContent = "コピーしました!!";
}
});
グループ編集処理
postServer
function postServer(obj, callback) {
//ロード開始
if (obj.method.includes("Member")) {
memberLoad();
} else if (obj.method === "editGroup") {
groupTitleLoad();
}
fetch(location.pathname, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj),
})
.then((res) => {
res.json().then((x) => {
console.log(obj);
console.log(x);
//ロード終了
if (obj.method.includes("Member")) {
memberLoad();
} else if (obj.method === "editGroup") {
groupTitleLoad();
}
//addMember deleteMember の場合
const memberObj = { memberid: x[0].memberid, ...obj.arguments }; //add=>alldata,delete=>memberid
callback(memberObj);
});
})
.catch((err) => {
console.log("通信に失敗", err);
});
}
callbackの引数が必要なのがaddmemberのみなので(memberidを受け取る必要がある)、memberObjを渡している。(deletememberの時も便利なので使っている)
addMember
//member追加したときの動作
$("#member-add").on("click", function () {
const memberNameInput = document.querySelector("#member-name-input");
const memberSex = document
.querySelector("#sex-button")
.className.split(" ")[1];
if (memberNameInput.value) {
const obj = {
method: "addMember",
arguments: {
name: memberNameInput.value,
membertype: memberSex,
},
};
//showMemberし、sessionストレージにデータ追加
postServer(obj, (obj) => {
sessionStorage.setItem(
"member",
JSON.stringify([...JSON.parse(sessionStorage.getItem("member")), obj])
);
//メンバーElement追加
showMember(obj);
});
}
//更新終わった後にinputを空にする
memberNameInput.value = "";
});
deleteMember,editGroupも同様のため省略
addEvent
かなりごちゃついた
addEventロード順
イベント編集なら編集前情報を画面に反映
function importEvent() {
//編集ボタンをクリックするとeditEventにeventidが追加される
//単純なイベント追加だとeditEventは""![スクリーンショット 2022-11-05 23.29.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952141/475fa78b-f5e3-a421-fbc7-9195c0ad647e.png)
const editeventid = sessionStorage.getItem("editEvent");
try {
//編集するイベントのデータを取得
const { name, payerlist, receiverlist } = JSON.parse(
sessionStorage.getItem("event")
).find(({ eventid }) => eventid == editeventid);
//nameを代入
for (const input of document.querySelectorAll(".event-name-input")) {
input.value = name || "イベント" + editeventid;
}
//payerを代入
if (payerlist.length == 1) {
//oneのページを表示
showOneorSeveral("one");
//one payerに代入
selectOnePayer(payerlist[0].memberid);
//one costに代入
document.querySelector(".cost-input").value = payerlist[0].cost;
} else {
//severalのページを表示
showOneorSeveral("several");
//several payerとcostを代入
payerlist.forEach(({ cost, memberid, name }) => {
selectSeveralPayer(memberid);
addSeveralPayerCostItem({
memberid: memberid,
name: name,
cost: cost,
});
});
}
//receiverを代入
receiverlist.forEach((memberid) => {
selectReciever(Number(payerlist.length != 1), memberid);
});
//最後に追加ボタンを更新ボタンに変更
for (const button of addEventButtons) {
button.firstElementChild.textContent = "更新";
}
} catch (err) {
console.log(err);
}
}
支払い者人数を一人から複数に切り替えたとき、入力していた情報を反映する機能や、受け取り手を一度に選択できるボタンを追加したり、使い勝手を意識して制作した。(故にごちゃついたのだが)
groupSettings
特に難しい処理はないので省略error
アクセスしたグループが存在しなかった時にcookieのデータを消す
const errorMassage = document.querySelector(".error-massage");
//urlのgroupidを取得
const hrefList = location.href.split("/");
const invalidgroupid = hrefList[hrefList.indexOf("groupHome") + 1];
if (errorMassage.textContent == "お探しのグループは存在しません") {
const cookie = document.cookie.split("; ").map((str) => str.split("="));
cookie.forEach(([groupid, groupname]) => {
if (groupid == invalidgroupid) {
document.cookie =
groupid +
"=" +
groupname +
"; expires=Mon, 31 Aug 2020 15:00:00 GMT;path=/";
}
});
}
expireを過去に書き換えることで削除する
おわりに
制作をおえて
このQiitaを編集していて気がついたのは、このアプリを制作する上で一番大変だったところは、コードを書くところじゃなくて設計すところだったということ。あとコーディングに関しては、(コードぐちゃぐちゃな自分が言うのもなんですが)、やりたいように動かすこと事態はそんなに難しくなくて、どれほどスマートにわかりやすくかけるかが一番大事で一番難しい部分なんだとも感じた。今後は実践経験を積んで、その部分についても勉強しつつ、セキュリティー面やバージョン管理といった難しい部分についても挑戦していきたい。
今後やりたいこと
- Reactを使用したSPAでの実装
- AWSを利用したデブロイ
- websocketを利用したリアルタイムで編集が反映されるgroupHome(制作途中でトライしたが、errorハンドリング等共有が難しく、断念した。)
- c#での経験を活かしてバックエンドTypeScriptでの書き直し。
質問があったらどしどし送ってください!!それでは!