はじめに
たつや、と申します。
少し実装しては止めてを繰り返していたサービスをようやくリリースすることができました…!
MeGUという名前のサービスです。
Me(私)とU(あなた)をG(ゲーム)で繋ぐことをサポートする、という意味があります(Gを間に置くことで繋ぐ的な意味合いにできないかなと)。もっとカッコいい名前にしたかったのですが思いつきませんでした。(読み方は未だに悩んでいます。「メグ」がいいのか「ミーグ」がいいのか他に何かあるのか…。)
▼ MeGU
▼ 私のページ
1. 背景
私はゲームが大好きです。週末はリア友やネッ友とよくゲームをしています。
今でこそ固定のメンバーと遊んでいますが、昔は(スカイプチャンネルというサービスで)募集をかけて知らない人と一緒に遊んだりしていました。基本的には楽しく遊べるのですが、私と性格が合わない人(勝ちにこだわったり、敵味方問わず暴言を吐くような人)がたまーーにいて、楽しく遊べないときがありました。「初心者が〇〇(キャラ名)使ってんじゃねーよ!!」なんて言われたりもしました。
こういった事象は今でも起きているらしいです。配信者の友人がいまして、その周りの人たちがX(旧Twitter)で募集をかけたら変な人が来た、みたいなことが度々あったようです。
長くなりましたが、実際に私自身楽しくなかった体験をしたこと、今も同じ問題があるんだなーということから、相手がどういう人なのか事前に把握できるようなサービスがあればいいのでは?と思い開発をしました。
2. サービス概要
このサービスはタイトルの通り一緒に楽しく遊べる人かどうかの判断をサポートするサービスです。
このサービスでは自分の性格(おとなしいタイプか、わいわいするタイプか)やゲームのプレイスタイル(慎重なタイプか、ガンガンいくタイプか)、朝型か夜型などの情報を登録することができます。
ゲームそのものについても、プレイしているゲームとそれぞれの頻度や各ゲームの自己紹介カードを登録することができます(これによって自己紹介カードをこのサービス上で一元管理できます)。
これらを以て一緒に楽しく遊べるかを判断してもらうことになります。
自己紹介カード
そのゲームにおいてどのようなキャラクターを使用するか、ランクはいくつか、などを記載したカードです。
2. 使い方
主にSNSで募集?、応募?の際に自分のページのリンクを貼り付けることを想定しています。
以下、イメージです。
このサービス上でチャット等によるユーザ同士のコミュニケーションはできません。(ゲーム関連ということもあり、サービス上でユーザー間のコミュニケーションを可能にすると晒し晒されなど面倒なことになりそうな気がしたためです。)
3. 技術スタック
前々からAWS
を触ってみたいと思っていたので、これを機に挑戦しました。と言ってもEC2
などよく聞くところは直接触っておらず、時間的な制約があったこともあり、このサービスを作る上で何を選定すれば最も簡単にリリースできるかを調べた結果以降のようになりました。
3-1. フロントエンド
★ React
★ Material UI
★ TailwindCSS
何度か触ったことがあったので今回も使おう、程度の理由で選定しました。実際にこれらを使って小規模のサービスをリリースをしてみています。
今回TypeScript
を採用しませんでした。勉強自体はしていたので基本的な読み書きはできますが、今回はAWS
を使ってみることがメインで他のところで極力負荷をかけたくなかったためです。
▼ Wikimiru
書いてある通りですが、ランダムに流れてくるWikipediaの情報をただ眺めるだけのサービスです。
▼ ほめちゃん
ほめちゃんという女の子が褒めてくれます。ただそれだけです。
3-2. バックエンド
★ Next.js
React
だからというのとログイン機能を実装するうえでNextAuth
が使いたかったため選定しました。以前調べて本にまとめていたので、実装の時間削減が目的でした。
実際には色々変わっていたのであまり使い物になりませんでしたが、Udemy
でNextAuth
だけではなくNext.js
での開発を全体的に解説してくださっている方がいらっしゃいましたので非常に助かりました。
▼ 以前に書いた本
古いバージョンのものですが、今でもたまにいいねされるので部分的にでも有効なものなのだと思います。
▼ 参考させていただいた動画
わかりやすく順を追って解説してくださっているので、必要な実装について理解しつつ簡単に実装することができました。
3-3. DB
★ DynamoDB
NextAuth
に対応していたため選定しました。NoSQL
がよかったわけではありません。過去にmongoDB
も使ったことがありますが慣れないです。
ストレージ
★ S3
サービスの中で自己紹介カード(画像)を管理する必要があるので選定しました。
3-4. サーバー
★ Amplify
あれこれといい感じにやってくれるとのことだったので選定しました。当初EC2
にデプロイすることを検討していたのですが、VPC
やら何やら1つを理解するために他も色々理解する必要があり、諸事情により時間に制約があったため断念しました。将来的にはきちんと勉強します。
3-5. ドメイン
★ Route 53
お名前ドットコムの方が安かったので、そちらでとってしまおうかとも思ったのですが折角ならAWSで完結させたかったのでこちらで取得しました。購入さえしてしまえば設定はとても簡単でした。
▼ 参考にさせていただいた記事
4. 機能(画面)紹介
プロフィールを見る、登録内容を編集する、それだけです。そのため大した機能はないですが一部紹介させてください。
4-1. ユーザ画面
設定画面から登録したゲームや性格が表示・確認ができます。「★」はゲームごとのプレイ頻度を表しており、クリックすることでフィルタされます。
また、コピーアイコンをユーザアイコンの上部と登録しているゲームの内、自己紹介カードが登録されているものに設定しています。これらをクリックすることで、簡単に自分のページや自己紹介カードをSNSに張り付けて連携することができます。
4-2. 設定画面
ゲーム設定としてプレイするゲームと頻度、そのゲームの自己紹介カードを登録することができます。
パーソナリティ設定として、年齢性別、VC有無や性格等を登録することができます。
4-3. 404エラー画面
NotFound関数なるものがあるようで、これを呼び出すだけでnot-found
という名前で実装したファイルを呼び出すことができます。画面はChatGPTくんに作ってもらいました。
コードと画面
const NotFoundPage = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div className="bg-white p-10 rounded-lg shadow-md text-center">
<h1 className="text-4xl font-bold text-gray-800 mb-4">404</h1>
<p className="text-lg text-gray-600 mb-6">お探しのページが見つかりませんでした。</p>
<Link href="/" passHref>
<Button variant="outlined">HOME</Button>
</Link>
</div>
</div>
);
};
4-4. 500エラー画面
404画面と同じ振る舞いをします。error.js
という名前で作成するとエラー画面として呼び出されます。同じくChatGPTくんに作ってもらいました。
コードと画面
const ErrorPage = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div className="bg-white p-10 rounded-lg shadow-md text-center">
<h1 className="text-4xl font-bold text-gray-800 mb-4">500</h1>
<p className="text-lg text-gray-600 mb-6">指定されたページが表示できませんでした。</p>
<Link href="/" passHref>
<Button variant="outlined">HOME</Button>
</Link>
</div>
</div>
);
};
5. 実装備忘録
AWS
を触るのが初めてだったり、気づいたらApp Router
なるものが存在していたり、結構詰まるポイントがありました。将来同じ轍踏まないように備忘録として残します。
5-1. NextAuth
今回はゲーマー関連サービスなのでDiscord
を使っています。AWS
を触ってみたかったこともありアダプターにはDynamoDB
を採用しています。
サンプルコード
const config = {
credentials: {
accessKeyId: process.env.AUTH_DYNAMODB_ID,
secretAccessKey: process.env.AUTH_DYNAMODB_SECRET,
},
region: process.env.AUTH_DYNAMODB_REGION,
};
export const client = DynamoDBDocument.from(new DynamoDBClient(config), {
marshallOptions: {
convertEmptyValues: true,
removeUndefinedValues: true,
convertClassInstanceToMap: true,
},
});
export const authOptions = {
secret: process.env.NEXTAUTH_SECRET,
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
],
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60,
},
adapter: DynamoDBAdapter(client, {
tableName: "Sample",
partitionKey: "pk",
sortKey: "sk",
indexName: "index",
}),
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
return session;
},
},
};
5-2. DynamoDBの操作
ユーザテーブルからデータを取得する場合は以下のような実装になります。0からの学習だったため、たったこれだけなのに時間がかかってしまいました。
サンプルコード
export async function GET(response, { params }) {
const dynamoParams = {
TableName: "User",
KeyConditionExpression: "userId = :userId",
ExpressionAttributeValues: {
// ディレクトリ名が[userId]となっているため、param.userIdとなる
":userId": params.userId,
},
};
try {
const response = await client.query(dynamoParams);
return NextResponse.json(response.Items[0]);
} catch (e) {
console.log(e);
}
}
インサートとアップデートについては処理が異なるようで、インサートではput
が使用されてアップデートではupdate
メソッドが使用されるようです。そのため、一度データを取得してヒットした場合はupdate
を、ヒットしない場合はput
を実行するような処理にしないといけなかったです。ざっくり以下のようになります。
サンプルコード
export async function POST(request, { params }) {
// データを取得しておく
const queryResponse = await client.query(dynamoParams);
// 取得結果の有無でどちらを流すか判断
if (response.Items.length > 0) {
const updateResponse = await client.update(dynamoUpdateParams);
} else {
const putResponse = await client.put(dynamoPostParams);
}
}
5-3. S3へ自己紹介カードのアップロード
あまり時間がかかる想定ではなかったのですが、Bodyに設定した画像がバックエンドで処理できずアップロード処理だけで結構時間がかかりました。App Router
であることが頭からすっぽり抜けてしまっていました。
以下サンプルコードになります。
サンプルコード
const handleUpload = async (index, file) => {
// アップロード処理の前にファイル拡張子を確認して不正なものははじくようにする
const extension = file.name.split(".").pop().toLowerCase();
if (!allowedExtensions.includes(extension)) {
setIsExtentionErrorOpen(() => true);
return;
}
// ロードのぐるぐる
setIsUploading(() => true);
// フォームデータとして送信
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/card`, {
method: "POST",
body: formData,
});
const cardResponse = await response.json();
// S3にアップロードした結果、登録されたパスと名前を取得して登録
gameMap[index].card.path = cardResponse.Location;
gameMap[index].card.name = cardResponse.Key;
} catch (e) {
console.log(e);
}
// ぐるぐる終了
setIsUploading(() => false);
};
export async function POST(request, { params }) {
// formData受け取り
const formData = await request.formData();
const imageInfo = formData.getAll("file")[0];
try {
const upload = new Upload({
client: s3Client,
params: {
Bucket: process.env.S3_BUCKET_NAME,
Key: `${Date.now()}-${imageInfo.name}`,
ContentType: imageInfo.type,
Body: imageInfo,
},
});
const cardInfo = await upload.done();
return await NextResponse.json(cardInfo);
} catch (e) {
console.log(e);
return NextResponse.json(e);
}
}
余談ですが削除する場合は以下のようにします。この時Key
情報としてはディレクトリ名+ファイル名の形になり、バケット名は含めません。
const s3DeleteParams = {
Bucket: process.env.S3_BUCKET_NAME, // S3バケット名を指定
Key: gameInfo.gameMap[i].card.name, // ディレクトリ名+ファイル名
};
await s3Client.send(new DeleteObjectCommand(s3DeleteParams));
5-4. Google Analytics
以下コマンドを入力してインポートしたのち、レイアウトファイルに追記するだけのようで、とても簡単でした。実装してデプロイした後にGoogle Analytics
上で結果の確認ができるので、それより反映されることを確認して終了です。
npm i @next/third-parties
// ※下記のリンク先より引用しています
import { GoogleAnalytics } from '@next/third-parties/google'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
<GoogleAnalytics gaId="G-XYZ" />
</html>
)
}
▼ 参考にさせていただいたサイト
5-5. 画像のキャッシュ利用
ゲームのサムネイルやユーザのアイコン以外に装飾用の画像をいくつも使用しています。毎度リクエストを投げているといくらお金がかかるかわかりませんので、キャッシュを利用するようにしました。
以下サイトの通りですが、Image
タグのsrc
にpublic
配下の画像パスを記載するのではなく、モジュールを設定してあげます。
▼ 参考にさせていただいたサイト
6. リリース前作業
最終確認として、まず以下チェックリストの内容を順々に確認・消化していきました。
どのように対応したのか、将来の自分用にいくつか備忘録として残しますので参考にしていただけますと幸いです。
▼ チェックリスト
今回くらいの規模のリリースは初めてでわからないこと(正直知らなかったこと)が多かったので、非常に助かりました。ありがとうございます。
6-1. Cookie
開発者ツールのアプリケーション > Cookie
から確認した限りでは適用されていたので特に対応していないです。何かしらがいい感じにやってくれていたのでしょう、ということにしています。
6-2. レスポンスヘッダ
以下に従って設定しただけです。設定後にAPIをコールしてみて適用されていることを確認しました。
▼ レスポンスヘッダの設定について
6-3. SEO対策
6-3-1. 実装
以下のようにすることで簡単に実装できました。
サンプルコード
// 例
/** @type {import("next").Metadata} */
export const metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL),
title: "MeGU",
description: "MeGUでにはゲーマー同士のつながりをサポートして、「より楽しくより気軽に」オンラインプレイができるようになればという想いが込められています。",
applicationName: "MeGU",
authors: "たつや、",
keyword: ["MeGU", "ゲーマー", "ゲーミング", "gamer", "gaming"],
creator: "たつや、",
icons: `${process.env.NEXT_PUBLIC_BASE_URL}/image/image.svg`,
openGraph: {
type: "website",
siteName: "MeGU",
title: "MeGU",
description: "MeGUでにはゲーマー同士のつながりをサポートして、「より楽しくより気軽に」オンラインプレイができるようになればという想いが込められています。",
images: `${process.env.NEXT_PUBLIC_BASE_URL}/image/image.png`,
},
twitter: { card: "summary_large_image", images: `${process.env.NEXT_PUBLIC_BASE_URL}/image/image.png` },
};
共通的な内容であればlayout
に実装することで適用されます。必要に応じて各ページで実装することでマージされますが、openGraph
については一部だけマージといった動きはされないため、全て実装するか・実装しないかどちらかにする必要があります。
以下例では、openGraph
は中の要素単位ではなくオブジェクト丸ごとマージされるためtitle: 'Blog'
のみ適用された状態となります。
// 下記のリンク先より引用しています
// layout実装
export const metadata = {
title: 'Acme',
openGraph: {
title: 'Acme',
description: 'Acme is a...',
},
}
// あるpageファイル実装
export const metadata = {
title: 'Blog',
openGraph: {
title: 'Blog',
},
}
▼ マージについて
6-3-2. 有用な設定をするために
リリースして間もないため、またSEOは長期的な戦いだと思っているので何ともですが、以下参考にしていました。
▼ 参考にさせていただいたサイト
6-4. 不要なモジュール確認
以下コマンドを叩くだけです。ヒットしたら削除します。
// 確認
npx depcheck
// 削除
npm remove ●●
7. マネタイズ
お金という形でサービスの有用性が見えますし、何よりモチベーションにつながるので絶対に考えた方がよいです。まだリリースしたばかりなので動けていませんが、何かしらの広告を埋め込みたいなと思っています。ランニングコスト分を稼ぐことが目標です。
ただ最近色々なサイトで広告がベッタベタで不快に感じることが多くなったので、同じようになることだけは避けたいです。
8. コスト
リリース間もないので、現時点で公開できるのは開発にかかった費用ですが、以下の通りです。これからどれくらい膨れ上がるのか心配です。8月に跳ね上がっていますがRoute 53
のドメイン購入代になります。また、9月分については9/20時点の金額になります。
申請をして300ドル?のクレジットをいただきました。だいぶ気持ちに余裕ができました。
月 | コスト |
---|---|
6月 | 1.9$ |
7月 | 2.09$ |
8月 | 17.03$ |
9月 | 0.52$ |
9. 改善点
(利用度合いによって)今後直していくという決意を込めて、現在確認できている改善点・バグを晒します。
9-1. 設定画面の三点リーダーで遷移した場合に下から始まる
設定画面自体は共通で設定内容に応じてコンポーネントの切り替えを行っており、これによる問題だと認識しています。簡単にはコンポーネント切り替え後にトップへ遷移するような実装をすればいいのかな?と思っています。
9-2. アイコンが更新できない
認証時にNextAuthによってDiscordのアイコンが登録されるので、それを名刺画面やヘッダのアイコンとして使用しています。Discord側でユーザがアイコンを変えると登録しているパスから取得できなくなってしまいます。
これはサインアウトしても変わらず、今のところ退会するしかないです…。結構大きいので直してからリリースするべきだったとは思いますが、見当つかなかったので先にリリースしてしまいました。折を見てとりあえずDiscord側のAPIで何かないか見てみようと思います。
9-3. お問い合わせ画面
google form
を使用しています。受け付けは簡単ですが、いただいた質問等に回答できないところが難点です。今後、自前のフォームを実装する等何かしらの形で改善を考えています。
9-4. ゲーム追加
ゲームごとにゲーム名を利用する上でのガイドラインをきっちり読んで追加する必要があります。この類に疎いこともあり、グレーに見えるゲームがいくつかあり取り込みを断念していたりします。
継続的に・自発的に調べて取り込むのもそうですが、お問い合わせ画面から追加依頼をいただけるようになっているので、そちらからも追加をしていきたいなと思っています。
9-5. ゲームアイコン追加
できれば各ゲームの既存アイコン的なものを利用したいのですが、規約的に無理だったので一旦テキストを画像化したものを使用しています。正直ダサいなーと思っています。
将来的には各規約に触れない形でもう少しリッチなものにしたいなと思っています(似たものではないけどそのゲームだということがパッと見でわかるようなものがいいのですが、そんなの作れるんですかね…)。今回は時間的な制約があったので、そこまで挑戦することはできませんでした。
9-6. モバイルの時ゲームプルダウンが正常に動作しない
完全にバグです…。原因がつかめていないです…。なんでだ…。
10. その他参考
★ イラスト
参考とは違うかもしれませんが好みのイラストでしたので、いくつも利用させていただきました。
おわりに
飽き性なのと「本当にこれでいいのか…使ってもらえるのか…」と悩む時間が長く、結構時間かかってしまいましたが、やっとリリースができました。とても嬉しいです。
個人が作った小さく簡素なサービスですが、このサービスを利用してくださる方が少しでもゲームで嫌な思いをしないようになってくれたらいいなと思っています。
また、この記事がどなたかの参考になれば嬉しいです。