🧑‍🍳

技術学習のために1からアプリを開発してみた

2024/11/22に公開

はじめに

一通りインプットが終わったタイミングで、言語やライブラリの書き方について合う程度理解はできたものの、いざものを作ろうとすると何からやれば良いかわからない、、という状況だったので、これまでの学習の総復習として、アプリを1から作ってみました。本記事はどのような流れで開発を進めたかや、その過程で得た学びをまとめています。自分自身の備忘録という意味合いが大きいですが、

  • 「一通り学習を終えたけれど、次に何をすれば良いかわからない」
  • 「コードを書くだけではなく、実際に動くアプリを公開してみたい」

といった思いを持った方の参考になれば嬉しいです。
(かなり荒い粒度でやったことをまとめてます)

完成品

今回開発したのは、「お気に入りのレシピを登録して、作りたい料理を選ぶと買い物リストを作成してくれるアプリ」です。

私自身、食材をまとめ買いすることが多いのですが、買い物中に毎回材料を計算するのが面倒で、もっと楽に管理したいと思い作成しました。巷にあるレシピアプリは確かに使いやすいのですが、あらかじめ用意されたメニューしか選べないものが多かったため、自由にレシピを登録できるようにした点が特徴です。製作期間は2ヶ月ほどです。

イメージ

コードはこちら → https://github.com/Syumai3/recipe-shopper-app-cp

全体の流れ

以下に今回開発した際の流れをまとめてます。ただ実際には綺麗にこの通りにはいかず、各工程を行ったり来たり試行錯誤を繰り返しながら進めました。(特にフロントエンドとバックエンドは行ったりきたりを繰り返します)また、これが正解ではなく、あくまで今回私がたどった流れです。

  • 作りたいものを決める
  • 要件を定義する
  • デザインを作成する
  • 使用する技術を決める
  • バックエンドの設計・実装
    • データ構造の設計
    • データベースの作成
    • スキーマとロジックの実装
  • フロントエンドの設計・実装
    • 静的ページの作成
    • APIとバックエンドの連携
  • 認証機能の実装
  • デプロイ

作りたいものを決める

アプリ開発の第一歩として、「何を作りたいか」を明確にすることから始めました。何か既存のアプリを模倣するのも良いですが、せっかくなら自分や周りの人が「ちょっと困っていること」を解決できるものにするとモチベーションが上がります。

私は普段、料理に必要な食材を買い物リストにまとめる作業が面倒に感じていたため、「レシピを登録して買い物リストを自動生成できるアプリ」を作ることにしました。他にもレシピアプリは存在しますが、既存のものは固定されたメニューしか登録できず、不便だと感じていました。そこで、自分が作りたい料理(たとえばクックパッドで見つけたレシピなど)を自由に登録できる仕組みを取り入れました。

要件を定義する

次に、アプリに必要な機能を洗い出します。ここでは、実現可能性を意識しながら最小限のスコープに絞ることが重要です。

今回のアプリの主な要件は以下の通りです:

  • 材料を選んでレシピを作成できる
  • 作成したレシピを編集・削除できる
  • 登録したレシピを選択すると、買い物リスト(材料一覧)を出力できる

はじめは色々やりたことが湧いてきて、機能がモリモリになったのですが、実装していると結構大変で、途中から最小スコープに絞って実装をしました。
(「材料の金額情報を持たせて合計金額を計算する」や、「冷蔵庫の在庫管理機能」などのアイデアもあったのですが、これらは将来的な追加機能としました。)

デザインを作成する

次に、ざっくりとしたデザインを作成します。現時点で完璧である必要はなく、レイアウトやページ遷移、入力フォームの配置などを簡単にイメージする程度で十分です。私は FigJam を使って全体像を整理しました。

イメージ:

使用する技術を決める

学習目的と既存のスキルを考慮し技術スタックを選定しました。

  • フロントエンド: React、Next.js、TypeScript
  • API: GraphQL(Apollo Client)
  • バックエンド: Apollo Server
  • データベース: PostgreSQL(ORMはPrismaを使用)
  • 認証: Auth.js(Google認証)
  • デプロイ: フロントエンドはVercel、バックエンドとデータベースはRender

特に、バックエンドは初めて触るため、なるべく簡単で理解しやすい技術を選びました。
Renderの無料プランは便利ですが、無料期間が終了するとDBが使えなくなる点には注意が必要です。
またバックエンドの開発には docker を使用しました。

バックエンドの設計・実装

データ構造の設計

アプリの機能を実現するために、データベースの構造を設計します。まずは、各テーブルがどのような情報を保持し、どのように関連付けられるかを明確にするため、ER図(エンティティ・リレーションシップ図)を作成しました。

作成したER図:

ER図を作成することで、データ構造を整理・管理できるようになります。
またER図を書くのははじめてだったので、こちらの本を参考にしました。

データベースの作成

データベースの構造をコードで定義するため、schema.prisma ファイルを作成します。

// データベースの接続情報
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// generateコマンドで生成するものを指定
generator client {
  provider = "prisma-client-js"
}

// アプリケーションで使用するモデル
model User {
  id        String   @id
  username  String
  email     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  recipes   Recipe[]
  menus     Menu[]
}

...省略

その後、以下のコマンドを実行してデータベースを生成します:

npx prisma migrate dev --name init

これで定義されたテーブルがprisma > migration フォルダにファイルが作成されます。これでデータベースが作成されます。
さらに、初期データとして材料情報を登録するため、Seedデータを用意しました。

const ingredients = [
{ name: "たまねぎ", quantity: 3 },
{ name: "にんじん", quantity: 2 },
// その他の材料
];

スキーマとロジックの実装

次に、GraphQLのスキーマを作成し、アプリで利用するクエリ(query)やミューテーション(mutation)を定義しました。

  • クエリ
    フロントエンドからのリクエストに応じて、データベースに問い合わせを行い、必要な情報を返します。
    例:登録済みのレシピ一覧を取得する。
  • ミューテーション
    クライアントからのデータを受け取り、データベースの情報を更新します。
    例:新しいレシピを登録する、既存のレシピを削除する。

schema情報

import { gql } from 'apollo-server';

export const typeDefs = gql`
  scalar DateTime

  type User {
    id: String!
    username: String!
    email: String!
    createdAt: DateTime!
    updatedAt: DateTime!
    recipes: [Recipe!]
    menus: [Menu!]!
  }

  type Recipe {
    id: Int!
    name: String!
    description: String
    recipeIngredients: [RecipeIngredient!]!
    createdBy: User!
    userId: String!
  }

  type RecipeIngredient {
    ingredient: Ingredient!
    quantity: Float!
  }

  type Menu {
    id: Int!
    name: String!
    description: String
    startDate: DateTime!
    endDate: DateTime!
    createdAt: DateTime!
    updatedAt: DateTime!
    user: User!
  }

...省略

特定のレシピを取得する query

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// 特定のユーザーを取得するためのリゾルバ
export const recipe = async (_: unknown, { id }: { id: string }) => {
  return prisma.recipe.findUnique({
    where: { id: Number.parseInt(id) },
    include: { user: true },
  });
};

レシピを作成するための mutation

import { MyContext } from '../../index.js';

type CreateRecipeInput = {
  name: string;
  description?: string;
  ingredients: {
    ingredientId: number;
    quantity: number;
  }[];
  userId: string;
};

// レシピを作成するためのミューテーション
export const createRecipe = async (
  _: unknown,
  { input }: { input: CreateRecipeInput },
  context: MyContext,
) => {
  return context.prisma.recipe.create({
    data: {
      name: input.name,
      description: input.description || '',
      user: { connect: { id: input.userId } },
      recipeIngredients: {
        create: input.ingredients.map((ing) => ({
          quantity: ing.quantity,
          ingredient: { connect: { id: ing.ingredientId } },
        })),
      },
    },
    include: {
      user: true,
      recipeIngredients: {
        include: { ingredient: { include: { unit: true } } },
      },
    },
  });
};

これで、データベースからデータを読み込んだり、データベース上のデータを書き換えられるようになります。

実装を進める際に特に苦労したのは型の不整合やエラー対処でした。Apollo playgroundでテストしながら実装することで、早期にエラーを検知できます。スキーマとPrismaモデルの型が一致していない場合などでエラーが頻発していました。(実際の開発時は必ず実装とテストをセットで作成します。)

以下、Apollo playgroundです。queryやmutationの動作確認ができます。

レシピを取得するQuery

レシピを作成するMutation

フロントエンドの設計・実装

Reactでプロダクトを実装する流れについては、公式DocsのReactの流儀を参考にしております。

静的ページの作成

デザイン案をもとに、まずは静的ページを構築しました。Reactを使用し、コンポーネントごとに責務を分けることで、管理しやすいコード構成を心掛けました。

状態管理と動的な機能の実装

ReactのuseStateを利用して、アプリ内の状態を管理しました。ページ遷移やフォーム入力、レシピ登録など、動的な動きを持たせました。

APIとバックエンドの連携

GraphQLの型定義をもとに、graphql-code-generatorを用いて型付きのクエリやミューテーション関数を自動生成しました。これにより、バックエンドとスムーズに連携するコードを短時間で書けるようになりました。
createRecipe の型情報

mutation createRecipe($input: CreateRecipeInput!) {
  createRecipe(input: $input) {
    id
    name
    description
    userId
    recipeIngredients {
      ingredient {
        id
        name
        unit {
          id
          unit
        }
      }
      quantity
    }
    createdBy {
      id
      username
    }
  }
}

これをcodegen し、次のように使用します。

function CreateRecipe() {
  const [createRecipe, { loading }] = useCreateRecipeMutation();

...省略

try {
      const result = await createRecipe({
        variables: {
          input: {
            name: formData.name,
            description: formData.description,
            userId: session.user.id,
            ingredients: formData.ingredients.map((ingredient) => ({
              ingredientId: ingredient.id!,
              quantity: ingredient.quantity,
            })),
          },
        },
      });

認証機能の実装

ログイン・ログアウト機能はAuth.jsを利用して実装しました。チュートリアルの中で、OAuthを強く推奨されていたので、Google認証を導入しました。
ただ、googele認証だとセッションIDが毎回変わるため、一度登録したユーザーで再ログインできないという問題に直面しました。
この問題を解決するため、JWT(JSON Web Token)を導入し、google認証情報を管理できるようにしました。

デプロイ

フロントエンドはVercel、バックエンドとデータベースはRenderにデプロイしました。どちらも無料プランがあり、初学者に最適な選択肢でした。ただし、Renderの無料プランは期間が限られており、データベースが非アクティブになる可能性がある点に注意が必要です。

感想

  • 1からアプリを作ることで、開発工程全体の解像度がグッと高まりました。特に、自分でバックエンドを実装したことで、全体のデータフローが把握できるようになり、バックエンドが普段どんな処理を行っているのかを理解することができました。本を読んでていてもいまいちイメージがつかなかったので、手を動かすのが一番手っ取り早かったです!
  • またバックエンドエンジニアの方が話している内容が以前よりも理解できるようになりました(英語リスニングで、発音の勉強をちゃんとしたら、ある日突然英語が聞き取れるようになった感覚を思い出しました)
  • 一方で反省点としては、作るものが大きすぎたと思います。機能が多く、詰まることも多かったので途中でモチベーションが下がった時期がありました。なので、もう少し小さなアプリで短いスパンで完結するプロジェクトを複数経験する方が、技術習得の効率が良いと感じました。

以上が今回のアプリ開発の全体像です。同じように「何から手をつければよいか分からない」と悩んでいる方の参考になれば幸いです!

Discussion