【個人開発】プログラミング学習のロードマップ投稿サービス 「ProadMaps」を開発しました！（Rails×Next.js×ECS）

## はじめに
私はもともとSIerで金融インフラの運用管理を4年ほど行なっていましたが、コードが書けるエンジニアになりたいと思い、webエンジニアへの転職に向けて学習をしております!
本記事では、私がプログラミングほぼ未経験から作成したプログラミング学習のロードマップ/学習記録 作成ツール&共有サービス 「ProadMaps」(サービス終了済み)について、作成までの学習記録、使用技術、サービス概要について紹介させていただきます!

**Githubのリポジトリ**

https://github.com/uyudane/backend-proadmaps

https://github.com/uyudane/frontend_proadmaps

## ポートフォリオ作成の背景と概要
現在、プログラミングを学習するにあたって、**書籍や動画、ハンズオンサービスなど、有料/無料、公式/非公式のものを含めてさまざまなサービス**があります。
良い教材を探したいと思ってレビューを見ると、「初心者には難しすぎ★3」や、「欲しかった情報はなく物足りない★2」といったものがありますが、それは教材の内容が悪いのではなく、実施順序が悪いのではないのかなと感じ、そうであれば**先に学習するべき教材、実施後に学習すべき教材を教えてほしいな**と思っていました。
また、プログラミング学習というのは人によって開始時のスキルや教材の嗜好が異なるため、複数のロードマップを比較して自分に合ったロードマップを参考にできるのがベストだなと思っていましたが、**既存のサイトに載っているロードマップはフォーマット、情報量がバラバラで比較しづらい**と感じておりました。
そういった背景から、**どういった学習教材をどの順番で学習するべきか、一定のフォーマットでまとめて見れるサービス**があれば...!と考え、本サービスを開発しました。

## 作成できるロードマップの形式
一つの「ロードマップ」に、「ステップ」という名前で、使用する教材(書籍、動画、コンテンツ等)を複数紐づけて投稿することができます！
**①ロードマップ**
- タイトル
- 概要
- タグ
- 開始時スキル
- 終了時しスキル
- 総ステップ数

**②ステップ**
- 教材名(URLから自動取得)
- 紹介文/コメント
- 所用時間
- 実施年月
- OGP情報(URLから自動取得)

例. https://proadmaps.com/1506616851853242371/roadmaps/14
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710784/5726cd16-44fd-e2a4-a55b-51bb2136a29b.png)](https://proadmaps.com/1506616851853242371/roadmaps/14)


## 実装機能

| ユーザ登録/ログイン機能 | ロードマップ一覧/検索機能 |
| :- | :- |
| ![login](https://user-images.githubusercontent.com/103183815/204941715-85e67e5f-d6dc-4354-b58f-47033e382ab0.png) | ![auto_slide.gif](https://i.gyazo.com/2e6fbec4b70fd1c7ebca050875a0a849.gif) |
| ・Auth0を使用して作成しており、Github/Twitter/メールアドレスを利用してユーザ登録/ログインをすることができます。| ・Next.jsのISRを使用して、高速に新しい情報を提供します。<br>・タグとフリーワードで、投稿されたロードマップを検索することができます。 |

| ロードマップ詳細機能 | 動的OGP機能 / マークダウン出力機能 |
| :- | :- |
| ![show](https://user-images.githubusercontent.com/103183815/204943085-0f65f9c4-e148-4923-8422-6358f69aaf37.png) | ![ogp](https://user-images.githubusercontent.com/103183815/204944558-8597bd57-116b-44bf-804d-26d4a7189ef0.png)<br>![markdown](https://user-images.githubusercontent.com/103183815/204944141-5ecb98c2-8222-4662-b90e-a101c8332b01.png) |
| ・Next.jsのISRを使用して、高速に新しい情報を提供します。<br>・作成時に登録したコンテンツのURLをもとに、Next.jsのAPIルートを利用して、サイトのメタタグを取得し、OGP情報を表示します。| ・vercel/ogライブラリを使用してTwitter投稿時の動的OGPを作成します。<br>・作成したロードマップをMarkdownで出力します。 本機能を使用して作成したQiita記事は[こちら](https://qiita.com/niboshi_uyudane/items/3afd709d7c7dec22e267)。|

| ロードマップ/学習記録の概要作成機能 | ステップ作成機能 |
| :- | :- |
| ![roadmap](https://i.gyazo.com/5df4770164ea64a222540ef9bc9db158.gif)| ![step](https://i.gyazo.com/3be9b39f105682e4558b14c1efa89599.gif) |
| ・ユーザの入力が楽になる&何を入力すればいいかわかるように、MUIのAutoCompleteを使用して、記入案からの選択と、自由記述の両方できるようにしています。 | ・一つのロードマップの中で、タイトル、URL、説明等を設定した学習教材を複数設定することができます。<br>・ReactDnDを利用して、ドラックアンドドロップでステップの順番を変更することができます。 |

**その他**
- ロードマップ下書き一覧機能
下書きしたロードマップを一覧で表示することができます。

- プロフィール詳細/編集機能
各ユーザのユーザ情報、投稿/いいねしたロードマップを見ることができます。
自身のユーザ情報(ユーザ名、Github/Twitterアカウント)を編集、登録できます。

- 管理機能
管理ユーザでログインすることで、ユーザと、ロードマップを管理することができます。

- レスポンシブデザイン
PC、スマートフォン、タブレットに合わせたレスポンシブデザインに対応しています。(スマホではロードマップ作成、編集機能は使用不可)

## インフラ構成と主な使用技術

![ポートフォリオ_インフラ構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710784/a8118194-6999-23f1-7125-d1e541931b5a.png)


### 認証

- Auth0

### バックエンド

- Ruby(3.1.2)
- Ruby&nbsp;on&nbsp;Rails(7.0.4)\*API モード
- Docker / Docker Compose

### フロントエンド

- Typescript
- React(18.2.0)
- Next.js(12.3.1)
- Recoil
- Axios
- SWR
- MUI

### インフラ
**フロントエンド**
- Vercel

**バックエンド**
- Amazon ECS(Fargate)
- Amazon ECR
- Amazon Route53
- AWS Certificate Manager
- Application Load Balancer
- RDS(MySQL)
- CircleCIを使用したCI/CD

## ER図
![ポートフォリオ_ER図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710784/62da8fa3-4eba-08cb-261b-2d3b28b082d8.png)

## ポートフォリオ作成までの学習記録
2022年の4月から仕事をしながら学習を始め、6月の中旬以降は退職をして学習にフルコミットし、2022年の12月頭にポートフォリオをリリースしました。

### RailsとWeb技術の基礎知識の学習(2022年4月〜8月の中旬)
[RUNTEQ](https://runteq.jp/r/TZ86h7jp)というプログラミングスクールで、Railsとその他の基礎知識の学習をしました。スクールに入るか入らないかは巷で色々と言われていますが、「基礎力をつけるための道筋を提示してくれる」点と、「一緒に転職を目指して頑張っている同士、自分よりも圧倒的にすごい人がいて刺激を受けられる」という点で、特に精神衛生上入って良かったなと思っています。そのため、お金に余裕があればスクールを使うのもありかなと思います。もちろん、スクールに入ればエンジニアになれるという考え方はNGで、スクールも使って、必要な知識、自走力をつけていく必要があるかと思います。

### フロントエンド(JavaScriptからReact、Next.js、TypeScript)の学習(8月中旬〜9月中旬)
本サービスを使用して作成した学習記録があるので、ぜひ覗いていただけると嬉しいです...!

https://qiita.com/niboshi_uyudane/items/3afd709d7c7dec22e267

### インフラ(Docker、CircleCI、EC2、ECS)の学習(9月中旬〜10月中旬)
こちらも本サービスを使用して作成した学習記録があるので、ぜひ覗いていただけるととても嬉しいです...!

https://qiita.com/niboshi_uyudane/items/7b94a6d060adc733f076

### 本ポートフォリオの作成(10月中旬〜11月末)
途中でMVPリリースを挟み、スクール内でアプリへのコメントをもらいました。

### その他
- Paiza、AtCoderを使用してRuby、競技プログラミングの学習を行いました。
- 書籍を利用して、エンジニアやプロダクトマネージャー、開発手法等について学習を行いました。

## 苦労した点・工夫した点
初めてのアプリ開発だったので、いろいろと苦労した点はありますが、見てくださっている方に参考にしていただけそうなものを3つ紹介させていただきます。

### ①Next.jsのAPIルートを用いて、入力されたURL先のメタタグを取得して、OGP情報をまとめたカードを作成
ロードマップ詳細画面に表示させている以下のようなカードを作成しようとしたのですが、初めはクライアント側で取得しようとしてCORSに阻まれ、最終的にAPIルートを用いてサーバ側で情報を取得しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710784/6f4d15bf-2871-2e80-9c10-960937547793.png)

<details><summary>コード/参考サイト等</summary>

**ogp.ts**
Next.jsのnode上で動くAPIのコードです。
Amazonのみメタタグの記載方法が特殊だったので、場合分けをしてます。

```typescript:ogp.ts
import axios from 'axios';
import { JSDOM } from 'jsdom';
import { NextApiRequest, NextApiResponse } from 'next';

const ogp = async (req: NextApiRequest, res: NextApiResponse) => {
  const targetUrl = req.query.url as string;

  const metaData = {
    site_name: '',
    title: '',
    description: '',
    image: '',
    url: targetUrl,
  };

  if (!targetUrl) {
    res.status(400).send('error');
    return;
  }

  // URLに含まれる幾つかの文字がaxiosの中でエンコードされないため、事前にurlをエンコードしている??
  const encodedUri = encodeURI(targetUrl);
  // カスタムヘッダーを設定
  const headers = { 'User-Agent': 'bot' };

  try {
    const res = await axios.get(encodedUri, { headers: headers });
    const html = res.data;
    const dom = new JSDOM(html);
    // 必要なデータのみを抽出
    // Amazonは特殊(propertyではなくname属性かつ、head外にmetatagが存在)だったため、処理を変える
    if (targetUrl.match(/amazon.co/)) {
      // imageがmetaになかったため、imageだけはクラス名で絞って最初に出てきた画像がトップの画像になる(っぽい)。
      const image = dom.window.document.getElementsByClassName('a-dynamic-image');
      metaData.image = image[0].getAttribute('src') as string;
      const metas = dom.window.document.querySelectorAll('meta');
      metaData.site_name = '書籍';
      for (let i = 0; i < metas.length; i++) {
        const pro = metas[i].getAttribute('name');
        if (typeof pro == 'string') {
          if (pro.match('title')) metaData.title = metas[i].getAttribute('content') as string;
          if (pro.match('description'))
            metaData.description = metas[i].getAttribute('content') as string;
        }
      }
    } else {
      const metas = dom.window.document.head.querySelectorAll('meta');
      for (let i = 0; i < metas.length; i++) {
        const pro = metas[i].getAttribute('property');
        if (typeof pro == 'string') {
          if (pro.match('site_name'))
            metaData.site_name = metas[i].getAttribute('content') as string;
          if (pro.match('title')) metaData.title = metas[i].getAttribute('content') as string;
          if (pro.match('description'))
            metaData.description = metas[i].getAttribute('content') as string;
          if (pro.match('image') && !pro.match('image:width') && !pro.match('image:height'))
            metaData.image = metas[i].getAttribute('content') as string;
        }
      }
    }
  } catch (error) {
    res.status(400).send('error');
  }
  res.status(200).json(metaData);
};

export default ogp;
```

**roadmaps.ts**
APIを使用するservice側のコード。SWRを使用しています。

```typescript:roadmaps.ts 
... 
export const useURLData = (url: string) => {
  const fetcher = async (apiURL: string, url: string) => {
    const res = await axios.get(apiURL, { params: { url } });
    return res.data;
  };
  const { data, error } = useSWR([ogpShow, url], fetcher);
  return {
    urlData: data,
    isLoading: !error && !data,
    isError: error,
  };
};
```

**参考記事**

https://zenn.dev/tomi/articles/2021-03-22-blog-card

https://zenn.dev/panda_program/articles/generate-og-image

</details>

### ②ReactDnDとRecoilを用いて、ドラックアンドドロップで順番を入れ替えられるカードを作成して、情報を配列としてグローバルなStateに設定する
ロードマップに紐づく教材をステップという名前で登録するにあたって、順番が重要なため、ドラックアンドドロップで簡単に操作できるようにしたいなぁと考えました。また、作成するのは結構な労力をかけてもらっているはずのため、万が一、リロードしてしまっても情報が削除されないように、Recoilを使用して、ローカルストレージに情報を保存できるようにしました。

<details><summary>コード/参考サイト等</summary>

**stepsState.ts**
Setpという型を準備して、その配列という形でRecoilのAtomを作成しています。また、LocalStorageに保存できるようにしています。

```typescript:stepsState.ts
import { atom } from 'recoil';
import { recoilPersist } from 'recoil-persist';
import { Step } from 'types';

const { persistAtom } = recoilPersist({
  key: 'recoil-persist',
});

const stepsState = atom<Step[]>({
  key: 'stepsState',
  default: [],
  effects_UNSTABLE: [persistAtom],
});

export default stepsState;
```

**DndStepContainer.tsx**
ステップカードの順番を入れ替える際のコードになります。
ReactDnDでドラッグしているアイテムのindexとホバー先のindexを取得して、ドラッグしたindexを削除して、ホバー先にindexを挿入しています。そして、その結果をRecoilのStepsに設定しています。

```typescript:DndStepContainer.tsx
  ...
  const [steps, setSteps] = useRecoilState(stepsState);
  const moveStep = useCallback((dragIndex: number, hoverIndex: number) => {
    setSteps((prevSteps: Step[]) =>
      update(prevSteps, {
        $splice: [
          // ドラッグしたindexの値を削除
          [dragIndex, 1],
          // ホバーした先のインデックスに挿入する
          [hoverIndex, 0, prevSteps[dragIndex] as Step],
        ],
      }),
    );
  }, []);
```

**CreateStepDialog.tsx**
ステップの新規作成、編集処理を行う処理になります。新規作成時は、Recoilにもともとあるステップに新しいステップを追加しており、編集時は編集箇所の部分のみ更新しています。

```typescript:CreateStepDialog.tsx
// 編集時に使用する関数。編集するオブジェクトの値を変更して、前後は元の値で上書く。
const replaceItemAtIndex = (arr: Step[], index: number, newValue: Step) => {
  return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)];
};
...
  // フォーム送信時の処理
  const onSubmit: SubmitHandler<Step> = async (data) => {
    // 新規作成時の処理(配列に新しいオブジェクトを追加)
    if (typeof index === 'undefined') {
      setSteps((oldSteps) => [
        ...oldSteps,
        {
          // 新規作成時はgetStepId()が渡されている想定
          id: currentStep?.id || getStepId!(),
          url: data.url,
          title: data.title,
          introduction: data.introduction,
          required_time: data.required_time,
          year: data.year,
          month: data.month,
          // step_numberは表示の際に使用するもので、作成時には関係ないためnullを入れる
          step_number: null,
        },
      ]);
      reset();
    } else {
      // 編集時の処理(配列の指定の値を変更する)
      const newList = replaceItemAtIndex(steps, index, {
        //index === 'undefined出ない時点で、idに値が入っている想定
        id: currentStep.id as number,
        url: data.url,
        title: data.title,
        introduction: data.introduction,
        required_time: data.required_time,
        year: data.year,
        month: data.month,
        // step_numberは表示の際に使用するもので、作成時には関係ないためnullを入れる
        step_number: null,
      });
      setSteps(newList);
    }
    handleClose();
  };
```

**DeleteStepButton.tsx**
ステップの削除処理。削除するindexの前と、index以降を結合する処理になっています。

```typescript:DeleteStepButton.tsx
const removeItemAtIndex = (arr: Step[], index: number) => {
  return [...arr.slice(0, index), ...arr.slice(index + 1)];
};
const DeleteStepButton = ({ index }: { index: number }) => {
  const [steps, setSteps] = useRecoilState(stepsState);
  const deleteItem = () => {
    const newList = removeItemAtIndex(steps, index);
    setSteps(newList);
  };
  return (
    <>
      <Button variant='text' onClick={deleteItem}>
        削除
      </Button>
    </>
  );
};
```

**参考記事**

https://recoiljs.org/docs/basic-tutorial/atoms/

https://react-dnd.github.io/react-dnd/examples/sortable/simple

</details>

### ③ロードマップに1対多で紐づいた複数カラムあるステップが、更新時に編集削除、追加、順番変更された際のバックエンド側の実装
すみません...笑 これはあまり他の方の役に立つものではないかもですが、バックエンドでは一番頑張ったところなので上げさせてください。今回、作成済みロードマップを更新する際に、古いステップの一部が削除されたり、編集されたり、ドラックアンドドロップで新規作成のステップが元のステップより前の順番に移動されたりするというところがあり、フロントエンドからソート順に渡されたステップをもとにバックエンド側でどのようにDB更新するかというところを苦労いたしました。

<details><summary>コード/参考サイト等</summary>

**roadmaps_controller.rb**

ロードマップコントローラーのupdateメソッドになります。modelのupdate_with_tags_stepsに、tagのリストと、stepのリストを渡すような処理になっています。
```ruby:roadmaps_controller.rb
 def update
    # ユーザー認証
    tag_list = params[:tags]
    step_list = params[:steps]
    if @roadmap.update_with_tags_steps(tag_list:, step_list:, roadmap_params:)
      render json: @roadmap, status: 200
    else
      render_500(nil, @roadmap.errors.full_messages)
    end
  end
```

**roadmap.rb**
ロードマップモデルのupdate_with_tags_stepsメソッドになります。
タグはfind_or_initialize_byで更新して、
ステップは、もとあるstepのidと比較しながら、stepの順番であるstep_numberを更新していき、新しいステップリストにはなかった古いステップ(削除されたステップ)は最後に削除するようにしています。

```ruby:roadmap.rb
def update_with_tags_steps(tag_list:, roadmap_params:, step_list: [])
    ActiveRecord::Base.transaction do
      # Tagが既にあればそのオブジェクトを、なければ新しくタグを作成して作成後のオブジェクトを返し、
      # self.tagsに代入することでロードマップとタグを紐づける
      # updateの場合でも、同様の記載でよい
      # 新たに関連づけられると、元あったタグは、中間テーブルから削除される
      self.tags = tag_list.map { |tag| Tag.find_or_initialize_by(name: tag[:name].strip) }
      # 現在ロードマップに紐づいているstepのidを確認(今回のリクエストで存在しなかったものを削除するために使用)
      current_id = steps.map { |step| step[:id] }
      # 取得したstep情報とインデックス(配列の順番)を使用して、ロードマップに紐づいたstep情報を作成する
      step_list.each.with_index do |step, index|
        step_params = step.permit(:url, :title, :introduction, :required_time, :year,
                                  :month).merge(step_number: index + 1)
        # 新しいステップの場合は新しく作成する
        if steps.find_by_id(step[:id]).nil?
          steps.build(step_params)
        else
          # 既存のステップの場合は上書きする
          steps.find_by_id(step[:id]).update!(step_params)
          # 削除対象idリストから外す
          current_id.delete(step[:id])
        end
      end
      current_id.each { |id| Step.find(id).destroy! }
      update!(roadmap_params)
    end
    true
  rescue StandardError
    false
  end
```
</details>

以上、長文になりましたが、最後まで読んでいただき本当にありがとうございました...!!!!!!
まだまだ未熟でどこまで参考になるかはわかりませんが、どなたかのお役に立てれば幸いです。