リアルタイムライブ配信ができるオークションアプリを作ってみた(AgoraSDK)

こちらは[「ライブ配信/ビデオ通話SDK（Agora）を使用したサービスのアイデアを大募集！【PR】V-CUBE Advent Calendar 2021」 ](https://qiita.com/advent-calendar/2021/agora)21日目の記事になります。

こちらのアドベントカレンダーは下記テーマで、アイディアを募集ということでしたが、
考えたものを実現してみたいなと思い、実装してみました。

①こんなライブ配信があったら面白いのでは？このリアルの体験をオンラインにできるのでは？
超低遅延ライブ配信/ビデオ通話SDKを使ったサービスアイディアを大募集！


## はじめに

自分自身、ネットオークションが好きで参加することが多いのですが、下記課題を感じることがあります。

* 実際の商品の品質が分かりづらい。
* 質問しても返答までにラグがある。
* 入札期間が長く、張り付く時間が多い

リアルタイムのライブ配信形式で行うツールがあれば、
すぐにやり取りでき、商品の品質もカメラで確認しながら、短い時間でオークションを行えるため、上記の課題を解決できるのではないかと思いました。

## Agoraとは

今回、リアルタイムライブ配信のオークションアプリを実現するにあたって、Agoraというサービスを使います。
`Agora（agora io sdk）は、スマートフォン・PCアプリやWebサイトに、カスタマイズしたビデオ・音声通話やライブ配信をかんたんに実装できるSDK`です。([Agora公式](https://jp.vcube.com/service/agora)より引用)

Agoraはトライアルとして10,000分無料で使うことができるので、ちょっと試してみるのにも最適です！

技術的な仕様については、ここでは触れないため、公式を参考にしてください。

### チュートリアル

Agoraを触ったことがない方が多いと思うので、簡単なチュートリアルを用意します。(agora公式から引用)
知っている方は次の項目にSKIPしてください。

今回はWebアプリケーションで実装を想定しているため、Webアプリでのチュートリアルを紹介します。
内容が長くなってしまうため、折りたたんでいます。必要な方はクリックして内容を確認してください。

<details><summary>チュートリアル</summary><div>

#### アカウント発行
まずは下記URLから、STEPに則りアカウントとSDKの取り組みをしてください。
https://jp.vcube.com/service/agora/developer/tutorial/video/web.html

アカウント発行とプロジェクトの作成が完了したら、いよいよ実装に入ります。

#### 初期実装
今回は試しに、ビデオ通話機能をサンプル実装します。

まずは、新規でフォルダを作ります。 ここではagora_sampleとします。
その他、必要なファイルを用意します。

ディレクトリ構成は下記です。

```
agora_sample
  └ index.html
  └ basicVideoCall.js
  └ package.json
  └ webpack.config.js
```

##### index.htmlの編集

下記コードをコピペします。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Agora Video Web SDK Quickstart</title>
    <script src="./dist/bundle.js"></script>
</head>
<body>
    <h2 class="left-align">Agora Video Web SDK Quickstart</h2>
        <div class="row">
            <div>
                <button type="button" id="join">JOIN</button>
                <button type="button" id="leave">LEAVE</button>
            </div>
        </div>
</body>
</html>
```

##### basicVideoCall.jsの編集

下記コードをコピペします。
optionsのappIdのところに、最初に作成したプロジェクトのappIdを入力してください。
tokenはGenerate temp RTC tokenのリンクをクリックして、Cannel Nameをテストにし、Generate Temp Tokenをクリックしてください。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/ccd52e8a-69db-e420-2719-6ee37d301262.png" alt="appId" width="300" />

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/947fd90e-4d38-6af7-5544-c2c8589b8fa7.png" alt="token" width="300" />

```javascript
import AgoraRTC from "agora-rtc-sdk-ng"

let rtc = {
    localAudioTrack: null,
    localVideoTrack: null,
    client: null
};

let options = {
    appId: "Your App ID",
    channel: "test",
    token: "Your temp token",
    uid: 123456
};

async function startBasicCall() {
    rtc.client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });

    rtc.client.on("user-published", async (user, mediaType) => {
        await rtc.client.subscribe(user, mediaType);
        console.log("subscribe success");

        if (mediaType === "video") {
            const remoteVideoTrack = user.videoTrack;
            const remotePlayerContainer = document.createElement("div");
            remotePlayerContainer.id = user.uid.toString();
            remotePlayerContainer.textContent = "Remote user " + user.uid.toString();
            remotePlayerContainer.style.width = "640px";
            remotePlayerContainer.style.height = "480px";
            document.body.append(remotePlayerContainer);

            remoteVideoTrack.play(remotePlayerContainer);

            // Or just pass the ID of the DIV container.
            // remoteVideoTrack.play(playerContainer.id);
        }

        if (mediaType === "audio") {
            const remoteAudioTrack = user.audioTrack;
            remoteAudioTrack.play();
        }

        rtc.client.on("user-unpublished", user => {
            const remotePlayerContainer = document.getElementById(user.uid);

            remotePlayerContainer.remove();
        });

    });

    window.onload = function () {

        document.getElementById("join").onclick = async function () {
            await rtc.client.join(options.appId, options.channel, options.token, options.uid);
            rtc.localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
            rtc.localVideoTrack = await AgoraRTC.createCameraVideoTrack();
            await rtc.client.publish([rtc.localAudioTrack, rtc.localVideoTrack]);
            const localPlayerContainer = document.createElement("div");
            localPlayerContainer.id = options.uid;
            localPlayerContainer.textContent = "Local user " + options.uid;
            localPlayerContainer.style.width = "640px";
            localPlayerContainer.style.height = "480px";
            document.body.append(localPlayerContainer);

            rtc.localVideoTrack.play(localPlayerContainer);
            console.log("publish success!");
        }

        document.getElementById("leave").onclick = async function () {
            rtc.localAudioTrack.close();
            rtc.localVideoTrack.close();

            rtc.client.remoteUsers.forEach(user => {
                const playerContainer = document.getElementById(user.uid);
                playerContainer && playerContainer.remove();
            });

            await rtc.client.leave();
        }
    }
}

startBasicCall()
```

##### package.jsonの編集

```json
{
  "name": "agora_web_quickstart",
  "version": "1.0.0",
  "description": "",
  "main": "basicVideoCall.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --config webpack.config.js",
    "start:dev": "webpack serve --open --config webpack.config.js"
  },
  "dependencies": {
    "agora-rtc-sdk-ng": "latest",
    "webpack": "5.28.0",
    "webpack-dev-server": "3.11.2",
    "webpack-cli": "4.9.1"
  },
  "author": "",
  "license": "ISC"
}
```

##### webpack.config.js

```javascript
const path = require('path');

 module.exports = {
   entry: './basicVideoCall.js',
   output: {
     filename: 'bundle.js',
     path: path.resolve(__dirname, './dist'),
   },
   devServer: {
     static: {
       directory: path.resolve(__dirname, './'),
     },
     compress: true,
     port: 9000
   }
 };
```

##### 実行

下記コマンドを実行します。

```terminal
npm install
npm run build
npm run start:dev
open http://localhost:9000 #mac以外はブラウザからlocalhost:9000をアドレスに入力してください。
```

暫く待つとjoinボタンが出てくるので、クリックします。
すると、こんな感じでビデオが連携されたのがわかるかと思います！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/1ddffbd1-6cd0-c3ff-8ca7-5d192222ed05.png" alt="join" width="300" />

##### 注意事項
Agoraはセキュリティの制限で、httpの場合はlocalhost or 127.0.0.1のみしかサポートしていません。
公開したい場合はhttpsでホスティングする必要があります。

##### その他
以上のチュートリアルができた方は、他に参考になるコンテンツがあるので、そちらも紹介します。
[新規プロジェクトの始め方](https://docs.agora.io/en/Video/run_demo_video_call_web?platform=Web)
[Reactでのチュートリアル](https://github.com/AgoraIO/Basic-Video-Call/tree/master/One-to-One-Video/Agora-Web-Tutorial-1to1-React)
[Webのサンプル ここから拡張していくとよい](https://github.com/AgoraIO/API-Examples-Web)

</div></details>

## オークションアプリの仕様
さて、いよいよオープションアプリの実装に移りたいと思います。
最初に述べたように、下記の課題を解消するものを要件として考えていきたいと思います。

* 実際の商品の品質が分かりづらい。
* 質問しても返答までにラグがある。
* 入札期間が長く、張り付く時間が多い

上記要件を元に、下記機能を必要なものとして、実装していこうと思います。

* ライブ配信機能
    * 商品の紹介をリアルタイムに行うため
* コメント機能
    * ユーザーがコメントした内容を元にライブ配信者が適宜回答を行うため
* オークション機能
    * 部屋の開設機能
    * 時間指定を行える機能
    * 出品終了機能
    * 最低落札価格の設定
    * 入札機能
* その他あると良い機能 (今回は時間がないので見送ります。)
    * 出品者側
        * 商品管理機能
        * 売上管理機能
    * 入札者側
        * 商品検索機能
        * ウォッチリスト機能
        * 決済機能
        * 評価機能

などなど

非機能要件としては下記が必須になります。 (今回は時間がないので見送ります。)

* 排他制御
    * 同じ金額での入札が起きた場合の正常な処理を担保する
* いたずら入札の防止
などなど

作る機能が決まったので、いよいよ実装に移っていきます。

## デモ

今回はあくまで試しで作っているもののため、項目やデザインなどは適当です。ご了承ください。

| ページ | 画像 |
| ----- | ---- |
| TOPページ(商品一覧) | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/ba4d836d-b1e9-a355-6c41-7cd52b1ae6ba.png" width="300" /> |
| 出品ページ | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/aef6bf41-3610-1db0-75d7-fda8bfb90ae4.png" width="300" />  |
| 出品時の管理ページ | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/8282235d-eb0e-946e-9c7d-4595217387e8.png" width="300" />|
| 入札ページ |<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224825/c0460d00-95c2-8976-6836-ef3bbfe1c11a.png" width="300" />|

出品の終了は各商品ごとに終了を選択することで出品を終えることができます。

## 技術解説
コードのブラッシュアップをしていきたいため、GitHubなどでの公開は現時点では考えておりません。

### 使用技術
* [firebase](https://firebase.google.com/?hl=ja)
* [Agora SDK](https://docs.agora.io/en)
* [Next.js](https://nextjs.org/)
* [Rails](https://railstutorial.jp/)
* [S3](https://aws.amazon.com/jp/s3/)

### 機能解説
最低限のものを作っただけのため、これといって変わったことはしていませんが、説明していきます。

#### ライブ配信機能
AgoraのStream機能を使用しています。
視聴可能人数は 1配信あたり 1,000,000人なので、よっぽど問題はありません！
遅延についても　ライブ配信 1秒以下のため、よほど気になることはないと思います。

#### コメント機能, 入札機能
Next.jsでページを構成しています。
リアルタイム入札とコメントに関してはfirebaseを駆使して、作成しています。
具体的な作り方については@taketakekahoさんの[こちらの記事](https://qiita.com/taketakekaho/items/52b7c196ddbd4cb3c968)を参照ください。

#### オークション機能
下記については、Railsで作成しています。シンプルなCRUDで作っているだけなので、特に解説はしません。
画像のホスティングをS3で行っています。
* 部屋の開設機能
* 時間指定を行える機能
* 最低落札価格の設定

### コード解説
Agora部分を解説します。
こちらの[サンプルコード](https://github.com/fu-jimoto/AgoraIO-LiveCommerce-Sample)をベースに開発しています。

ライブ配信を実現するために、modeをliveにします。

```javascript
const agoraClient = AgoraRTC.createClient({ mode: "live", codec: "vp8" });
```

ストリーミング配信を行うために、下記を設定します。
[API仕様についてはこちら](https://docs.agora.io/en/Video/API%20Reference/web/globals.html?platform=Web#createstream)

```javascript
 localStream = AgoraRTC.createStream({streamID: uid, audio: true, video: true, screen: false});
```

配信者側の配信情報をpublishします。

```javascript
    client.publish(localStream, function (err) {});
```

後は視聴者側で配信されたものをsubscribeすることで視聴できるようになります。

以下は[ライブコマースシステム構築の「はまりどころ」と「対策」](https://jp.vcube.com/service/agora/developer/implementation/1101.html)の引用になりますが、たった、これだけのコードでライブ配信が実現できるのはスゴイと言わざるを得ないですね...!

配信側で必要最低限の実装コード

```javascript
function join() {
    client = AgoraRTC.createClient({mode: 'live', codec:'vp8'});
    client.init(appId, function () {
      client.join(channel_key, channelName, gUid, function(uid) {
          localStream = AgoraRTC.createStream({streamID: uid, audio: true, video: true, screen: false});
          localStream.setVideoProfile(videoProfile);
          localStream.init(function() {
            localStream.play('agora_local',{fit: 'cover'});
          }, function (err) {
          });        
      }, function(err) {
      });
    }, function (err) {
    });
}

function publish(){
    client.publish(localStream, function (err) {
    });
}
```

視聴側で必要最低限の実装コード

```javascript
function join(){
    client = AgoraRTC.createClient({mode: 'live', codec:'vp8'});
    client.init(appId, function () {
        client.join(null, channelName, null, function(uid) {
        }, function(err) {
        });
    }, function (err) {
    });
    client.on('stream-added', function (evt) {
        var stream = evt.stream;
        client.subscribe(stream, function (err) {
        });
    });
    client.on('stream-subscribed', function (evt) {
        remoteStream = evt.stream;
    });
}

function playStream(){
    remoteStream.play('agora_remote'+remoteStream.getId());
}
```


実際の実装課題を例に、下記ページで公式が解説してくれているのでそちらもよろしければ参照ください。
[ライブコマースシステム構築の「はまりどころ」と「対策」](https://jp.vcube.com/service/agora/developer/implementation/1101.html)

## まとめ

* Agoraかなり使いやすい！すぐに実装できました。
* 実際に使ってみたらより面白そう！いつか正式サービス化してみたい！
    * 課題は山積みですが、実現したら面白いサービスになるのではと思ってます。

今回、このようなアドベントカレンダーのテーマがなければ触れることがなかった技術なので、
Agoraを提供していただいた[V-cubeさん](https://jp.vcube.com/)ありがとうございました。

## 参考文献
[Agoraで君だけの最強のClubhouseを作ろう](https://zenn.dev/ginpei/articles/agora-voice-chat)