LoginSignup
271
161

More than 3 years have passed since last update.

声を売買できるプラットフォームを作った話

Last updated at Posted at 2019-05-19

個人開発で声を売買できるマーケットプレイスを作ったので
そのときに苦労した話とどのような経緯で作ったのか公開します。

vov - 声から生まれるマーケットプレイス
https://smartvov.com/
Twitter運用アカウント(フォローしていただけると泣いて喜びます:joy:
https://twitter.com/smartvov

スクリーンショット 2019-05-19 13.44.52.png

今回リリースタイミングで
KANEさん主催のpodcastの三日月ラジオというチャンネルに出演させていただきました。
そこでもvovの開発秘話を語らせていただきました。
結構長いので時間があるときや、通勤通学などの時間帯等にお聞きください(宣伝)

三日月ラジオ前篇・後編
https://anchor.fm/crescentradio/episodes/ep1teradonburi-e42mlf

どんなことができるの?

・ウェブ上、スマホ上でそのまま録音ができ、録音後の声をトリミングできる
・カテゴリ別に声コンテンツを公開できる
・販売者登録すれば、年会費無料で声コンテンツの販売ができる(声コンテンツが売れたときの手数料としてプラットフォームが徴収する形式のビジネスモデル)
・クレジットカード決済で有料音声を購入できる

なぜ作ろうと思ったのか?

声なら誰でも出せるし、幅広いジャンルで利用用途がありそうと感じたことです。
nanaradikoVoicyといった音声、音楽のプラットフォームはあったのですが、声コンテンツをそのまま売買できるプラットフォームはなさそうだと思ったので作ろうと思いました。
アイディア自体は去年の夏ぐらいから実はあったのですが、実際に着手し始めたのは今年の1月からです。

個人開発のツラミは休日プログラミングになることが多く、主にモチベーションの維持なのですが、
2019年1月から開催されているconnpassにて3monthsServiceという
三ヶ月でサービスを個人開発しようというイベント見つけて参加させてもらってモチベーションを維持させてもらいました。
このイベントで@akahori_ss さんのようなサービスも生まれています。
Webの仕事をしていない人が個人Webサービスを作ってみて得られた知見

使っている技術

・ReactJS(+Redux)
・WebAudio API
・Firebase
・Stripe Connect
・Parcel

ReactJSは個人的になれているのと、Component化して綺麗に作れるから
Firebaseを選んだのはAWSだと運用がしんどそうなのと導入から運用まで個人で作るレベルならばお手頃感とスケールしやすさが一番あるため採用しました。
Stripe Connectはエスクロー決済を実現するために使用しています。
webpackではなく、parcelを使っているのは、webpackの設定がめんどくさかったのと後でも乗せ替えられると思ったからです。(ただし、リソース周りのパス指定に関しては色々めんどくさいことがわかった)
結局リリースビルドまでParcelから乗せ替えずにできたので、スピード開発するときには選択肢としては全然有りかなとも思いました。

難しかったこと&苦労したこと

主に苦しんだのがモバイルレイアウトを使いやすさを考えて大幅にアプリぽいレイアウトにしたのと
WebAudio周りとstripe connectの決済周り、Firebase独特の仕組み周りです。
今回、録音してmp3化してトリミング、アップロードを全部クライアントサイドやりました。
WebAudioAPIを駆使してフロントエンドでmp3エンコード、トリミング処理をしています。音や動画ファイルのマルチメディアに関してはバックエンドで処理してしまうと通常は変換処理に大量のリソースとお金がかかってしまうからです。(当然そんなお金は個人開発ではないため)

WebAudioContextの罠

他のウェブサービスではあまり見かけない、トリミング機能も作成しました。
これは、長時間の録音後に不要部分をトリミングできないと不便だと思い実装しました。
スクリーンショット 2019-05-19 13.54.52.png

トリミング処理に関してはToneJS、録音後の波形可視化にはwavesufferJSを使っているのですが、
WebAudioContextに関してはタッチアクションがないと動作しないという制約があるため、
2つのライブラリ間のWebAudioContextの受け渡しが厄介でした。
最終的には一度生成したAudioContextをReduxストアで管理することで解決しました。

WebAudio API系のライブラリは単独で使う分には問題ないことは多いのですが複数組み合わせるとなるとAudioContext周りの共有で苦しみます。
(またToneJSはimportしたタイミングでconstructorにてAudioContextの初期化処理が走ってしまい、ライブラリの初期化の実装があまりよくない感じがしました)

また音周りの実装はデバッグがしんどい・・・

Firebaseで苦しんだこと

・ Realtime DB、Firestoreに関して
最初の方はFirestoreがベータ版だったこともあり、
Realtime DBでデータ保存していたのですが、Firestoreが3月に正式版になったため、
Realtime DBからFirestoreに乗せ替えました。

検索周りに関してはFirestoreでも検索の自由が低く、正規表現検索やbetween検索ができないため限界があるなと感じています。
検索部分に関してニーズがある場合はFirestoreでなく、ElasticsearchやAlgolia Searchにデータをimportしてそちら側で検索するなどの対策が必要になりそうです。
また、テーブル設計に関してはスケールさせるのがかなり難しいと感じました。
JOIN機能がないため、ユーザコンテンツに関しては
別テーブルにして、ユーザコンテンツの取得は別テーブルのみ参照、
更新はbatchで一括で更新するみたいな処理をしていました。
(一番厄介なのがユーザとユーザコンテンツを同時に表示している一覧画面を作った場合に、ユーザのプロフィールを更新された際にユーザコンテンツのテーブルをすべて更新しなければならなくなる)

firebase.js
const store = firebase.firestore()

const setBulkStoreData = (model, conds = [], data) => {

  let ref = store.collection(model)
  let usedMultiCompare = false
  for (let cond of conds) {
    // デフォルトでインデックスが有効なのは単一フィールドによるクエリのみなので、2つ以上のフィールドの組み合わせによるクエリでは予めインデックスを設定しておく必要がある
    // <, <=, >, >= は複数のwhere句の中に1つしか存在できない
    if (usedMultiCompare && ['<', '<=', '>', '>='].includes(cond.compare)) break
    if (cond.field && cond.compare && cond.value) {
      ref = ref.where(cond.field, cond.compare, cond.value)
    }
    usedMultiCompare = ['<', '<=', '>', '>='].includes(cond.compare)
  }
  return ref.get()
    .then((querySnapshot) => {
      const batch = store.batch()
      querySnapshot.forEach(doc => {
        batch.set(doc.ref, data, {merge: true})
      })
      return batch.commit()
    })
}

4/27にリリースされた、CollectionGroupを使えば、SubCollectionからの検索も後付でできるらしいのでユーザに紐づくデータはSubCollectionに入れてしまう設計のほうが今はよいかもしれない・・・
待ち焦がれたCollectionGroupがCloud Firestoreへやってきた。

・セキュリティルールとfunctionsでの認証に関して
セキュリティルールはfirestore特有のものでデータのアクセス制限をfirestore.rulesに記述していきます。
firestoreに格納されているデータに合わせて
match データのパス/{パラメータId}のように指定します。
request.auth.uidにはFirebase Authenticationで認証されている場合にBearに認証トークンを付与されている場合、そのユーザのuidが入ります。
resource.data.フィールド名はfirestore内の一致しているユーザのuidを返却します。(from、toにユーザのuidが保存されている前提)
次の例では/messages/に1:1のチャットのセキュリティルールを指定しています。(from、toのuidを持つユーザ以外からはアクセスさせない)
また、/users/{uid}request.auth.uidの比較にてユーザコンテンツの書き込みは本人のみ、読み取りは誰でもできるようにしています。
(決済のデータは別テーブルに分けているため、クライアントサイドからは参照できないようにしてます)

firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    allow read, write: if false;

    match /messages/{messageId} {
      function canRead() {
        return request.auth.uid == resource.data.from || request.auth.uid == resource.data.to;
      }

      function isOwner() {
        return request.auth.uid == resource.data.from;
      }

      allow update, delete: if isOwner();
      allow create: if request.auth.uid != null;
      allow read: if canRead();
    }

    match /users/{uid} {
      function isOwner() {
        return request.auth.uid == uid;
      }
      allow read;
      allow write: if isOwner();
    }

  }
}

重要なデータに関しては
クライアントサイドからアクセス拒否してバックエンドのみから読み書きできるようにしたくなります。
その場合、セキュリティルールを次のようにread, writeをfalseにします。

firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    allow read, write: if false;

    match /records/{key} {
      allow read, write: if false;
    }

  }
}

firebase-adminのキーの払い出しと設定が済んでいれば
次のようにfirebase functions側で認証をかけることが可能になります。
firebase functionsはexpressのルーティングを指定することもできるので
expressアプリケーション丸ごとexports.api = functions.https.onRequest(app)とfunctionsのエンドポイントして指定すると開発が楽です。

index.js
'use strict'

const { functions, auth, WarmupFunctions } = require('./libs/firebase')
const express = require('express')
const cors = require('cors')({origin: true})
const bodyParser = require('body-parser')
const app = express()

// ファイルアップロード用、生データをrawBodyに追加
const rawBodySaver = (req, res, buf, encoding) => {
  if (buf && buf.length) {
    req.rawBody = buf.toString(encoding || 'utf8')
  }
}

// 認証API
const validate = async (req, res, next) => {

  if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
      !(req.cookies && req.cookies.__session)) {
    return res.status(403).send('Unauthorized')
  }

  let idToken
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    // ID Tokenをthe Authorization headerから取得
    idToken = req.headers.authorization.split('Bearer ')[1]
  } else {
    // 認証失敗
    return res.status(403).send('Unauthorized')
  }

  try {
    const decodedIdToken = await auth.verifyIdToken(idToken)
    console.log('ID Token correctly decoded', decodedIdToken)
    // ID Token decodedreq.userに格納
    req.user = decodedIdToken
    return next()
  } catch (error) {
    console.error('Error while verifying Firebase ID token:', error)
    return res.status(401).send('Unauthorized')
  }
}

app.use(cors)
app.use(bodyParser.urlencoded({extended: true, verify: rawBodySaver}))
app.use(bodyParser.json({verify: rawBodySaver}))

// 認証つきAPI例
app.use(
  '/records',
  express.Router()
    .post('/', validate, records.create)
)

// express自体をエンドポイントに指定すると/api以下のルーティングはexpressのルーティングが受けてくれる
exports.api = functions.https.onRequest(app)

クライアントサイドからFirebase Authenticationで取得したtokenを送信します。

index.js
import axios from 'axios'
const client = axios.create({baseURL: apiEndpoint})

client.interceptors.request.use(req => {
  const auth = JSON.parse(localStorage.auth || '{}')
  const token = auth.token

  if (token) {
    // ieのリクエストキャッシュ対策
    document.execCommand && document.execCommand('ClearAuthenticationCache', 'false')
    req.url += (req.url.indexOf('?') == -1 ? '?' : '&') + '_=' + Date.now()
    // ユーザ認証トークン付与
    req.headers.Authorization = `Bearer ${token}`
  }
  return req
}, err => Promise.reject(err))

Stripe Connectについて

現状、Stripe日本支部の中の人が解説してくれている記事が一番参考になります。(が、すでにAPIバージョンが更新されていて古いです。Stripe Connectの概念を理解するのに役立ちます。)
Stripe Connect 101

Stripe Connectで一番苦しんだのは自由度が高すぎて
ドキュメントは充実しているものの、やろうとしている決済フローの(Customアカウント)の実装が正しいのか、不安になった点です。
細切れで各セクションが解説されているため、完全な実装サンプルみたいなものが見つからず手探りで実装せざるを得なかったのが辛い・・・

また、2019/3/14でAPI仕様が結構変わったのでアップグレードするのが大変でした。
個人的に小額すぎる決済など200JPY未満はエラーになるなど、エラーリストがドキュメントに完全には網羅されていないのが不安点だったりします。(エラーメッセージは出てくるのでイレギュラーなものは最初は都度対応するしかないかも・・・)
実運用ではAPIのバージョンは必ず指定したほうが良いです。

stripe.js
const stripe = require('stripe')(serviceAccount.stripe_secret)
stripe.setApiVersion('2019-03-14') // APIのバージョンを指定する

その他、厄介だったのが販売者アカウント作成時に必要な住所や銀行コードをいかにユーザに指定させるかです。
住所に関しては日本郵便の住所情報データをDLしてきて、JSON形式に変換し、Realtime DBにインポートしました。(Firestoreだと大量データをimportするには、Blazeプランに上げる必要があったので)

銀行コードに関してはjson形式で全データnpmにおいてあるパッケージがあるのでそれを使いました。
zengin-code

これからやろうと思っていること

nanaやradikoといった競合のプラットフォームのクォリティがやはり高いので
プラットフォーム自体のデザインやクォリティをあげていくことと
カテゴリ別にTPOにあったデザインを提供していくことをやりたいなと思っています。
Twitterでの拡散やSEO対策も順次進めていきます。

最後に

最近Qiitaに投稿するモチベーションが上がらないので投稿していませんでした。
理由に関してはここに直接書くのも気が引けるのでnoteの方に書きました。

・僕がQiitaに投稿するのをやめた理由
※発言は個人の見解で所属組織とは無関係です
https://note.mu/vov/n/nc69efcb536c2

これからはnoteの方の投稿が増えていくと思います。
そちらの方もフォローしてもらえると嬉しいです

271
161
6

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
271
161