0
0

GoとNext.jsでQiitaのような記事投稿サービスの実装してみた

Posted at

概要

現在GoとNext.jsを勉強しており、その一環でQiitaのような記事を投稿サービスを開発しています。

QiitaやGithubなどマークダウンでコンテンツを保存する機能があるサービスを参考に、自分なりにロジックを考えて実装してみたのでメモとしてまとめてみます。

詳細に書くと内容が多くなってしまいます。
簡略化して書いている部分や具体的なコードは省略して、基本的にはロジックをメインに記載させていただきます。🙇

※ Qiitaをリバースエンジニアリングして忠実に再現したわけではなく、動作を同じにすることを重視しており、ロジックは開発コストを抑えるために簡易的に実装しており考慮できていない部分もある可能性がありますので、そういった部分はコメントで教えていただけると幸いです。

仕様

システムアーキテクト

構成は鬼シンプルで、できるだけalways freeで使用が可能なGCPサービスを使っています。
CloudSQLだけ月1300円ほどかかってしまっています。🥲
理由としては、以下の2点です。

  • これから記事投稿以外にも色々機能を追加する予定のため、今後扱うデータが結構増えそうな気がしている
  • 全部GCPにしていた方が管理しやすいのではないかという浅はかな考え

完全無料にしたい場合は無料のDBホスティングサービスがたくさんあるのでそちらを使う方が良いかもしれないです。
バックエンドについてはCompute Engineを使ってもよかったのですが、工数的にコンテナイメージをアップロードするだけで簡単にデプロイができるCloud Runを採用しています。

スクリーンショット 2024-09-12 18.17.10.png

記事内容のデータ

記事のデータは大まかに以下の属性を持っています。

  • タイトル
  • 本文
  • 作成者ユーザID
  • 公開日
  • 公開ステータス
  • 作成日

本文のマークダウンの情報はmdファイルにしてCloudStorageに保管してあります。
その他の属性は、MySQLのデータベースのテーブルに格納されており、本文の情報の代わりにCloud Storage上のパスを保存するカラムを持っています。

Cloud Storageのディレクトリ構造

.
├── articles/
│   ├── ciEncIbn9Fn30aS0.md
│   └── ...
└── images/
    ├── kC09E4iobh03IMnb
    └── ...

articlesの中には16桁のハッシュ値が名前になったmdファイルが保存されており、全てのユーザの記事は同じ階層に保存されています。
imagesにも記事に使用されている画像が保存されており、記事に関わらず使われている画像は同じ階層に保存されています。
名前がハッシュ値になっている理由は、CloudStorageのファイルとDB上のデータをマッピングするために中間テーブルを設けなくても良いようにするためです。

実装

スクリーンショット 2024-09-16 18.01.43.png

● 記事作成

大まかな流れ
(1) ユーザがブラウザで記事を書く
(2) 保存ボタンを押した時にフロントからバックエンドに以下のデータを送信
   ・ タイトル
   ・ 本文のマークダウンの内容
   ・ 画像データ(画像を使用している場合)
(3) バックエンドで受け取った値元にDBやCloudStorageに保存

補足
記事に画像がある場合、「Cloud Storageのディレクトリ構造」の仕様上、1と3で、以下のような処理をおこう必要があります。

(1) ユーザがブラウザで記事を書く

  1. 16桁のハッシュ値を算出し、その画像の名前をハッシュ値に変更
  2. public配下に画像を保存
  3. 記事には![ハッシュ値](ローカルの画像パス)のような文字列を挿入

(3) バックエンドで受け取った値元にDBやCloudStorageに保存

画像の部分を以下のように変更

![kC09E4iobh03IMnb](/kC09E4iobh03IMnb.png)

![kC09E4iobh03IMnb](/api/article/image?name=kC09E4iobh03IMnb)

この処理は記事更新時のために行なっています。
具体的な理由は記事更新の部分で説明します。

● 記事の閲覧

大まかな流れ
(1) ブラウザから見たい記事の記事IDが渡される
(2) 記事IDから以下の情報をかき集めてフロントに返す
   ・ タイトル
   ・ 記事内容

補足
記事に画像が使われている場合、単にCloud Storageのマークダウンファイルを返してもパスの部分がフロントのエディターで表示していた時のものになっているため画像は表示されません。
そのため以下のようにバックエンドで記事内容の画像のパス部分を著名付きURL置き換える処理が必要です。

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"regexp"
	"strings"
	"time"
	"cloud.google.com/go/storage"
)

// 記事の画像パスを著名付きURLに置換
func ReplaceArticleContentWithSignedURL(articleContent string) (string, error) {
	imageNamePattern := `\!\[([a-z|A-Z|0-9]*?)\]\(.*?\)`
	imageNameRegexp, err := regexp.Compile(imageNamePattern)
	if err != nil {
		return "", err
	}

	matches := imageNameRegexp.FindAllStringSubmatch(articleContent, -1) // -1にする個数無制限
	if len(matches) == 0 {
		return articleContent, nil
	}

	ctx := context.Background()
    // GetCloudStorageBucketはコンテキストからCloud Storageのバケットを取得する自前の関数func(), error)
	bucket, clientDeferFunc, err := GetCloudStorageBucket(&ctx)
	if err != nil {
		return "", err
	}
	defer clientDeferFunc()

	singedUrlOption := &storage.SignedURLOptions{
		Scheme:  storage.SigningSchemeV4,
		Method:  http.MethodGet,
		Expires: time.Now().Add(12 * time.Hour),
	}

	imageUrlPattern := `\!\[[a-z|A-Z|0-9]*?\]\((.*?)\)`
	imageUrlRegexp, err := regexp.Compile(imageUrlPattern)
	if err != nil {
		return "", err
	}

	var targetStrings []string
	var newStrings []string

	for _, match := range matches {
		imageFileName := match[1]
		gcsFilePath := fmt.Sprintf("%s/%s", "images", imageFileName)
		signedImageUrl, err := bucket.SignedURL(gcsFilePath, singedUrlOption)
		if err != nil {
			return "", err
		}

		// ![](パス) -> ![](著名付きURL)の文字列生成
		matchString := match[0]
		imageUrl := imageUrlRegexp.FindAllStringSubmatch(matchString, 1)[0][1]
		rep := strings.NewReplacer(
			fmt.Sprintf("(%s)", imageUrl),
			fmt.Sprintf("(%s)", signedImageUrl),
		)
		newImageTag := rep.Replace(matchString)

		targetStrings = append(targetStrings, matchString)
		newStrings = append(newStrings, newImageTag)
	}
 
	// 記事の中身を置換
	rep := strings.NewReplacer(createArgForNewReplacer(targetStrings, newStrings)...) // https://pkg.go.dev/strings#NewReplacer 引数の情報より可変長で渡せる
	articleContent = rep.Replace(articleContent)

	return articleContent, nil
 }

途中で使用しているGetCloudStorageBucketはCloud Storageのバケットを取得する自前の関数です。
具体的に実装は以下に記載しています。

これでフロント側では、受け取った記事の内容の文字列を描画するだけで閲覧することができます!

● 記事の更新

大まかな流れ
(1)ブラウザで編集したい記事をクリック
(2)リクエストを受け取りバックエンドで以下の必要な情報をフロントに返す
   ・ 記事ID
   ・ 記事タイトル
   ・ 記事内容
(3)フロントでデータを受け取りマークダウンエディタに表示
(4)更新後は更新ボタンを押してエディターのデータをバックエンドに送信
(5)バックエンドで受け取ったデータからDBとCloudStorageのデータを更新

補足

記事閲覧では画像のパスを署名付きURLの置き換えて返していましたが、同じ方法で編集画面に記事データを返すと、署名付きURLは鬼長いので画像のパスでエディターの半分が埋まってしまいます。🥲

そのため以下のような方針で画像のパスを短くするようにしています。

① フロントに編集記事のデータを返す際に、使用されている画像のハッシュ値と署名付きURLをマッピングしたデータも返す

{
    "name": "1598955bd822af1b",
    "url": "https://storage.googleapis.com/..."
}

② 記事編集画面でバックエンドから記事データを取得した時に署名付きURLの情報をセッションに保存

// 記事情報取得
useEffect(() => {
    const fetchData = async (articleId: number) => {
        try {
            const res = await fetch(getApiEndpoint(`http://localhost:3000/edit/${articleId}`),{ method: "GET"});
            // 別ファイルにバックエンドからのデータを受け取るためのEditArticle型を定義
            const data:Model.EditArticle = await res.json()

            // 記事のデータを変数にセットする処理(省略)

            if (data.imageUrls.length > 0) {
                const now = new Date();
                const oneDayLater = new Date(now.getTime() + 24 * 60 * 60 * 1000);

                data.imageUrls.map(image => {
                    setCookie(`articleImageUrl_${image.name}`, image.url, {expires: oneDayLater});
                })
            }
        } catch (error) {
            console.error("データ取得に失敗しました:", error);
        }
    };

    fetchData(articleId);
}, []);

編集画面のpage.tsxでは'use client'を定義してるため、cookieをセットするためにcookies-nextsetCookieを使用しています。

③ フロントに以下のような画像を取得するAPIを実装

src/app/api/article/image/route.ts

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers'

export async function GET(req: Request){
  if (!req.url) {
    return NextResponse.json(
      { error: "Bad Request" },
      { status: 400 }
    );
  }
  const queryParam = (new URL(req.url)).searchParams.get('name');

  if (!queryParam) {
    return NextResponse.json(
      { error: "Bad Request" },
      { status: 400 }
    );
  }
  const imageName = queryParam

  const cookieStore = cookies();
  let signedURLCookie = cookieStore.get(`articleImageUrl_${imageName}`);
  if (!signedURLCookie) {
    return NextResponse.json(
      { error: "not found" },
      { status: 404 }
    );
  }
  let signedURL = signedURLCookie.value as string

  try {
    const res = await fetch(signedURL);

    if (res.status == 200) {
      const blob = await res.blob();
      const buffer = await blob.arrayBuffer();

      const response = new NextResponse(Buffer.from(buffer));
      response.headers.set('content-type', 'image/png');
      return response;
    } else {
      console.log('Filed to get image')
      throw new Error("Failed to get image");
    }
  } catch (error) {
    if (error instanceof Error) {
      return NextResponse.json(
        { error: "Something went wrong" },
        { status: 500 }
      );
    }
  }
}

これにより記事の作成時に以下のように画像のパスを変更する処理を入れたと思いますが、作成時のマークダウンファイルをそのまま表示するだけで記事編集画面のマークダウンエディタで画像が表示されるようになります。

![kC09E4iobh03IMnb](/api/article/image?name=kC09E4iobh03IMnb)

● 記事の削除

大まかな流れ
(1) ブラウザから削除したい記事を選択して削除ボタンを押し、以下のパラメータを送信
   ・ 記事ID
   ・ ログインユーザID
(2)バックエンドでは記事IDかつ作成者がログインユーザの記事があるか検索
(3)記事がある場合は削除して204、記事が無い場合は404などのエラーをフロントに返す

補足

記事に画像が使われている場合、バックエンドで記事データを削除する際に以下の処理も必要になります。
・ 記事内容から使用されている画像のハッシュ値を取得(![ハッシュ値](パス)のハッシュ値の部分)
・ 取得したハッシュ値と対応する画像をCloud Storageから削除

最後に

正直、今回は小規模開発というのもあり効率やセキュリティ面については足りない部分や改善する点がまだたくさんあると思います...

本家の方はもっとそういった面を重視した実装になっていると思うので、日々使い続けていく中で気づいたことがあれば、自分のサービスにも反映させていきたいと思います!🫰

ですが、実装をする上でgoやNext.jsについて理解を深めることができました!
まだAppRouterのNext.jsやgoの仕様など分からない部分がたくさんあるので、これから機能の追加や他のサービスの実装をする中で色々と知識をつけていきたいです!

0
0
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
0
0