概要
大学の企画でチームを組み、初めてのWebアプリ開発に挑戦し大学の食堂の売り切れ情報を管理するアプリを開発しました。この時Firebaseを利用することで、サーバーの設定をしなくてもデータベースを利用することができ、開発に役立ちました。
この記事では、開発で使用した言語・技術、実際のコードを見ながら解説していきます。
1. はじめに
チームメンバーの紹介
私たちは、経営学部の3年生1人と私を含めた2年生の同期3人でチームを組みました。
プログラミングに少し触れたことはあったものの、私と3年生の先輩が他の2人よりも知識があったため、主にエンジニアを担当することになりました。
しかし、後に行われるハッカソンには、3年生の先輩が気胸が再発し参加できなくなったため、同期の1人にデザイナーとエンジニアを兼任してもらうことになりました。
基本的にHTML,CSSはA先輩とBくんで行い、Firebase関係のことは私が実装しました。この時にFirebase等様々な知識を教えて下さったエンジニアとして働いているUさんは一生忘れません。ありがとうございました。
名前(仮名) | 役割 |
---|---|
私 | リーダー兼エンジニア |
A先輩 | エンジニア |
Bくん | デザイナー兼エンジニア |
Cくん | 資料作成 |
Uさん | 師匠 |
アプリ開発のきっかけとなったハッカソン参加の経験
今回参加したハッカソンは「SPAJAM2022京都大会」です。
このハッカソンでは、アプリを開発の流れを知るということが目的でした。
参加した2日間で実際に学びになった項目を以下に示します。
- ・アイデア出し
- ◦ お題に関係するものをマンダラートのように書き出す。
- ◦ お題に関する感情を喜怒哀楽で書き出す。
- ◦ 書き出した関連ワードと感情から、アイデアを創造して具体的にそしてできるだけ書き出す。
- ・開発日程
- ◦ 会場1日目を全てアイデア出しに使い、帰宅し半日で制作
- ・開発手法
- ◦ Flutter,Firebase
- ◦ ハードウェア
- ・発表方法
- ◦ 動画の使用
- ◦ 発表順番の設定(私のハッカソンでの発表はデモ→開発理由→構成図)
2. アプリのアイデアと開発内容
アプリのアイデアの背景
このアプリのアイデアは、大学の食堂に関する問題を解決しようとしたことがきっかけです。具体的には、以下の問題点がありました。
- ①いつ買っても使える食券(←偽造し放題やんけ)
- ②食堂に行かなければ売切れかどうか分からない。
- ③食事の提供を停止してから券売機が停止されるため、当日朝に購入しても利用できない。
これらの問題点のうち、①については学生側では解決できないため、今回のアプリ開発では、②を解決することに注力しました。
②の問題は、教職員の方々にとっても課題となっていた。というのも、教職員の休憩時間は学生よりも遅く、食堂のメニューは早い時間帯に売り切れてしまうことが多いためです。そのため、休憩時間を有効に使うためには、食堂に行く前にメニューの在庫を確認できる方法が必要だと考えました。
使用した言語・技術の紹介
- ・使用した言語・技術の紹介
- ◦ HTML: HTML5
- ◦ CSS: CSS3
- ◦ JavaScript: ECMAScript 6
- ◦ Firebase: Firebase Authentication、Firebase Firestore Database
- ・使用した目的
- ◦ HTML、CSS、JavaScript: フロントエンド開発
- ◦ Firebase: バックエンドの機能を提供するため
Firebaseのデータベース構造
admin (collection)
├ {admin_email} (document)
│ └ email: string (管理者のGmailアドレス)
menu (collection)
├ {menu_id} (document)
│ └ stock: boolean (在庫があるかどうか)
├ {menu_id} (document)
│ └ stock: boolean (在庫があるかどうか)
└ {menu_id} (document)
└ stock: boolean (在庫があるかどうか)
データベース設計をされている方は発狂しそうなデータ構造をしていると今更ながら思います。食堂のメニューが少なく、やりたいこととしてはこれくらいでも達成できることからこのままです。
{menu_id}に直接商品名を入れているところがミソです。
後の解説で、collection(コレクション)document(ドキュメント)という言葉は出てくるので覚えといてください。
Firestoreについて少し知りたい方は、こちらの記事を見て頂ければと思います。
どちらも初めのほうの記事を読むと分かるようになっています。
流行りのchatGPTにかけてみると以下のようなものを出力してくれました。
admins (collection)
├ {admin_email} (document)
│ └ email: string (管理者のGmailアドレス)
menus (collection)
├ {menu_id} (document)
│ ├ name: string (メニューの名前)
│ └ stock: boolean (在庫があるかどうか)
├ {menu_id} (document)
│ ├ name: string (メニューの名前)
│ └ stock: boolean (在庫があるかどうか)
└ {menu_id} (document)
├ name: string (メニューの名前)
└ stock: boolean (在庫があるかどうか)
初心者だけど同じ様なものを作りたい、作ってみたいという方はchatGPTくんが作成したものを使ってみるのもいいかもしれませんね。
3. コードの全文・解説
HTML/CSSのコード全文
この章では、A先輩とBくんが作成したコードを見ます。
私がFirebaseを導入する時に、少し変更していますが、殆ど原文ママなのでそのまま残していきます。
GitHubにコードを挙げて見ました。
HTMLのソースコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="./styles/index.css">
<title>Document</title>
</head>
<main>
<body onload="anonymousLogin() && firstStateFoodmenu()">
<script src="/__/firebase/9.6.6/firebase-app-compat.js"></script>
<script src="/__/firebase/9.6.6/firebase-auth-compat.js"></script>
<script src="/__/firebase/9.6.6/firebase-firestore-compat.js"></script>
<script src="/__/firebase/init.js"></script>
<script src="./scrips/reload.js"></script>
<script src="./scrips/firstStateFoodmenu.js"></script>
<script src="./scrips/lunchNotification.js"></script>
<script src="./scrips/onClickFoodButton.js"></script>
<h1>lunch menu</h1>
<!-- <button id="btn" onclick="location.href='Admin.html'">Login</button> -->
<a class="btn btn--yellow btn--cubic" onclick="doReloadNoCache()">更新</a>
<div id="classic">
<div class="food">
<div id="DailyLunch" class="food2" style="visibility: visible;">
<img src="img/gyutan.jpg" alt="日替わり">
</div>
<h4>日替わり定食 800円</h4>
</div>
<div class="food">
<div id="FriedChicken" class="food2" style="visibility: visible;">
<img src="img/karaage.jpg" alt="唐揚げ">
</div>
<h4>唐揚げ定食 800円</h4>
</div>
<div class="food">
<div id="Syouga" class="food2" style="visibility: visible;">
<img src="img/syogayaki.jpg" alt="生姜焼き">
</div>
<h4>生姜焼き定食 800円</h4>
</div>
<div class="food">
<div id="Tonkatu" class="food2" style="visibility: visible;">
<img src="img/tonkatsu.jpg" alt="とんかつ">
</div>
<h4>とんかつ定食 800円</h4>
</div>
<div class="food">
<div id="Hamburge" class="food2" style="visibility: visible;">
<img src="img/humburge.jpg" alt="ハンバーグ">
</div>
<h4>ハンバーグ定食 800円</h4>
</div>
<div class="food">
<div id="Sashi" class="food2" style="visibility: visible;">
<img src="img/sashimi.jpg" alt="刺身">
</div>
<h4>刺身定食 1000円</h4>
</div>
</div>
</body>
</main>
</html>
CSSのソースコード
@import url('https://fonts.googleapis.com/css2?family=Itim&display=swap');
body{
background-color: beige;
}
main{
width: 480px;
overflow: hidden;
}
h1{
width: 470px;
margin-top: 0;
font-size: 80px;
font-family: 'Itim', cursive;
color: white;
text-align: center;
background-color: yellowgreen;
border-radius: 5px;
}
h4{
width:210px;
margin-bottom: 10px;
font-size: 15px;
line-height: 60px;
color: white;
text-align: center;
}
#classic{
clear: both;
justify-content: center;
}
.food{
width: 210px;
height: 280px;
padding: 10px;
margin-bottom: 10px;
margin-right: 5px;
margin-left: 2.5px;
border-radius: 5px;
background-image: url(../img/sold_1.png);
background-position: top;
background-repeat: no-repeat;
background-color: burlywood;
float:left;
text-align: center;
}
.food2{
width: 180px;
padding: 10px;
margin-bottom: 10px;
margin-right: 5px;
margin-left: 5px;
border-radius: 5px;
background-color: burlywood;
float:left;
}
img{
width: 100%;
}
div#gyu{
visibility: visible;
}
a {
color:black;
text-decoration:none;
}
.btn,
a.btn,
button.btn {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.5;
position: relative;
display: inline-block;
padding: 1rem 4rem;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-transition: all 0.3s;
transition: all 0.3s;
text-align: center;
vertical-align: middle;
text-decoration: none;
letter-spacing: 0.1em;
color: #212529;
border-radius: 0.5rem;
margin-bottom: 5px;
}
a.btn--yellow {
color: #000;
background-color: #fff100;
border-bottom: 5px solid #ccc100;
}
a.btn--yellow:hover {
margin-top: 3px;
color: #000;
background: #fff20a;
border-bottom: 2px solid #ccc100;
}
JSのコード解説
JSのソースコードと解説① firstStateFoodmenu.js
コード全文
// 初期化処理。ログインとメニューの初期表示設定を行う。
function onInitPage(){
Login(); // Googleアカウントでのログイン処理を実行
firstStateFoodmenu(); // メニューの初期表示状態を設定
}
// Googleアカウントを使ってFirebaseへログインする非同期関数
async function Login(){
const provider = new firebase.auth.GoogleAuthProvider();
provider.addScope('https://www.googleapis.com/auth/contacts.readonly');
await firebase.auth().signInWithPopup(provider) // Google認証ポップアップを表示
}
// 匿名でFirebaseへログインする非同期関数
async function anonymousLogin(){
await firebase.auth().signInAnonymously(); // 匿名ログインを行う
}
// Firebaseからログアウトする非同期関数
async function Logout(){
await firebase.auth().signOut(); // ログアウトを行う
}
// メニューの初期表示状態を設定する非同期関数
async function firstStateFoodmenu() {
// 'food2'クラスの要素を全て取得
const foodElements = document.getElementsByClassName('food2');
const len = foodElements.length;
console.log(len);
// Firestoreから'menu'コレクションを取得
const menuCollectionRef = db.collection('menu');
const foodDocs = await menuCollectionRef.get();
const foodinfos = foodDocs.docs.map(snapshot => {
// メニューのIDとデータを抽出
return { docId: snapshot.id, data: snapshot.data() };
});
// 各メニューについて、在庫があれば表示、なければ非表示に設定
foodinfos.forEach(info => {
// メニューIDに対応するHTML要素を取得
const elem = document.getElementById(info.docId);
if (info.data.stock) { // 在庫がある場合
elem.style.visibility = "visible"; // 要素を表示
console.log(info.docId);
console.log(info.data);
} else { // 在庫がない場合
elem.style.visibility = "hidden"; // 要素を非表示
console.log(info.docId);
console.log(info.data);
}
});
};
コード解説
function onInitPage(){
Login();
firstStateFoodmenu();
}
関数onInitPage()
は、ページの初期化時に呼び出されます。
この関数は、まずFirebaseのGoogle認証プロバイダを使用して、ユーザーのログインを試みます。その後、firstStateFoodmenu()
関数を呼び出して、Firestoreからメニューデータを取得してWebページに表示します。
async function Login(){
const provider = new firebase.auth.GoogleAuthProvider();
provider.addScope('https://www.googleapis.com/auth/contacts.readonly');
await firebase.auth().signInWithPopup(provider)
}
関数Login()
は、Google認証プロバイダを使用して、Firebaseの認証システムにユーザーをログインさせます。この関数では、ユーザーのGoogleアカウントから連絡先情報を読み取ることが許可されます。
async function anonymousLogin(){
await firebase.auth().signInAnonymously();
}
関数anonymousLogin()
は、匿名ユーザーとしてFirebaseにログインします。これは、ユーザーがGoogleアカウントを持っていない場合や、Googleアカウントを使用したログインが必要ない場合に使用されます。
async function Logout(){
await firebase.auth().signOut();
}
関数Logout()
は、Firebaseからユーザーをログアウトします。
async function firstStateFoodmenu() {
const foodElements = document.getElementsByClassName('food2');
// 取得した要素の数を取得
const len = foodElements.length;
console.log(len);
// console.log(foodElements);
const menuCollectionRef = db.collection('menu');
const foodDocs = await menuCollectionRef.get();
const foodinfos = foodDocs.docs.map(snapshot => {
return { docId: snapshot.id, data: snapshot.data() };
})
foodinfos.forEach(info => {
const elem = document.getElementById(info.docId);
if (info.data.stock) {
elem.style.visibility = "visible";
console.log(info.docId);
console.log(info.data);
} else {
elem.style.visibility = "hidden";
console.log(info.docId);
console.log(info.data);
}
});
};
関数firstStateFoodmenu()
は、Firestoreからメニューデータを取得して、Webページ上に表示します。この関数は、Firestoreのmenuコレクションからデータ
を取得し、各ドキュメントの在庫情報に基づいて、Webページ上のメニューアイテムの表示/非表示を切り替えます。
コードが長いため少し分割して解説します。
const foodElements = document.getElementsByClassName('food2');
// 取得した要素の数を取得
const len = foodElements.length;
console.log(len);
// console.log(foodElements);
1行目では、documentオブジェクトのgetElementsByClassName()
メソッドを使用して、クラス名が"food2"
であるすべてのHTML要素を取得しています。このメソッドは、引数に指定されたクラス名を持つすべての要素をHTMLCollectionオブジェクトとして返します。HTMLCollectionは配列のようにインデックス番号でアクセスできます。
2行目では、取得したHTML要素の数を、foodElements.length
プロパティで取得して、変数len
に格納しています。この変数は、後で処理で使用するために取得されています。
3-4行目では、取得した要素の数をデバック用にコンソールに出力しています。console.log()
メソッドを使用して、lenを出力することで、取得したHTML要素の数が表示されます。また、foodElements自体を出力することで、取得したHTML要素が配列のように表示されます。
const menuCollectionRef = db.collection('menu');
const foodDocs = await menuCollectionRef.get();
const foodinfos = foodDocs.docs.map(snapshot => {
return { docId: snapshot.id, data: snapshot.data() };
})
1行目では、menu
という名前のコレクションを参照するために、Firestoreのdbオブジェクトのcollection()
メソッドを使用して、menuCollectionRef
という変数にコレクションの参照を代入しています。
2行目では、menu
コレクション内のすべてのドキュメントを取得するために、menuCollectionRef
オブジェクトのget()
メソッドを呼び出し、その結果をfoodDocs
という変数に代入しています。
get()
メソッドは非同期で実行されるため、await
キーワードを使用して、foodDocs
がFirestoreから正常に取得された後に、次の行の処理に進むようにしています。
3行目では、foodDocs
オブジェクトのdocs
プロパティを使用して、QueryDocumentSnapshot
オブジェクトの配列を含むfoodDocs.docs
という配列を作成します。この配列の各要素は、Firestoreの各ドキュメントを表し、QueryDocumentSnapshot
オブジェクトには、ドキュメントIDやドキュメント内のフィールドなどの情報が含まれています。
4行目では、配列のmap()
メソッドを使用して、foodDocs.docs
の各要素に対して処理を行い、新しい配列foodinfos
を作成しています。ここで、配列のmap()
メソッドは、各要素に対して指定された関数を適用して、新しい配列を作成するメソッドです。
この場合、各QueryDocumentSnapshot
オブジェクトのdocId
プロパティとdata
プロパティを含む新しいオブジェクトを作成して、foodinfos
に追加しています。docId
プロパティには、ドキュメントのIDが、data
プロパティには、ドキュメント内のデータが含まれます。
このようにして、Firestoreから取得したメニューデータをJavaScriptのオブジェクトの配列に変換し、処理しやすくしています。
JSのソースコードと解説② lunchNotification.js
const db = firebase.firestore();
// 'menu'コレクションのドキュメントの変更をリアルタイムに監視する
db.collection('menu')
// 最初の20件のドキュメントに限定する
.limit(20)
// ドキュメントが更新されるたびにこのコールバックが実行される
.onSnapshot((snapshot) => {
let items=[];
console.log("Snapshot:検知しました。");
// ページが表示された時に実行されるイベントハンドラを定義
window.onpageshow = function(event) {
// ページがバックフォワードキャッシュから読み込まれた場合
if (event.persisted) {
console.log("Leroad:リロード。");
window.location.reload(); // ページをリロードする
}
};
}, err => console.log(err));
このコードはFirebase Cloud Firestoreを使用して、データベースの menu
コレクションから最大20件のデータを取得し、変更があった場合にリアルタイムで更新を行うためのものです。
具体的には、onSnapshot
メソッドを使用して、menu
コレクションの変更を監視し、変更がある度にコールバック関数を呼び出します。このコールバック関数では、取得したデータを items
配列に格納し、console.logを使ってメッセージを出力します。
また、このコードでは、window.onpageshow
を使用して、ページをリロードするようにしています。これは、ブラウザでページを更新すると、コールバック関数が再度呼び出され、最新のデータを取得することができるためです。
最後に、エラーが発生した場合は、コンソールにエラーメッセージを出力するようになっています。
JSのソースコードと解説③ onClickFoodButton.js
// 食品のボタンがクリックされたときに実行される非同期関数を定義
async function onClickFoodButton(id) {
// クリックされた食品のHTML要素を取得
const elem = document.getElementById(id);
// Firestoreからクリックされた食品のドキュメントを取得
const lunchRef = db.collection('menu').doc(id);
// ドキュメントのデータを非同期に取得
const foodDoc = await lunchRef.get();
console.log(foodDoc.data());
// ドキュメントのデータを変数に保存
const foodData = foodDoc.data();
// 在庫がある場合
if(foodData.stock){ // stock === true
elem.style.visibility = "hidden";
lunchRef.set({stock: false});
console.log(lunchRef);
}
// 在庫がない場合
else{
elem.style.visibility = "visible";
lunchRef.set({stock: true});
console.log(lunchRef);
}
}
この関数では、次の処理が行われます。
1.引数で渡されたIDをもとに、対応するHTML要素を取得します。
2.Cloud Firestore データベースから、IDに対応するドキュメントを取得します。
3.取得したドキュメントが存在する場合は、そのドキュメントのデータをコンソールに表示します。
4.取得したドキュメントから、stock
というフィールドを取得し、その値が true
である場合、HTML要素を非表示に設定します。その後、stock
フィールドの値を false
に更新し、更新したドキュメントを保存します。
5.取得したドキュメントから、stock
フィールドを取得し、その値が false
である場合、HTML要素を表示に設定します。その後、stock
フィールドの値をtrue
に更新し、更新したドキュメントを保存します。
この関数は、Cloud Firestoreのデータを更新するために使用されます。具体的には、menu
コレクションの中にある、各料理に対応するドキュメントのstock
フィールドの値を更新します。stock
フィールドの値がtrue
であれば、対応する料理の在庫があることを表し、HTML要素を非表示にすることで、在庫がないことをユーザーに伝えます。stock
フィールドの値がfalse
であれば、在庫がないことを表し、HTML要素を表示することで、在庫があることをユーザーに伝えます。
JSのソースコードと解説④
function doReloadNoCache() {
// キャッシュを無視してサーバーからリロード
window.location.reload();
};
この関数は、window.location.reload()
を呼び出すことで、現在のURLをリロードし、ページを更新します。しかし、通常、ブラウザはページのコンテンツをキャッシュするため、window.location.reload()
を単純に呼び出すだけでは、古いキャッシュが使用されてしまう場合があります。この関数は、そのような問題を回避するために、キャッシュを無視してページをリロードするためのものです。
ここのコードはwindow.addEventListener()
などで、ページが読み込まれたときに実行するように変更することが良いと思われます。
window.addEventListener('load', function() {
doReloadNoCache();
});
しかし、キャッシュを利用しないことでサイトを開く速度が遅くなったり、サーバーに負荷がかかったりするため一概に良い方法とは言えないかもしれません。
Firebaseのセキュリティコード解説
公式ドキュメントや次の動画を参考にすると良いでしょう。
簡単なものですが、今回のコード例です。
rules_version = '2';
// Allow read/write access on all documents to any user signed in to the application
service cloud.firestore {
match /databases/{database}/documents {
match /menu/{document=**}{
allow read: if request.auth != null && request.auth.uid != "xxxxx";
allow read,write: if request.auth != null && request.auth.uid == "xxxxx";
}
}
}
JSコード解説①を実行した場合のUID(uid)
はFirebaseのAuthentication
で確認することができます。
また、Firebaseの公式ドキュメントでは次のコードでuidを取得できると記述されています。
getAuth()
.getUser(uid)
.then((userRecord) => {
// See the UserRecord reference doc for the contents of userRecord.
console.log(`Successfully fetched user data: ${userRecord.toJSON()}`);
})
.catch((error) => {
console.log('Error fetching user data:', error);
});
4. まとめ
アプリ開発の反省点と評価点
今回の開発において反省点はいくつもあるが、今書いているなかで一番思うことは知識が不足しているので、反省すべき点も抽象的な事ばかりになってしまうことだった。抽象的なことでも3つ書き出して考えてみる。
反省点
・技術トレンドを知らない
(今回の場合だとFirebaseというやり方があったが、サーバを用意する方法だけを考えていた)
・コーディングに対する知識不足
(Uさんに教えてもらうまでは、変数のキャメルケースを知らなかった)
(単純に英単語の引き出しが悪く、変数名をより良い形でつけることが出来なかった)
(async/awaitなど非同期処理の理解など、限られた技術の中でモノを作ることだけを考えていた)
・チームメンバーの管理と成長機会の創出
(メンバー全員に達成感を味わってもらうことが出来なかった)
(特にCくんは、私が工程を進めることを考えていたため発表の機会などを奪ってしまった)
評価点
・やりきった
・アウトプットをした事が足りないことへの気付きへと繋がった
・bくんには次もやってみたいと言ってもらえた
今後の展望
今回のプロジェクトとしては、成功と言えるのではないかと個人的には思っている。
その理由はハッカソンややり遂げることを通じて、エンジニアとして少し前進したと感じたからです。
そのため、今後の展望としては安直かもしれないが身近な課題と自分たちの創造性を織り交ぜて、定期的にアプリ開発をしていきたいと思う。所属しているゼミ内でも2,3年の合同でハッカソンなどを行う計画もあるらしいので、少しでもチームで開発し経験を積んでいきたいと思いました。
また記事も修正できる箇所を見つけ次第、随時修正できたらと思います。
5.終わりに
ここまで記事を読んで下さりありがとうございます。
初めての記事作成ということで、どの様に書けば良いか分からず何となく書いてみましたがどうでしたか?
このプロジェクトで、チームで開発するという経験と自分が想像したアプリを開発することはとても楽しいことだと改めて実感することができました。皆さんも機会があればチームを組んでアプリだけではなく、何かを達成する喜びというものを知っていただけらと思います。
今後も、私やこの記事を読んでいる皆様に役立つ記事や面白い記事を提供出来ればと思います。