個人開発で声を売買できるマーケットプレイスを作ったので
そのときに苦労した話とどのような経緯で作ったのか公開します。
vov - 声から生まれるマーケットプレイス
https://smartvov.com/
Twitter運用アカウント(フォローしていただけると泣いて喜びます)
https://twitter.com/smartvov
今回リリースタイミングで
KANEさん主催のpodcastの三日月ラジオというチャンネルに出演させていただきました。
そこでもvovの開発秘話を語らせていただきました。
結構長いので時間があるときや、通勤通学などの時間帯等にお聞きください(宣伝)
三日月ラジオ前篇・後編
https://anchor.fm/crescentradio/episodes/ep1teradonburi-e42mlf
どんなことができるの?
・ウェブ上、スマホ上でそのまま録音ができ、録音後の声をトリミングできる
・カテゴリ別に声コンテンツを公開できる
・販売者登録すれば、年会費無料で声コンテンツの販売ができる(声コンテンツが売れたときの手数料としてプラットフォームが徴収する形式のビジネスモデル)
・クレジットカード決済で有料音声を購入できる
なぜ作ろうと思ったのか?
声なら誰でも出せるし、幅広いジャンルで利用用途がありそうと感じたことです。
nanaやradiko、Voicyといった音声、音楽のプラットフォームはあったのですが、声コンテンツをそのまま売買できるプラットフォームはなさそうだと思ったので作ろうと思いました。
アイディア自体は去年の夏ぐらいから実はあったのですが、実際に着手し始めたのは今年の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の罠
他のウェブサービスではあまり見かけない、トリミング機能も作成しました。
これは、長時間の録音後に不要部分をトリミングできないと不便だと思い実装しました。
トリミング処理に関しては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で一括で更新するみたいな処理をしていました。
(一番厄介なのがユーザとユーザコンテンツを同時に表示している一覧画面を作った場合に、ユーザのプロフィールを更新された際にユーザコンテンツのテーブルをすべて更新しなければならなくなる)
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
の比較にてユーザコンテンツの書き込みは本人のみ、読み取りは誰でもできるようにしています。
(決済のデータは別テーブルに分けているため、クライアントサイドからは参照できないようにしてます)
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にします。
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のエンドポイントして指定すると開発が楽です。
'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を送信します。
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のバージョンは必ず指定したほうが良いです。
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の方の投稿が増えていくと思います。
そちらの方もフォローしてもらえると嬉しいです