はじめに
最近、自炊を始めたのですが、冷蔵庫にある食材をうまく使い切れず、つい余らせてしまうことが増えました。そこで、手元にある食材を登録して、それに基づいてレシピを提案してくれるアプリがあれば便利だと思い、開発することにしました。
加えて、以前から学習していたJavaScriptやNode.js、MongoDBの知識を実際に使ってみたかったこともあり、今回はこれらの技術を活用して開発することにしました。特に初心者が扱いやすいようにシンプルな構成にすることを目標にしました。
この記事では、ウェブアプリ開発に挑戦してみたい初心者の方に役立つよう、アプリ開発の詳細を紹介していきます。
この記事で分かること
- Javascript初心者(筆者)が作成したレシピ提案アプリの中身
- MongoDBを用いたデータベース管理
使用技術
環境
- エディタ:Visual Studio Code
- 言語:JavaScript (Node.js)
- フロントエンド:HTML, CSS, JavaScript
- バックエンド:Node.js, Express
- データベース:MongoDB
- テンプレートエンジン:EJS
- バージョン管理:Git
使用したライブラリ
-
express
: Webアプリケーションフレームワーク -
mongoose
: MongoDBのオブジェクトモデリングツール -
passport
: 認証用ミドルウェア -
express-session
: セッション管理 -
connect-mongo
: MongoDBを使ったセッションストア -
connect-flash
: フラッシュメッセージ用ミドルウェア
アプリの詳細
機能概要
このアプリには、以下の機能があります。
-
ユーザー認証機能
新規ユーザーはアカウントを作成し、既存ユーザーはログインして自分の食材リストを管理できます -
食材登録機能
ユーザーが手元にある食材をアプリに登録します。登録された食材はリストとして保存され、あとから編集や削除が可能です -
レシピ提案機能
登録された食材に基づいて、作成可能なレシピを提案します。ユーザーが持っている食材を利用し、足りない食材があってもレシピの提案が可能です
これらの機能がどのように実装されたのかを説明していきます。
ユーザー認証機能
まずはユーザー認証、つまりユーザーがアカウントを作成し、ログインできるようにします。ユーザーは自分の食材リストを個別に管理でき、ログイン後はそのデータにアクセスできます。
ユーザーログイン
passport.use(new LocalStrategy({
usernameField: 'username',
passwordField: 'password',
passReqToCallback: true
}, (req, username, password, done) => {
User.findOne({ username: username }, (err, user) => {
if (err) return done(err);
if (!user) {
return done(null, false, req.flash('error', 'ログイン名またはパスワードが間違っています'));
}
if (!user.verifyPassword(password)) {
return done(null, false, req.flash('error', 'ログイン名またはパスワードが間違っています'));
}
return done(null, user);
});
}));
passport.use(): これはPassportライブラリ内の機能であり、認証戦略(Strategy)を定義するために使用されます。今回の場合、LocalStrategyを使用しています。LocalStrategyでは、ユーザー名とパスワードを利用してローカルの認証を行います。ここで、ユーザー名はキャビネット名として表示されます。これは、ユーザー名とキャビネット名を統一して、データベースへ登録するようにしたためです。
ユーザー登録
let registeredCabinet = await Cabinet.register(new Cabinet({ username }), password, (err, cabinet) => {
if (err) {
if (err.name === 'UserExistsError') {
req.flash('error', 'このキャビネット名は既に登録されています。');
} else {
req.flash('error', '登録失敗!');
}
return res.redirect('/register');
}
Cabinet.register(): Cabinetモデルを使用して新しいユーザー(キャビネット)をデータベースに登録します。usernameが既に存在している場合、UserExistsErrorが発生し、フラッシュメッセージでユーザーにエラーメッセージを表示します。
食材登録機能
ユーザーがログインもしくは登録をしましたら、食材が登録できる画面に移動します。ここでは、食材の名前と量を決めて登録することができ、登録された食材の数量を調整したり、削除するなどの管理もできます。
食材数量の増減
function count(that, option) {
const quantity = that.parentElement.parentElement.querySelector('.quantity');
if (option === 'p') {
++quantity.value;
} else if (option === 'm') {
if (quantity.value < 1) return;
--quantity.value;
}
quantity.dispatchEvent(new Event('change'));
}
function count(): この関数は、指定されたオプション(optionがPならプラス、Mならマイナス)に応じて、食材の数量を増減させます。
quantity.value: ユーザーが選択した食材の数量を表します。この値がプラスやマイナスで操作され、食材の数量が調整されます。
dispatchEvent(new Event('change')): 数量が変更された後に、changeイベントを起こして、データベースへの更新などが自動的に反映されます。
食材検索
let searchBox = document.getElementById("name");
searchBox.addEventListener("search", async function () {
if (this.value.length === 0) return;
let foundGroceries = await (await fetch(`/api/grocery/search?name=${this.value}`)).json();
let resultsArea = document.getElementById("search-results");
resultsArea.innerHTML = "";
for (const e of foundGroceries) {
const div = document.createElement("div");
div.appendChild(document.createTextNode(e));
resultsArea.appendChild(div);
}
});
searchBox: 食材の名前を入力する検索ボックスです。ユーザーがこのボックスに名前を入力すると検索が行われます。
addEventListener("search"): のイベントリスナーは、ユーザーが検索ボックスで検索操作を行った際に動作します。
fetch(): 食材の検索結果をサーバーから取得するために使われます。指定されたURL(/api/grocery/search?name=${this.value})にリクエストを送り、サーバーから該当する食材のリストを取得します。
resultsArea: 検索結果を表示するエリアです。取得した結果をdiv要素として作成し、それを結果エリアに追加して表示します。
レシピ提案機能
食材の登録が完了できたら、右上のレシピ提案ボタンを押すことで、レシピ提案画面に移ることができます。ここでは、登録された食材を選び、レシピ検索ボタンを押すと、選ばれた食材から作れるレシピが生成されます。
async function naiveSearch(kinds) {
const recipes = await Recipe.find({
ingredients: {
$all: kinds
}
});
recipes.sort((a, b) => {
if (a.ingredients.length < b.ingredients.length) return -1;
else if (a.ingredients.length === b.ingredients.length) return 0;
else return 1;
});
return recipes;
}
naiveSearch: naiveSearch 関数は、ユーザーが持っている食材(kinds)に基づいて、MongoDBのRecipeコレクションから該当するレシピを検索します。
ingredient: ingredients フィールドに対して、ユーザーが持っている全ての食材($all)を含むレシピを検索します。
見つかったレシピを、使用されている食材の数でソートします。少ない食材で作れるレシピが優先されるように設定されています。
router.post('/search', async (req, res) => {
const kinds = Object.values(req.body);
const foundRecipes = await naiveSearch(kinds);
console.log(kinds);
res.status(201).render("snippets/recipe.ejs", {recipes: foundRecipes});
});
ユーザーが送信した食材のリストを req.body から取得し、そのリストを naiveSearch 関数で検索します。
const RecipeSchema = new Schema({
name: {
type: String,
required: true,
},
ingredients: [String],
procedures: [String],
});
RecipeSchema: RecipeSchema は、レシピの名前、必要な食材(ingredients)、調理手順(procedures)を管理するためのスキーマです。このスキーマを基にして、MongoDB上でレシピデータを保存・検索します。
router.get('/', async (req, res) => {
const cabinet = await Cabinet.findById(req.user._id).populate({
path: 'contents',
populate: {
path: 'kind',
}
});
res.render("recipe", {contents: cabinet.contents});
});
ユーザーが登録した食材を取得し、その食材リストを recipe.ejsに渡して表示します。
データベース設計
今回の開発では、データベース設計においていくつかの課題に直面しました。その中でも、食材とレシピの関連付けやユーザーごとのデータ管理が主な難題でした。
まずは、MongoDBを用いて、以下のようなモデルを作成しました。
-
Cabinet
:ユーザーが持っている食材リスト -
Grocery
:全ての食材データ -
Recipe
:レシピ情報
そして、MongoDBに接続するための関数**mongoose.connect()**を用いて、MongoDBのデータベースに接続できます。
食材とレシピの関連付け
初期段階では、レシピのingredientsフィールドとユーザーが登録した食材リストを直接照合してレシピを提案する予定でした。しかし、ingredientsフィールドは文字列の配列で管理していたため、「トマト」と「トマト缶」などの異なる表記が原因で、正確なレシピ提案が難しくなりました。
解決策
この問題に対処するため、食材モデル(Grocery)を導入して、データベース内で食材を一元管理するようにしました。これにより、食材名を一貫して管理し、異なる表記による混乱を防止しました。
let groceries = [];
for (const elem of plainGroceries) {
groceries.push(new Grocery({
name: elem,
}));
}
await Grocery.bulkSave(groceries);
このコードにより、すべての食材が一元管理され、正確に検索されるようになりました。
ユーザーごとのデータ管理
ユーザーが登録した食材(Cabinet)と、レシピ提案機能を連携させる際、ユーザーごとのデータの一貫性を保つことが難しくなりました。特に、各ユーザーが異なる食材リストを持っているため、これを効率的にデータベースで管理する必要がありました。
解決策
この問題を解決するために、ユーザーの食材リストをCabinetモデルとして独立したデータベースコレクションで管理し、contentsフィールドに食材(Grocery)のリファレンスを持たせることで、ユーザーごとに食材リストを簡単に取得できるようになりました。
これにより、ユーザーが登録した食材に基づいて、適切なレシピを簡単に提案できる仕組みを作ることができました。
const CabinetSchema = new mongoose.Schema({
username: { type: String, unique: true },
contents: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Grocery'
}]
});
このようにして、ユーザーごとに異なる食材リストを効率的に管理し、レシピ提案機能との連携を図りました。
for (const elem of plainGroceries) {
contents.push({
kind: (await Grocery.findOne({name: elem}))._id,
});
}
また、このコードにより、登録された食材が正確にリファレンスされ、データベース上で管理されます。
まとめと今後のタスク
このプロジェクトを通して、フルスタックでの開発経験を深めることができました。特に、バックエンドとフロントエンドの連携や、データベース管理についての知識を実際に活用できた点が良かったです。基本的な機能は完成しましたが、今後も改善と機能追加を進めていく予定です。
今後のタスク
- 食材の画像アップロード機能:視覚的に食材を登録できる機能の追加
- レシピのユーザー評価システム:レシピに対するユーザーのフィードバック機能
- スマホ対応:レスポンシブデザインを導入して、モバイル端末でも快適に使用できるようにする
- レシピ検索機能の強化:材料やカテゴリでの検索を強化
- 生成AIによるレシピ提案:DBに登録されているレシピだけに依存せず、生成AIを活用したレシピ提案を目指す
最後に
今回の記事が、アプリ開発を始めたい初心者の方にとって参考になり、何かしらのきっかけになれば嬉しいです。今後も技術を学び続け、アプリをさらに発展させていく予定です。皆さんもぜひ、自分だけのアプリを作ってみてください!