🤸‍♂️

【個人開発】「作りたいものがない」を少し解決するWEBサービスを作りました

2022/05/06に公開
2

概要

「夢色水車」という Twitter API v2 を使用した Twitter の市場調査サービスを作りました。
https://ys.7oh.dev

2023/02/09 追記

Twitter API の仕様変更に伴い本サービス「夢色水車」の更新を停止しました。
リリースからこれまでたくさんのアクセス本当にありがとうございました 🙇

サービスの更新は停止しましたが最後に Github にソースコードを公開しました。
これから Twitter API を使い始める方、Go や Next.js を学習している方に少しでも助けになればと思います。
使い方や詳細は各 README.md に記載しています。ライセンスの範囲でご自由にお使いください。

フロントエンド:

https://github.com/7oh2020/ys-front

バックエンド:

https://github.com/7oh2020/ys-back

開発のきっかけ

みなさんは今、作りたいものはありますか?

Twitterを見ていると、「ポートフォリオ作成や個人開発をしたいけれど作りたいものがない」「プログラミングの基本を終えたあと何をしたらいいか分からない」というエンジニアさんをよく見かけます。
自分もプログラミングを学び始めた頃そういう悩みがあったのでとても共感できます。

そして同じくツイッターではたくさんの「こんなアプリあったらいいな」「こんなサービスあればいいのに」という非エンジニアさんの要望ツイートであふれています。

この2つをうまくマッチングしたらどちらの悩みも解決するのでは?と思ったのが開発のきっかけでした。

コンセプト

アプリやサービスなどのプロダクトは何らかの課題や問題を解決するためにあります。
システム開発においては、まず解決するべき課題を設定してからそれを解決するために機能を考えます。

もし課題よりも先に機能を作ってしまうと、「何のための機能なのか」「誰のための機能なのか」が分からなくなってしまいます。
せっかく作るなら人に使ってもらえるものを作りたいですよね。

解決するべき課題はこの世界のどこにでも溢れていますが、一人ではなかなか気づきにくいです。
課題を発見する一番良い方法は、様々な立場の人の悩みや考えに耳を傾けることです。

様々な立場の人の悩みや考えに触れる最適なツールが、Twitter などの SNS です。
特にツイッターは 2020 年時点で日本人の約 4 割が利用しています。(総務省調査)
最近はイーロン・マスク氏による買収でさらに注目を集めていますね。

実際にツイート検索してみると毎日たくさんの要望=「あったらいいな」が投稿されていることが分かります。

夢色水車」は Twitter から「こんなのあったらいいな」「こんなのあればいいのに」という要望ツイートだけを一覧表示する WEB サービスです。
関係のないツイートは発想の妨げにならないように極力除外しています。
ツイートはすべてアプリやサービス、ゲームやイベントなどでタグ付けされているので目的に合わせてフィルタリング可能です。

課題の発見や市場調査、発想のトレーニングなどに是非ご活用ください。

プロダクト名について

【夢色水車】
ツイートを川の流れに例えて、それをエネルギーに変える水車を連想しました。
最近はローマ字表記のサービス名が主流ですが、あえて漢字にして差別化を狙っています。

技術スタック

  • フロントエンド: Next.js(SG), TypeScript, Tailwind CSS
  • バックエンド: Go, Echo
  • データベース: PostgreSQL
  • PaaS: Heroku, Cloudflare

SG を採用した理由

今回のプロダクトではリアルタイムなデータ更新が不要なため Next.js の Static Generation(SG)を採用しました。
SG はビルド時に静的 HTML を作成するため CDN によるキャッシュが可能です。

データに関しては、ビルド時に SG 用の関数内でバックエンド API を呼び出して、それをもとに各ページのパスと Props を作成できます。
バックエンド API とデータベースはビルド時にしかアクセスされないため予め使用量の見積もりが可能です。
リージョンやスペックも特に気にしなくて良いのでバックエンドは Heroku のフリープランを採用しました。

ダイナミックルーティングによるページの自動生成

ページに関しては、Next.js のダイナミックルーティング機能によりページの自動生成が可能です。

Next.js のページは基本的に pages ディレクトリ配下に置かれた.jsx, .tsx ファイルがそのままパスとして認識されます。
例えば「pages/about.tsx」ファイルは「/about」でアクセス可能です。

ファイル名やディレクトリ名に[パラメータ名]を使用するとパスがパラメータ化され、後述する SG 用の関数内でパラメータに値をマッピングできるようになります。
SG 用の関数は以下の2つです。

  • getStaticPaths():パスを生成してパラメータにマッピングします。生成したパスは getStaticProps()関数に渡されます。
  • getStaticProps():パスのパラメータを受け取れます。ページコンポーネントに渡すための Props を生成します。

例えば「pages/tag/[tag]/page/[page].tsx」というファイルを作成して以下のようなコードを記述すると、値がパラメータにマッピングされ「/tag/1/page/1」「/tag/1/page/2」…のようにアクセスできるようになります。
また、getStaticProps()関数内で tag と page をパラメータとして受け取ることができるのでその値を使用してバックエンド API にリクエストできます。
作成された Props はページコンポーネントに渡されるので、あとはいつものように画面に表示するだけです。

以下のコードはダイナミックルーティングのサンプルです。

pages/tag/[tag]/page/[page].tsx
import TweetCard from 'components/TweetCard';
import type { GetStaticPaths, GetStaticProps, GetStaticPropsContext, InferGetStaticPropsType, NextPage } from 'next'
import { responseSymbol } from 'next/dist/server/web/spec-compliant/fetch-event';
import { TweetIndexResponse } from 'types/Tweet';

type Props = InferGetStaticPropsType<typeof getStaticProps>;

export default function Index({ tweets }: Props) {
  return (
    <main>
      {tweets && tweets.map((tweet) => <TweetCard tweet={tweet} />)}
    </main>
  )
}

// SG時のパス生成
export const getStaticPaths: GetStaticPaths = async () => {
  // サンプルのため直書きしているが、通常はここでバックエンドから総件数などのメタ情報を取得してpathsを生成する
  return {
    paths: [
      { params: { tag: '1', page: '1' } },
      { params: { tag: '1', page: '2' } },
      { params: { tag: '2', page: '1' } },
      { params: { tag: '2', page: '2' } },
    ],
    fallback: false,
  };
};

// SG時のProps生成
export const getStaticProps = async (context: GetStaticPropsContext) => {

  // getStaticPaths()関数からパスのパラメータを受け取る
  const { params } = context;

  // パラメータを使用してバックエンドAPIへリクエストする
  const resp = await fetch(`https://****.com/tweet/index?tag_id=${params?.tag}&page=${params?.page}`);
  const tweets: TweetIndexResponse = await resp.json();

  // ページコンポーネントに渡されるPropsを生成する
  return {
    props: {
      tweets: tweets.items,
    },
  };
}

ビルドとデプロイの自動化

生成された HTML はデプロイ後に Cloudflare CDN から高速に配信されます。
Cloudflare Pages の無料プランは月に 500 回までビルド可能です。さらにリクエストと帯域幅に制限がないというのがありがたいですね。

Cloudflare Pages は通常 Github に Push されたタイミングでビルドがトリガーされますが、Deploy Hooks というデプロイ用の Webhook を使うと任意のタイミングでトリガーできます。
これを Cloudflare Workers から CRON トリガーすることで、ビルドが定期実行され常時最新のデータがページに反映されます。

バックエンドでツイート取得

ツイートの取得に関しては、Go の net/http パッケージを使用して Twitter Search API v2 をリクエストしています。
その際、最後に取得したツイート ID を since_id パラメータに指定して新しいツイートのみを取得しています。

		target := fmt.Sprintf("https://api.twitter.com/2/tweets/search/recent?tweet.fields=created_at&user.fields=profile_image_url&expansions=author_id&max_results=%d&query=%s", count, keywords)
	if id != "" {
		target += fmt.Sprintf("&since_id=%s", id)
	}

レスポンスを見ると、ユーザー情報の扱いが以前のバージョンと変わっていて最初は混乱しました。
ユーザー情報は author_id と includes.users とを紐付ける必要があるので対応する構造のエンティティを作成しました。

その他工夫した点

  • スモールスタートで確認しつつ OK なら実装を進めていきました。最初は公式のツイート埋め込みを検討していましたがパフォーマンスの関係で SG を採用しました。
  • 以前から Twitter API v2 を活用したプロダクトを作りたいと思っていました。アプリケーション単位なら Bearer Token により以前より手軽に始められるようになりました。
  • バックエンドはレイヤードアーキテクチャで開発しました。テスタビリティが高くて影響範囲の特定もしやすいです。レイヤードアーキテクチャについては過去に記事を書いています。→【Go】テストまで書いて理解するレイヤードアーキテクチャ
  • CSS に関しては Tailwind CSS3 を使用しています。やはり React のコンポーネントと相性が良いです。バージョン 2 と多少設定が異なるので注意が必要です。Next.js + Tailwind CSS についても過去に記事を書いています。→【TypeScript】Next.js + Tailwind CSS を使用してさくっと SPA を作る方法
    • すべて無料枠で収まっているので運用コストは月額 0 円です。Cloudflare は上位プランの価格も良心的なのでもしスケールした際も安心ですね。

まとめ

以前から何かコンセプトを込めたプロダクトを作りたいと思っていたので無事に完成できて良かったです。

今回のように単一方向のアプリならパフォーマンスや状態管理などの悩み事も少なく済みます。
運用コストに関しても、ありがたいことに無料からスモールスタートできるので、みなさんも恐れずどんどん個人開発に挑戦してみてほしいです。

この記事が誰かの学びになれば幸いです。
ここまで読んで頂きありがとうございます!

Discussion

ちぇんちぇん

いいね数でのフィルタや、単語での検索ができるといいですねえ