🚀

【個人開発】 #買ってよかった が集まるSNS「pocitta(ポチッタ)」を支える技術

2023/09/25に公開
3

はじめに

2023年6月30日に #買ってよかった が集まるSNS「pocitta(ポチッタ)」 というWebサービスをリリースしました。

https://pocitta.jp/

この記事では、pocittaというサービスの概要とそれを支える技術スタックについてご紹介します。

Webサービス開発者の方々に何かしら参考にしていただければ幸いです。

どんなサービス?

pocittaは、買ってよかったものをシェアする専用のSNS です。

主な特徴は以下の3点です。

  • Amazonや楽天の商品ページURLを貼り付けるだけで商品情報を自動で取得 してくれて、簡単に投稿できる
  • 「○年○月の買ってよかったもの一覧」のようなページ(「まとめ」)も簡単に作れる
  • Amazonアソシエイト・楽天アフィリエイトと簡単に連携 できて、アフィリエイト活動の場としても活用できる

image

image

なぜ作ったのか

もともと買ってよかったものをシェアする文化が好きすぎた ので、素朴に「専用のSNSがあったらいいのに」と思って作り始めました。

実はpocittaの最初のバージョンは今から5年も前、2018年に一度作って身内限定で公開したことがありました。個人的に割と手応えを感じたので、もう少しちゃんと作り込んで正式版としてリリースしたいと考えたのですが、ちょうどこの頃から、前職での仕事が忙しくなったり、うつ病を再発したり、個人事業主として独立したりと色々なイベントが立て続けに起こり、バタバタしている間に4年ぐらいの時間が経過してしまいました。

そして今年の春頃から久しぶりに開発に再着手し、6月30日にめでたくリリースに漕ぎ着けたという次第です。

この辺りの、pocittaの誕生の裏側みたいな話はPR TIMES STORYさんに寄稿した以下の記事で詳細を語っていますので、もし興味がおありでしたらご参照ください。

https://prtimes.jp/story/detail/xzm4gYIdXNB

楽天ROOMを倒したい

ちなみに、現時点で明確に意識している競合サービスは 楽天ROOM さんです。

pocittaは、楽天ROOMと違ってAmazonの商品や任意のECサイトの商品も載せられる ので、まずはAmazon派の人たちにぜひ使ってもらいたいですし、楽天ROOMでアフィリエイト活動をしている人たちにもpocittaに乗り換えてもらえるように頑張っていきたいなと思っています!

サービスの構成と使用技術

というわけで、ここからはpocittaを支える技術について解説していきます。

サービス構成図

大まかな構成は下図のとおりです。

image

以下にレイヤーごとの詳細を解説します。

バックエンド

バックエンドはPHPで実装しています。Symfony というWebアプリケーションフレームワークと、SymfonyをベースとしてWeb APIの高速開発を可能にしてくれる API Platform というフレームワークを使っています。

API Platformは、PHP界隈で近年人気が高まっている新興のフレームワークで、まだ発展途上な部分も多いのですが、それを加味してもSymfony + API PlatformによるWeb APIの開発は以下のとおり非常に開発体験がよいです。

  • そもそもSymfonyはPHPにおける至高のWAFである(強い思想)
  • API Platformは、Symfonyアプリケーションに対して疎結合にWeb API(REST or GraphQL)としての振る舞いを追加できる
  • OpenAPIドキュメントを自動で生成してくれる (設定によって細かい調整も可能)

自分は今回のpocittaの開発に限らず、クライアントワークでもこの構成を多く採用しています。これからPHPでWeb APIを開発する方にはお勧めの構成です。

API Platformについては以下のZenn本で詳細にご紹介しているのでよろしければご参照ください。

https://zenn.dev/ttskch/books/a3800fc0912fbb

Amazonからの商品情報取得

pocittaでは、Amazonまたは楽天の商品ページURLを貼り付けるだけで、商品名や商品画像、価格などの情報を自動で取得できるようになっています。

Amazonからの商品情報取得は Amazon Product Advertising API(Amazon PA-API) 経由で行っています。

PA-APIのクライアントには thewirecutter/paapi5-php-sdk というライブラリを活用しています。

PA-APIのシンタックスをそのまま再現したようなシグネチャになっているので、以下のPA-APIのリファレンスを参照しながらコードを組み立てていけばそれほど迷わずに書ける感じです。

https://webservices.amazon.com/paapi5/documentation/get-items.html

参考までに、PA-APIから必要な商品情報を取得するためのコードはおおよそ以下のような形になります。

$response = $this->client->getItems(new GetItemsRequest([
    'partnerTag' => $tag,
    'languagesOfPreference' => ['ja_JP'],
    'itemIdType' => 'ASIN',
    'partnerType' => 'Associates',
    'marketplace' => 'www.amazon.co.jp',
    'itemIds' => [$asin],
    'resources' => [
        GetItemsResource::ITEM_INFOTITLE,
        GetItemsResource::OFFERSLISTINGSPRICE,
        GetItemsResource::IMAGESPRIMARYLARGE,
        GetItemsResource::IMAGESVARIANTSLARGE,
    ],
]));

$item = $response->getItemsResult()->getItems()[0] ?? null;

$url = $item->getDetailPageURL();
$title = $item->getItemInfo()->getTitle()->getDisplayValue();
$listing = $item->getOffers()?->getListings()[0] ?? null;
$price = $listing?->getPrice()->getAmount();
$price = $price ? intval($price) : null;
$imageUrls = [
    ...array_filter([$item->getImages()->getPrimary()?->getLarge()->getURL()]),
    ...array_map(fn (ImageType $image) => $image->getLarge()->getURL(), $item->getImages()->getVariants() ?? []),
];

$model = [$url, $title, $price, $imageUrls];

楽天からの商品情報取得

楽天からの商品情報取得は 楽天商品検索API 経由で行っています。

このAPIはパラメータをつけてGETするだけなので、専用のクライアントライブラリは使わず、普通に symfony/http-client でリクエストしています。

楽天APIのクオータは1秒1リクエスト で結構厳しいので、もしユーザーが増えたら 制限緩和を申請 する必要がありそうです。

フロントエンド/BFF

フロントエンド/BFFは Next.js v12で実装しています。(そのうちv13に上げてApp Routerに移行する意思はあります)

UIフレームワークは Chakra UI を使っています。(最近 shadcn/ui がとても気になっているので、そのうち移行する可能性があります)

APIクライアントには aspida を使っており、openapi2aspida によってバックエンドと型情報を共有しています。超絶に開発体験がよいです。

image

こんな感じでバックエンドAPIのすべてが型付けされて補完されます。

ちなみにaspidaの開発者は日本人のSolufaさんという方です。応援しましょう。

aspidaの使い方の詳細については、Solufaさんご本人が書かれている以下の記事をご参照ください。

https://zenn.dev/solufa/articles/getting-started-with-aspida

あとは同じくSolufaさんの pathpida も便利に使わせていただいています。

https://zenn.dev/solufa/articles/renewed-pathpida

他に、状態管理に TanStack React QueryRecoil を導入しています。Recoilはかなり限定的な範囲でしか使っていないので、より軽量な Jotai への移行を検討中です。

動的OGP

投稿やユーザープロフィールなどの画面は動的OGPに対応しており、あえて @vercel/og を使わない実装を採用しています。

背景や実装の詳細については以下の記事をご参照ください。

https://zenn.dev/ttskch/articles/c317b41935c617

インフラ

インフラはすべてPaaSに寄せていて、バックエンドは Render 、フロントエンド/BFFは Vercel でホストしています。また、認証には Supabase Auth を使っています。

Render

バックエンドは Render でホストしています。

Renderは、有料化されたHerokuに代わって無料または安価に利用できるPaaSの筆頭として注目を集めており(多分)、東京リージョンはまだありません(検討中)が、日本の最寄としてシンガポールリージョンが選択可能です。

pocittaでは、Renderの

  • Web Service (Webサーバー)
  • Background Worker (バックグラウンドジョブサーバー)
  • PostgreSQL (DBサーバー)

を使用しており、それぞれ以下のインスタンスタイプで運用しています。

サービス インスタンスタイプ 月額
Web Service Starter $7
Background Worker Starter $7
PostgreSQL Starter $7

各サービスのインスタンスタイプごとのスペックと料金は こちらをご参照 ください。

ちなみにGitHub ActionsからRenderにデプロイするためのActionは、以下の自作のものを使っています。

https://zenn.dev/ttskch/articles/33250dc0a5845c

Amazon S3

ストレージはS3を使っています。

PHPで外部ストレージを抽象化するためのライブラリとして thephpleague/flysystem を導入しており、S3との繋ぎ込みは league/flysystem-async-aws-s3 + async-aws/simple-s3 で行っています。

Symfonyへのインテグレートは以下のような形になっています。

# config/services.yaml
services:
  AsyncAws\SimpleS3\SimpleS3Client:
    arguments:
      $configuration:
        accessKeyId: '%env(AWS_S3_ACCESS_KEY)%'
        accessKeySecret: '%env(AWS_S3_SECRET_KEY)%'
        region: '%env(AWS_S3_REGION)%'
# config/packages/flysystem.yaml
flysystem:
  storages:
    default.storage:
      adapter: asyncaws
      options:
        client: AsyncAws\SimpleS3\SimpleS3Client # service id
        bucket: '%env(AWS_S3_BUCKET)%'
        prefix: '%env(AWS_S3_PREFIX)%'

これで、League\Flysystem\FilesystemOperator $defaultStorage でどこにでもDIできるようになります。

また、

  • DBからファイルを読み込んだときに自動でS3の 署名付き一時URL を発行してエンティティに持たせる
  • DBからファイルを削除したときに自動でS3からも対応するファイルを削除する

ために、Symfonyの Entity Listener を使って以下のような実装をしています。

<?php

declare(strict_types=1);

namespace App\EntityListener;

use App\Entity\File;
use Cake\Chronos\Chronos;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PostLoadEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

#[AsEntityListener(entity: File::class)]
class FileListener
{
    public function __construct(
        private readonly FilesystemOperator $defaultStorage,
        #[Autowire('%env(int:FILE_URL_LIFETIME)%')]
        private readonly int $temporaryUrlLifetime,
    ) {
    }

    public function postLoad(File $file, PostLoadEventArgs $event): void
    {
        $url = $this->defaultStorage->temporaryUrl(strval($file->getLocation()), Chronos::now()->addSeconds($this->temporaryUrlLifetime), [
            'get_object_options' => [
                'ResponseContentDisposition' => sprintf('attachment;filename=%s', $file->getName()),
                'ResponseCacheControl' => sprintf('max-age=%s', $this->temporaryUrlLifetime),
            ],
        ]);
        $file->setUrl($url);
    }

    public function preRemove(File $file, PreRemoveEventArgs $event): void
    {
        $this->defaultStorage->delete(strval($file->getLocation()));
    }
}

実際には postLoad() ではS3から取得した署名付き一時URLを symfony/cache 経由でRedisにキャッシュしているのですが(そうしないと、ファイルが読まれる度にS3に対してURLを発行する処理を要求してしまうので)、ここでは省略してご紹介しています。

Upstash

バックエンドのアプリレイヤーの一部キャッシング(外部APIの結果セットなど)に Upstash のRedisを使っています。

Japanリージョンもあるし、当面は Freeプラン で十分に回ります。

RenderにもRedisのサービスはあるのですが、データの永続性が保証されるPersistent Instanceは月$10のStarterプラン以上でないと使えない ため、RedisだけはUpstashを使っています。

Vercel

フロントエンド/BFFはNext.jsで実装していることもあり、脳死で Vercel でホストしています。

スペック的にはHobbyプランでも全然回ると思うのですが、Vercelの定めるCommercial Usage におそらく該当するため、Proプランに入っており、月額$20がかかっています。

ログインユーザーに依存しないレスポンスはすべて Vercel Edge Cache によってキャッシュして高速化しています。

Vercel Edge Cacheの導入手順は以下のスクラップをご参照ください。

https://zenn.dev/ttskch/scraps/73d7f83040341e

Amazon CloudFront

バックエンドサーバーの前段に Amazon CloudFront を配置して、APIレスポンスをエッジキャッシュすることで高速化しています。

REST APIの前段にCloudFrontを配置するための一般的な手順は以下のスクラップをご参照ください。

https://zenn.dev/ttskch/scraps/e37a50f2a2d8bf

また、API Platformで実装したREST APIの前段にCloudFrontを配置するにあたってバックエンド側で行った対処については以下のスクラップをご参照ください。

https://zenn.dev/ttskch/scraps/c7382ec1d1349b

Supabase Auth

ユーザー認証には Supabase Auth を使っています。5万MAUまではFreeプランで対応できます

Freeプランは1週間何もリクエストがないとプロジェクト自体が自動で一時停止され、Web UIから手動で再起動する必要があるため、極端にアクセスが少ないサービスを運用する場合は注意が必要かもしれません。

SupabaseといえばFirebase(Firestore)のRDB版として人気急上昇中のDBaaSですが、DBだけでなく認証やストレージのサービスも付随しています。

pocittaではこのうち認証サービスだけをIDaaSとして利用しています。

実は、pocittaは開発初期にはバックエンドが自前実装ではなくSupabaseを使っていたので、その名残で今も認証だけSupabase Authを使っているという経緯があります。

バックエンドを自前実装に切り替えたときに、あわせて認証も別のIDaaSに移行することを検討したのですが、いくつか試した結果、IDaaSとしてもSupabase Authが今のところ一番使いやすいという結論になり、一旦そのままになっています。

構成としては、

  1. フロントエンドでSupabsae Authから認証を受ける
  2. フロントエンドからバックエンドAPIへのリクエストに Authorization: Bearer {Supabase Authのアクセストークン} を添える
  3. バックエンドで、Authorization ヘッダーで受け取ったアクセストークンでSupabaseにログインする
  4. ログイン結果に応じてフロントエンドに対して適切な認可を行う

というふうになっています。

Supabase Authのクライアントライブラリとしては、フロントエンド側は @supabase/auth-helpers-nextjs、バックエンド側は rafaelwendel/phpsupabase を利用しています。

バックエンド側で利用している rafaelwendel/phpsupabase は、Supabaseのローカルエミュレーター 内のSupabase Auth環境には接続できない仕様なので、開発環境においてもステージング環境のSupabase Authを利用しなければならなかったり してちょっとイマイチなのですが、現状他に同様のライブラリが存在しないようなので仕方なくこれを使わせてもらっています。今後に期待。

まとめ

#買ってよかった が集まるSNS「pocitta(ポチッタ)」 というWebサービスを支える技術について簡単にご紹介しました。

もしサービス自体に興味を持っていただけたら、ぜひ覗いてみてください!そしてよろしければユーザー登録して何か投稿してみてください!🙏

https://pocitta.jp/

あとはこちらのポストの拡散にご協力いただけるのもとても嬉しいです🙏

https://twitter.com/ttskch/status/1674600586501836800

以上、pocittaを支える技術のご紹介でした!

GitHubで編集を提案

Discussion

sekigamisekigami

サイトを覗かせていただきました。
気になった点が2点ありましたので、一意見として述べさせていただきます。

1.ページを移動した後、ページの上部に戻った方がユーザビリティが上がると思います。
2.トレンドのページですが、2ページ目以降が存在しないのに、遷移ボタンが表示されていること。ちょっと分かり辛いと思います。

たつきちたつきち

ご意見ありがとうございます!2点とも修正しました!
(後者に関してはシンプルにバグでしたね、、見落としてましたご指摘ありがとうございました)

sekigamisekigami

修正の速度が速すぎてびっくりしました。
再度確認しましたが、すべて修正されておりました。
すごく見やすくなっていてよかったです。

私のような一意見を取り入れていただき、ありがとうございました。