52
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初めての個人開発 ②実装編

Posted at

はじめに

私は2023年10月より、内定直結型エンジニア学習プログラム「アプレンティス」に2期生として参加しています。

アプレンティスの課題としてオリジナルプロダクトを開発したので、その内容をまとめようと思います。
前回の「初めての個人開発 ①要件定義・設計編」の続きとなる「実装編」です。

目次

1.出来上がった Web アプリケーションのご紹介
2.何から手を付けるか
3.フロントエンドとバックエンドはどちらが先か
4.データベースはどこまで正規化すべきか
5.CSS 実装方法の選択
6.React Hook Form と画像の即時表示
7.Laravel Sanctum と Laravel Socialite を利用した OAuth 認証
8.ログイン情報をどのように保持するか
9.Googleマップの多重レンダリング問題
10.EC2 インスタンスのファイルシステムの拡張
11.プレゼン用のダミーデータを CSV ファイルからインポート
12.テストユーザーへのインタビュー
13.全体を通して学んだこと
14.最後に

1. 出来上がったWebアプリケーションのご紹介

はじめに、出来上がった Web アプリケーションをご紹介します。

バナー.png
https://vegevery.my-raga-bhakti.com/

「for Every Vegetarian」 - すべてのベジタリアンのための"食"情報サイト『VegEvery』(ベジェヴリー)です!

まだまだ不十分な点はたくさんあるのですが、初めての個人開発としてご容赦いただけたらと思います。
なにかお気づきの点がございましたら、優しくアドバイスいただけると幸いです。

コンセプトなどについては、こちら ↓ をご覧ください。

GitHub リポジトリはこちら ↓ です。

2. 何から手を付けるか

初めての個人開発 ①要件定義・設計編に記載したように、要件定義の段階で、実装したい機能に優先度をつけていました。
その中でも、レシピ機能を最優先で実装することにして、実装をスタートしました。

大まかな順序を最初に決めておいたことで、後の実装を比較的スムーズに進められたのではないかと思っています。

開発スケジュールは Notion のガントチャートで管理しました。
スクリーンショット 2024-05-02 5.06.34.png

3. フロントエンドとバックエンドはどちらが先か

調べてみたのですが、結論、「どっちからでもいい」という意見もある一方、「API から実装すべき」という意見がやや多かったように感じます。

参考 ↓

私は今回なんとなく、デザインカンプを元にフロントエンドから実装を始めました。
そこで感じたのは、前述のように、API から実装した方が効率が良さそうだということです。

なぜかというと、フロントエンドはデータベースへのデータの登録やデータの出力が必要なページが多々あるためです。

そのページに関わるバックエンド API が実装できていない状態では、データの保存・出力を試せず、API を実装してから改めてフロント側で確認が必要になります。

さらに言うと、データの出力のためのダミーデータも必要になるので、まずは(最低限最初に実装する機能の) migration → Factory と Seeder でダミーデータの生成からスタートするのがいいのかなと思います。

その後、API →フロントと実装を進めて、そこから先は上手くいかないところに関して API とフロントを行ったり来たりして実装していきました。

まずは1つの機能について、最優先の項目だけの実装です。
私の場合、レシピの投稿、編集、削除機能を実装しました。ログイン機能は未実装なので、一旦 users テーブルの id が 1 のユーザーが操作している体で進めました。

どれだけデータベース設計がしっかりしているかが問われるところだと感じました。(修正が必要な箇所がちょこちょこ出てきました)

4. データベースはどこまで正規化すべきか

本来は設計編の方で語るべきかと思いますが、実際に実装の段階で迷った部分なので、こちらに記載することにしました。

データベースの正規化については、「第3正規型までは正規化しよう」というのが一般的なようです。

参考サイト ↓

それに習って、はじめは他対他のテーブルには中間テーブルを設けるように設計していました。

ただ、実装している間にテーブルをいくつか増やすことになって、結果的に articles_of_recipe テーブル、articles_of_item テーブル、reviews テーブルそれぞれが、likes テーブルと bookshelves テーブルに対して、他対他の関係を持つことになりました。
それによって、それぞれに中間テーブルを設けようとした結果、中間テーブルだけでどんどんテーブル数が増えていってしまいました。

ただテーブル数が多くなるというだけならまだいいのですが、今回は検索機能を実装するため、テーブルを分けることによって、検索時にテーブルを結合する必要が出てきて、検索スピードに影響が出てきます。

加えて、API を実装する際のメソッドがほぼ同じになるので、それぞれ別のコードを実装するのは果たして最善なのか?と分からなくなりました。

そこで AI に相談してみたところ、Eloquent のポリモーフィック リレーションについて教えてくれました。

そもそも ポリモーフィズム とは

参考 ↓

Eloquent の ポリモーフィック リレーション

私の解釈で簡単にまとめると、中間テーブルを作らずに、参照するテーブルを type カラムで指定し、そのテーブルにおける id を記載することによって、type と id のセットでリレーションを表す方法です。

この方法を採用することで、正規化と、テーブル数の多さ・検索速度の低下というトレードオフにおいて、バランスを取ったつもりです。

5. CSS実装方法の選択

今回、初めて Tailwind CSS を導入してみました。
Tailwind CSS にはないような CSS を当てたい箇所があるページに限り、CSS Modules も導入しています。

Tailwind CSS を使用してみての感想は、CSS を当てるのが非常に簡単だということです。
ただ、その反面、色のバリエーションなど限りがあるため、使用したい色が決まっている場合などは、向いていないと感じました。

tailwind.config.jsを編集することで色などをカスタマイズすることもできるようなので、例えばメインカラーなど使いたい色が数種類に限られる場合は、カスタマイズして利用するのもいいかと思います(カスタマイズが大変だと本末転倒なので)。

参考 ↓

6. ReactHookFormと画像の即時表示

React Hook Form で動的なフォームを実装する方法については、以前簡単に紹介しました。

上の記事でも記載した通り、React Hook Form で動的に増減するフォームを作成する基本的な方法は理解したのですが、そこからが大変でした。

今回はレシピ投稿ということで、フォームの中に type=fileinput 要素を設け、画像をアップロードした瞬間にその画像が画面に表示されるような実装をしようとしていました。

流れとしてはこんな感じです。

  • React Hook Form でフォームを作成
  • input 要素の onChange 属性を利用して、ファイルアップロードを検知
  • イベントが発火したらフォームの値を取得
  • 仮の URL を付与
    const url = URL.createObjectURL(file)
    
  • img 要素 の src 属性にセットする
    <img src={url}>
    

ところが、この onChange 属性が機能しないという状況に遭遇し、しばらく詰まってしまいました。
理由は、React Hook Form で onChange 属性がすでに指定されていることでした。

React Hook Form では、ブログに記載した通り、register をフォームの各要素に渡すことで、その要素をフォームの一項目として登録します。

<input id="userName" {...register('userName')} />

ここで、この register には onChange メソッドが含まれていて、それをinput 属性に渡しています。
上記は、以下のコードと同じことをしています。

const { onChange , onBlur , name , ref } = register ( 'userName' ) ;    
        
<input
  // input 要素の onChange 属性に register の onChange メソッドを割り当てる
  onChange = { onChange }
  onBlur = { onBlur }
  name = { name }
  ref = { ref }
/ >

画像表示のために onChange のメソッドを設定しようとすると、この ReactHookForm とバッティングして、上手くいかなかったのです。

公式サイト ↓

そこで、react-dropzone というライブラリを導入してみることにしました。
これはドラッグ&ドロップでファイルをアップロードできる機能を提供するライブラリです。

参考サイト ↓

ドロップダウン先としたい要素に getRootProps を、input 要素に getInputProps を渡すことで、getRootProps を渡した要素にファイルをドラッグ&ドロップでアップロードすることができるようになります。
もちろん、クリックでのアップロードも可能です。

ただ、ここでも問題になったのが、useFieldAarry フックを利用して input 要素が動的に増減するようにしていたため、それぞれの input 要素に対して onDrop の関数を設定する必要があった点です。

この点については、別で関数を定義するのではなく、input 要素の onDrop プロパティに直接関数を記述することで対応しました。

import Dropzone from 'react-dropzone'

// 略

<div className="bg-orange w-full aspect-[4/3] max-w-md mx-auto">
  {/* url があるなら画像を表示、そうでなければ Drop ゾーンとして機能させる */}
  {stepImages[index] && stepImages[index].url ? (
    <div className="image-preview aspect-[4/3] max-w-md relative flex mx-auto">
      {/* アップロードした画像を削除するための「✕」ボタン */}
      <button
        className="absolute right-1 top-1 bg-white w-4 h-4 leading-none"
        type="button"
        onClick={() => {
          // 画像表示用の state は、該当する index の値を空にする
          setStepImages(prevState => {
            const newState = [...prevState]
            newState[index] = ''
            return newState
          })
          // フォームに空の値をセットする
          setValue(`steps.${index}.image`, '')
        }}></button>
      {stepImages[index].url && (
        // state に保存された URL を使って、画像を表示させる
        <img
          src={stepImages[index].url}
          className="object-cover aspect-[4/3] max-w-md w-full h-full block"
          alt="Uploaded Image"
        />
      )}
    </div>
  ) : (
    // URL がない場合は Drop ゾーンにする
    <Dropzone
      className="w-full h-full"
      // この中で関数を定義することで index を使える
      onDrop={acceptedFiles => {
        // URL を生成
        const file = acceptedFiles[0]
        const createdUrl = URL.createObjectURL(file)
        // フォームに値(ファイル)を登録
        setValue(`steps.${index}.image`, file)
        // URL を生成し、画像表示用の state に保存
        setStepImages(prevState => {
          const newState = [...prevState]
          newState[index] = { url: createdUrl, file: file }
          return newState
        })
      }}>
      {({ getRootProps, getInputProps }) => (
          <div
            // Drop ゾーンとする要素に getRootProps を渡す
            {...getRootProps()}
            className="w-full h-full flex justify-center items-center">
            {/* これはカメラのアイコン */}
            <IconContext.Provider
              value={{ color: '#ccc', size: '80px' }}>
              <PiCameraLight />
            </IconContext.Provider>
            {/* 実際に ファイルアップロードの機能を果たす input 要素に
            getInputProps を渡し、hidden で非表示にしている */}
            <input type="text hidden" {...getInputProps()} />
          </div>
      )}
    </Dropzone>
  )}
</div>

以下が実際の表示です。
オレンジの範囲が Drop ゾーンとなっており、クリックかドラッグ&ドロップで画像ファイルをアップロードできます。
アップロードした瞬間に、オレンジのゾーンにはアップロードした画像が表示されます。

「手順を追加」ボタンを押すことで、Drop ゾーンと手順入力のテキストエリアがセットで増加します。

スクリーンショット 2024-05-04 1.32.54.png

7. LaravelSanctumとLaravelSocialiteを利用したOAuth認証

認証機能には、Laravel/Sanctum を採用しました。
はじめは SPA 認証で実装しようとしたのですが、CROS エラーで上手くいかず、API トークン認証に切り替えました。

/**
 * 新規登録、ログイン共通
 * Google の方に設定した コールバック URL にアクセスがあった時に実行される
 * ソーシャルアカウント情報がデータベースに登録済みか確認し、
 * 未登録ならフロント側でアカウント登録画面に誘導
 */
public function callback(string $provider, Request $request)
{
// Google に登録されたユーザー情報を取得
$providerUser = Socialite::driver('google')->stateless()->user();

// すでにデータベースに保存されているかチェック
$registeredSocialAccount = SocialAccount::where([
  ['provider_id', '=', $providerUser->getId()],
  ['provider', '=', $provider],
])->first();

$response = "";
if ($registeredSocialAccount) {

  $response = [
    "message" => "registered",
    "socialAccountId" => $registeredSocialAccount->provider_id
  ];
} else {
  $response = [
    "message" => "unregistered",
    "socialUser" => $providerUser,
  ];
}
return response()->json($response);
}


public function register(Request $request)
  {
    $path = "";
    $url = "";
    // ユーザーがアカウントのアイコン画像をアップロードした場合
    if ($request->iconFile) {
      // S3 に画像ファイルを保存
      $path = Storage::putFile(
        'S3 ストレージの保存先フォルダのパス',
        $request->file('iconFile')
      );
      $url = "CloudFront で設定した画像配信用独自ドメイン" . $path;
    } else if ($request->iconUrl === '未設定時のデフォルト画像のURL') {
      $path = "画像までのパス";
      $url = $request->iconUrl;
    } else {
      // provider(Google) のアイコン画像をそのまま利用する場合
      $path = "";
      // これは Google側 の URLになる 
      $url = $request->iconUrl;
    }

    // Google ログインの場合、アカウント ID は自動で生成する
    if ($request->provider) {
      // ランダムな文字列を生成し、テーブルに存在する場合は作り直す
      do {
        $randomString = Str::random(15);
      } while (User::where('account_id', $randomString)->exists());

      $user = User::create([
        "account_id" => $randomString,
        'name' => $request->name,
        'vegetarian_type' => $request->vegeType,
        'icon_url' => $url,
        'icon_storage_path' => $path
      ]);

      $social = SocialAccount::create([
        "user_id" => $user->id,
        "provider" => $request->provider,
        "provider_id" => $request->providerId
      ]);
    } else {
      // Google ログインでない場合は普通に保存
      $user = User::create([
        "account_id" => $request->account_id,
        'name' => $request->name,
        'password' => $request->password,
        'secret_question' => $request->secretQuestion,
        'answer_to_secret_question' => $request->secretAnswer,
        'vegetarian_type' => $request->vegeType,
        'icon_url' => $url,
        'icon_storage_path' => $path
      ]);
    }

    // Laravel Sanctum のトークンを発行
    $token = $user->createToken('sanctum_token')->plainTextToken;

    return response()->json(['token' => $token, "user" => $user], 200);
  }

  public function login(Request $request)
  {
    // social ログインの場合
    if ($request->provider) {
      $social_account = SocialAccount::where([
        ['provider_id', '=', $request->providerId],
        ['provider', '=', $request->provider],
      ])->first();

      $user = User::find($social_account->user_id);

      // Laravel Sanctumのトークンを発行
      $token = $user->createToken('sanctum_token')->plainTextToken;

      return response()->json(['token' => $token, "user" => $user], 200);

      // 普通のログインの場合
    } else {

      $request->validate([
        'account_id' => 'required|string',
        'password' => 'required',
      ]);

      $user = User::where('account_id', $request->account_id)->first();

      if (!$user || !Hash::check($request->password, $user->password)) {
        return response()->json([
          'errors' => [
            'login' => ['IDかパスワードが間違っています']
          ]
        ], 422);
      }

      $token = $user->createToken('sanctum_token')->plainTextToken;
      return response()->json(['token' => $token, 'user' => $user], 200);
    }
  }

public function logout(Request $request)
{
// 現在のアクセストークンを削除して![スクリーンショット 2024-05-04 2.10.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070527/733c1d0e-84f8-68c6-d7eb-4fb24d201107.png)
セッションをログアウト
$request->user()->currentAccessToken()->delete();

return response()->json(['message' => 'ログアウトしました。'], 200);
}

  // 以下略

本番環境であれば、同じトップレベルドメインを使用していれば CROS エラーを回避できるのですが、ローカル環境で Docker コンテナを使用していたため、フロントとバックエンドが異なるホストと認識されてしまったことがエラーの原因のようです。

ステージング環境を用意するなどして、本番環境に移行する前に同じトップレベルドメインで動作するかテストする方法もあるようですが、リアルタイムで挙動を確認できませんし、今回は手間がかかりすぎるので採用しませんでした。

トークンの管理

API トークンは、フロント側で js-cookie ライブラリを利用して Cookie に保存し、Axios でフェッチする際に Cookie から取り出してヘッダーに付与することで、バックエンドの認証 middleware をパスすることができました。

import Axios from 'axios'
import Cookie from 'js-cookie'

const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
  headers: {
    // リクエストがAjaxを介して行われたことをサーバーに伝える
    'X-Requested-With': 'XMLHttpRequest',
  },
  // リクエストに資格情報(クッキーや HTTP 認証データなど)を含める
  withCredentials: true,
  // リクエストに XSRFトークンをを含める
  withXSRFToken: true,
  // XSRFトークンのクッキー名を指定
  xsrfCookieName: 'XSRF-TOKEN',
  // XSRFトークンのヘッダー名を指定
  xsrfHeaderName: 'X-XSRF-TOKEN',
})

// リクエストインターセプターの追加
axios.interceptors.request.use(
  config => {
    // クッキーからトークンを取得
    const token = Cookie.get('sanctum_token')

    // トークンが存在する場合、ヘッダーに追加
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }

    return config
  },
  error => {
    return Promise.reject(error)
  },
)

export default axios

アカウント情報

今回のオリジナルプロダクトの課題では、極力個人情報を扱わないよう指示がありました。
そのため、一般的なメールアドレスとパスワードのアカウント認証ではなく、アカウント ID とパスワード、秘密の質問とその答えを登録するようにしました。

パスワードを忘れた場合は、アカウントIDを入力し、事前に設定した秘密の質問に答えることで、再設定ができるようにしました。

加えて、Laravel/Socialite を利用し、Google アカウントでのソーシャルログイン機能を実装しました。

ここでも、ユーザーの名前やメールアドレス等はデータベースに保存しません。
プロバイダーの識別ID の他に、ニックネームとアイコン画像を取得してアカウント登録時にデフォルトで表示させ、それをユーザーがそのまま、あるいは編集して登録できるようにしました。

Google への申請が必要

計算外だったのは、Google の OAuth はそのままだと事前に登録してあるテストユーザーしか Google ログイン機能を利用できないことでした。

テスト状態から本番状態に移行するには、プライバシーポリシーなど Google 側の指定するページを実装する必要があり、それをもって審査を通過することで初めて一般ユーザーが利用できるようになるようです。

現段階ではテスト状態で、今後プライバシーポリシーなどを作成して申請しようと思っています。

スクリーンショット 2024-05-04 2.10.53.png

8. ログイン情報をどのように保持するか

実は認証機能を実装するのは初めてだったのでここまででも一苦労でしたが、問題はその後にもありました。

Laravel 側でログインした後、フロント側でそのユーザーを「ログイン状態である」とどうやって判断すればいいのか?ということです。

useContext を利用する

useContext を使って state に保存したユーザー情報を各コンポーネントで共有することを最初に考えました。
ただ、すべてのコンポーネントで共有するために、例えば app/layout.js などで useContext を使用する場合、トップレベルの親コンポーネントで "use client" ディレクティブを指定することになります。

"use client" ディレクティブを指定したコンポーネントは、その子コンポーネントがすべてクライアントサイド・コンポーネントとなるため、"use client" はできる限り深部のコンポーネントで指定することが望ましいようです。

クライアントサイド・レンダリングでは、一度表示されたページの表示はキャッシュが残るため早いのですが、画面の初回表示は遅くなるという欠点があるからです。

こういった理由から、useContext を利用してすべてのコンポーネントで state を共有する方法は採用しませんでした。

ページ遷移のたびに user 情報をフェッチする

useContext に代わって考えたのは、sanctum middleware をアタッチした、 ログインしている user 情報 を返す API ルートに対して、ページ遷移のたびにフェッチをかけるという方法です。

ただ、ページ遷移のたびに API を叩くというのはあまり望ましくないだろうと思い、別の方法を探そうとしていました。

この時点で認証にかなり多くの時間を使っており、アプレンティスの担当メンターに現状を相談したところ、今回はまず動くものを作ることが最優先ということで、ページ遷移のたびに API を叩く方法でとりあえず進めることになりました。

この記事を執筆している時点では、まだより良い方法にリファクタリングできていないため、時間が許す限り模索したいと思います。

(結局 フックスを使うコンポーネントばかりでほぼクライアント・コンポーネントになってしまっているので、useContext を使っても同じでは?とも思い始めています...)

なお、ユーザー情報のフェッチには、Next.js と通信できるように Laravel 公式が提供している、 breeze-nextuseAuth フックを使用しました。
この Github イメージをクローンして開発を始めたわけではなかったので、コピペでコードを拝借しました。一から Docker コンテナを作成せずに、最初からこちらを利用すればよかったと思いました。

9. Googleマップの多重レンダリング問題

レストランマップに関して大いに詰まったのは、多重レンダリングの問題でした。

ライブラリの選定

Google Maps API 自体は Vanilla JavaScript なのですが、React 用のライブラリを 2 種類見つけました。
@react-google-maps/api@vis.gl/react-google-maps です。

@vis.gl/react-google-maps ↓

ググってみていくらか情報があるのは前者で、後者は新しいライブラリのようです。
最初にどちらも軽く試してみたところ一長一短ありそうだったので、どちらを導入するか迷いました。

前者は、現在非推奨となっている Marker コンポーネントを利用しており、新しい AdvancedMarker コンポーネントをサポートしていないようです。これらはマップ上に表示させるマーカーを生成するためのコンポーネントです。

その点後者は AdvancedMarker コンポーネントをサポートしており、加えて Google Maps の Place API などを利用するための useMapsLibrary コンポーネントも用意してあります。
また、新しいライブラリなこともあって、リポジトリだけでなく分かりやすいドキュメントもあります。

ただ、新しいがゆえに、まだ動作が安定しない部分もあり、更新が頻繁になされるであろうことを公式で宣言していることと、紹介している記事がほとんどないことがデメリットだと感じました。

結局、Place API を利用する必要があることなどから、後者の @vis.gl/react-google-maps ライブラリを導入することに決めました。

やりたかったこと

  • Place API でマップ周辺の飲食店情報を取得
  • 取得したお店の位置にマーカーを表示
  • マーカーをクリックすると、そのお店の情報が表示される

JavaScript で情報ウィンドウつきマーカーを生成する方法として、公式に紹介されているコードです。

// This example displays a marker at the center of Australia.
// When the user clicks the marker, an info window opens.
function initMap() {
  const uluru = { lat: -25.363, lng: 131.044 };
  const map = new google.maps.Map(document.getElementById("map"), {
    zoom: 4,
    center: uluru,
  });
  const contentString =
    '<div id="content">' +
    '<div id="siteNotice">' +
    "</div>" +
    '<h1 id="firstHeading" class="firstHeading">Uluru</h1>' +
    '<div id="bodyContent">' +
    "<p><b>Uluru</b>, also referred to as <b>Ayers Rock</b>, is a large " +
    "sandstone rock formation in the southern part of the " +
    "Northern Territory, central Australia. It lies 335&#160;km (208&#160;mi) " +
    "south west of the nearest large town, Alice Springs; 450&#160;km " +
    "(280&#160;mi) by road. Kata Tjuta and Uluru are the two major " +
    "features of the Uluru - Kata Tjuta National Park. Uluru is " +
    "sacred to the Pitjantjatjara and Yankunytjatjara, the " +
    "Aboriginal people of the area. It has many springs, waterholes, " +
    "rock caves and ancient paintings. Uluru is listed as a World " +
    "Heritage Site.</p>" +
    '<p>Attribution: Uluru, <a href="https://en.wikipedia.org/w/index.php?title=Uluru&oldid=297882194">' +
    "https://en.wikipedia.org/w/index.php?title=Uluru</a> " +
    "(last visited June 22, 2009).</p>" +
    "</div>" +
    "</div>";
  const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: "Uluru",
  });
  const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: "Uluru (Ayers Rock)",
  });

  marker.addListener("click", () => {
    infowindow.open({
      anchor: marker,
      map,
    });
  });
}

window.initMap = initMap;

このように実装する場合、Place API で取得した配列の店舗情報を表示させるには、取得した店舗情報をループで回して、上記のコードを参考にマーカーを生成するようになると思います。

これだと、特に問題はないように見えました。

ところが、前述のライブラリを使用して実装しようとしたところ、多重レンダリングとなり詰まってしまいました。多重レンダリングになっているのだと気づくまでも時間がかかってしまいました。

多重レンダリング問題

React の場合、Place API で取得してきた情報を useState を使って配列で保存し、フェッチしてきたコンポーネントとは別のコンポーネントで state を map 関数で回してマップ上に生成するという方法で実装しました。

実装してみたものの、マーカーをクリックすると情報ウィンドウは表示されるのですが、そのウィンドウの枠にかかっているシャドウがやけに真っ黒なのです。あまりに主張が強くて笑ってしまったのですが、笑っていられるのも最初のうちだけでした。

はじめは CSS の問題かと思い、CSS の変更方法を調べたのですが、調べた限りでは、情報ウィンドウの CSS はカスタマイズできないようでした。
そのため、情報ウィンドウのフックスを利用するのを諦め、shadcn/ui ライブラリの Drawer コンポーネントで情報を表示するようにしてみました。

ドキュメントの実装例を見ると、クリックしたらドロワーの周りは薄く黒みがかった表示になるはずでした。
ところが、ドロワーを開いた途端に、周りが真っ黒になってしまいました。情報ウィンドウのシャドウが真っ黒になるのは、情報ウィンドウコンポーネントが起因する分けではなかったということがここで分かりました。

さらに、ドロワーを画面下に引き下げて閉じた際に、下から同じドロワーがもう一つ出てきました。
ここで、同じマーカーが重複して生成されているのだと気づきました。

おそらく、Place API でフェッチした店舗情報を state に保存した際に、レンダリングが複数回走ってしまっているのだと推測しましたが、useEffect を使用するなど色々試してみても上手く制御できませんでした。
state を使用する方法の場合、ライブラリを @react-google-maps/api に変えてみたりもしましたが、やはり結果は同じでした。

色々足掻いた結果、何日も前に進まなかったため、切り口を変えてやりたいことを実現する方法を考えることにしました。

最終的に、マーカーをクリックしたら情報が表示されるのではなく、取得してきた情報すべてをカルーセルで(最大で20件の情報をフェッチ)表示させ、マップ上のどこにそのお店があるのかを知りたい場合は「ここに移動」をクリックすることで、該当店舗の緯度経度にマップの中心が移動し、そこにマーカーが表示されるという、ある意味逆のアプローチを取りました。

スクリーンショット 2024-05-04 4.18.14.png

React のレンダリングの問題にはいつも悩まされているので、いずれは攻略したいなと思っています。

今回は、あまりに詰まって進まない場合は、別のアプローチを考える必要もあるということも学べました。

10. EC2インスタンスのファイルシステムの拡張

気付いたらバックエンドのサーバーがダウンしていることに気づいて、あれやこれや原因を探っていると、docker build 時にエラーが発生しました。

cannot copy extracted data for './usr/bin/xxd' to '/usr/bin/xxd.dpkg-new':
failed to write (No space left on device)

参考サイト ↓

ということで、ディスク容量を確認すると以下の状態でした。

スクリーンショット 2024-04-15 18.29.43.png

どうやら、/dev/root がいっぱいのようです。
EC2 インスタンスのディスク容量を増やすということを今までやってことがなかったのですが、以下のサイトがとても参考になりました。

今回、インスタンスタイプを一番小さなものにしていたので、足りなくなった場合のことを考える必要があることを学びました。

スクリーンショット 2024-03-29 3.22.32.png

11. プレゼン用のダミーデータをCSVファイルからインポート

最初からしばらくは、Laravel の Facker で生成したダミーデータ(意味のないデタラメな文字列)を使ってきました。

アプレンティスでは、オリジナルプロダクトを企業にプレゼンする機会があるため、ユーザーが投稿したレシピや商品情報のように見えるように、Copilot を利用してそれらしいダミーデータを生成することにしました。

以前も苦戦したことがあるのですが、AI に指示してダミーデータを生成してもらうのが難しく感じています。

「AI に DDL を食わせるといいよ!」とメンターさんにアドバイスいただいたのですが、マイグレーションファイルのスキーマをCopilot に渡しつつ、ベジタリアン向けのサイトであることや、CSV 形式にして欲しいこと、その他いくつかの補足事項を説明しても、なかなか思ったようなデータが得られませんでした。

今後このように AI に作業を指示することも多々あると思うので、何度も挑戦してコツを掴んでいきたいです。

コマンドの登録

CSV ファイルが作成できたら、それをデータベースに挿入するためのコードを書きます。

今回は、実行すると各ファイルからテーブルにデータを挿入するコマンドを登録しました。

12. テストユーザーへのインタビュー

テストユーザーの依頼

今回テストユーザーを依頼したのは、以下の 4 名です。

  • ぺスカタリアン(肉類は食べないが魚介類は食べるベジタリアン)の母
  • オリエンタル・ベジタリアン(野菜の中でも五葷は食べないベジタリアン)のヨーガ仲間 2 人
  • 私(オリエンタル・ベジタリアン)のパートナー

私のパートナーに関しては、本人はベジタリアンではありませんが、「ベジタリアンの家族がいるためベジタリアン料理を作る人」として依頼しました。

複数のバグ報告への対応

一応最低限動作する状態になってから、「使ってみてもらって気づいたことや感想をもらいたい」とお願いしたのですが、投稿が上手くいかない等、複数のバグ報告をいただきました。

Notion を共有して、報告されたバグなどをチェックボックスで記載していき、対応完了後にチェックを入れるようにしました。

ユーザーが想定した使い方をするとは限らないことを念頭に置く

「使い方がイマイチ分からない」といった意見をもらった部分もあり、ユーザーフレンドリーな UI になるよう工夫が必要だと感じました。
ヘルプページを設けるのも 1 つですが、説明がなくてもパッと見て分かるようにアイコンで示したり、これから工夫してみようと思っています。

ボタン押下時表示

投稿などのボタンをクリックするとバックでは処理が走っているのですが、画面遷移するまで時間がかかり、クリックしても画面上は何も起こっていないように見えるのも問題でした。

投稿ボタンが押せていないと思い何度も押したことで、複数同じ投稿が並んでしまうことがありました。

全体的に、アクションを起こすボタンをクリックした時にすぐに画面に変化が現れない状態ったので、ボタンをクリックしたら「Loading..」など何かしらの表示を出す必要があると思っています。

現時点でまだ実装できていませんが、最優先で対応する予定です。

13. 全体を通して学んだこと

要件定義や設計の重要性

実装段階で迷ったり、手戻りが発生しないようにするためには、以下の点が重要であると学びました。

  • 何のために、誰のために作るのかを明確にすること
  • 要件定義を明確にし、優先度をつけること
  • 設計段階でプロダクトの要件に適したツール等を選択すること

また、これらを明確にするためには、完成後のプロダクトを詳細度高くイメージすることが大事だと感じました。

「何のために、誰のために」を明確にすることで、実装で詰まった時に、別のアプローチで目的を達成できないかを考えることができました。

認証周りと、セッションや Cookie に関する知識

オリジナルプロダクト制作前の段階では、認証機能を実装したことがありませんでした。

ログイン機能はアプリケーションに必須と言っても過言ではないので、今回学んだことがダイレクトに今後に活かせるのではないかと思います。

困った時は周りに相談すること

2週間に1回メンターさんとの面談があり、アドバイスをいただきながら進めてきました。

また、チーム開発の際のメンバーと、週に1~2回チーム会を開いており、オリジナルプロダクトの開発で詰まっていることろなどをお互いに相談し合いました。

自分にはなかった視点や、見つけられなかった情報を見つけてきてくれたりして、個人開発の際も相談でき仲間がいたことは非常に大きかったです。

就職できたらチームで協力して開発していくことになるので、チームメンバーとしての役割も意識していきたいと思っています。

ユーザーに実際に使ってもらうこと

チーム開発では、ユーザーがいることを想定してアプリケーションを制作しましたが、チームメンバー以外の人に使ってもらうところまではいきませんでした。

今回初めて、自分の制作したアプリケーションを人に使ってもらったことで、課題解決のために制作しているのだと改めて実感することできました。

また、今回のプロダクトは自分自身があったらいいなと思うものでもあったので、熱量高く取り組むことができたのもよかったです。

持病があるので体調に左右されるものの、週に100時間程度、日によっては一日20時間もの時間を制作に費やしてきました。

当たり前ですが、エンジニアはコードを書くことそのものが目的ではなく、制作したアプリケーションによってユーザーの課題を解決したりすることが目的なので、要件定義から実際にユーザーに利用してもらうまでの一連の流れを経験できたことは大きかったと思います。

テストユーザーの友人からは、「ベジタリアンだからこそ、寄り添ったものが作れるんだろうなと感じた!」「実際にレシピや情報が集まってきたら、これは本当に凄いことになりそう!」と嬉しいメッセージをいただきました。

使って欲しい人がいることもあり、今回のオリジナルプロダクトは課題として提出するに留まらず、引き続きアップデートしながら運用してみたいと考えています。

14. 最後に

今回のオリジナルプロダクトは、内定直結型エンジニア実習「アプレンティス」の最終課題として制作してきました。

アプレンティスでは、このオリジナルプロダクトをアプレンティス参加企業様に履歴書等とともに提出し、企業様から指名をいただきます。

指名をいただいた企業様との 1 on 1 で、オリジナルプロダクトをプレゼンさせていただき、合格をいただけたら実際にその企業でのアプレンティスシップ(実務実習)が始まります。

そして、双方良ければそのまま正社員として内定をいただけるという流れです。

2023 年 10 月からアプレンティス 2 期生として全力で走ってきて、学習時間は累計で 2,500 時間程にもなりました。
このプロダクトをもって、いい節目を迎えられることを願います。

お読みいただきありがとうございました。

52
62
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?