Agoraの使い方入門として最低限の映像を映すだけの実装方法をまとめてみた（Nuxt, Vue）

タイトルのとおりですが、
Agoraの実装方法が最初なかなか分かりづらかったので記事にまとめてみます🙋‍♂️
Agoraちょっとこれから触るけどよくわかんないみたいな方向けです🙆‍♂️

# 今回のAgoraを動作させる順序
すごく簡単に書くと
①Nuxt.js（SPA） → ApiGateway → Lambdaで一時トークンを生成してそれをレスポンスとして返す。
②取得した一時トークンを使用してNuxt.js → Agoraで映像を配信する。

# 用語について
これらはAgoraのコンソール画面でProjectを作成した後に詳細画面から確認することができます。
・AppID　　　　　アプリにつけられる一意のID
・CertificateKey　 一時トークンの鍵と考えるとわかりやすい
その他
・Client　Agoraを使用する際には必ず作成します
・Stream　映像の情報だと思ってもらえると分かりやすい
・LocalStream　自分のカメラやマイクの情報
・RemoteStream　相手側のカメラやマイクの情報

# 一時トークンの必要性
一時トークンを使用しなくてもAgoraで発行されるAppIDのみでAgoraを使用することもできるが、セキュリティ上よろしくはなく、他人に不正利用されてしまうリスクがあるため、
一時トークンをサーバー側で生成して使用することを強くオススメしたいです🙌
これはAgoraの公式の方も勧めている方法です🙆‍♂️

# 一時トークンの設定
トークンの有効期限などをサーバー側で自由に設定できます。
①サーバー側でCertificateKeyを使って鍵を閉めたトークン情報をレスポンスとして返す。
②Agora側も同一のCertificateKeyを使って鍵を開けて解読する。

つまり、このCertificateKeyは絶対にバレてはいけないということです。合鍵みたいなもの🙋‍♂️
間違えてGithubにあげちゃったとかないように気をつけましょう🥺

# Agoraの実装における懸念点
公式のドキュメントを読んだかぎり、そのまま実装していくと成功した場合の処理でめちゃくちゃネストが発生するやん。っていうことがあります🤔
例えば入室処理に関しても普通に書くと
Client作成→Client初期化→入室→LocalStreamを作成→LocalStreamを初期化→プレビューを再生
という流れになり、ソースコードを見てもらえるとわかるかと思いますが、パット見で非常にネストが多くわかりずらいし、ミスってバグが起きる可能性も割と高めかなと

````admin/index.vue
<template>
<div>
  <!-- ここにプレビューが再生される -->
  <div id="preview"></div>
</div>
</template>
<script>
import AgoraRTC from "agora-rtc-sdk";

export default {
  data() {
    return {
      client: null,
      localStream: null,
    }
  },
  //ここからのこと！！
  mounted() {
    //Client作成
    this.client = AgoraRTC.createClient({
      mode: "live",
      codec: "vp8",
    });
    //Client初期化
    this.client.init('あなたのAppID', () => {
      //Client初期化が成功した場合の処理
      //入室
      joinメソッド('トークン', 'チャンネル名', 'uid(ユーザーIDのようなもの)', () => {
        //入室が成功した場合の処理
        //LocalStreamの作成
        this.localStream = AgoraRTC.createStream(audio: true, video: true);
        //LocalStreamの初期化
        this.localStream.init(() => {
          //LocalStreamの初期化に成功した場合の処理
          //LocalStreamのプレビューを再生（id="preview" の子要素に再生される）
          this.localStream.play("preview");
        }, (err) => {
          //LocalStreamの初期化に失敗した場合の処理
        });
      }, (err) => {
        //入室が失敗した場合の処理
      });
    }, (err) => {
      //Client初期化が失敗した場合の処理
      console.log(`Clientの初期化に失敗しました ${err}`);
    })
  },
}
</script>
````


という感じなわけでもっとパット見わかるようにしたい！
と思ったのです。

# Promise使えばいいじゃん
Promiseを使えばこんなにネストしなくて良いということを先輩エンジニアから学びました🙇‍♂️
コード自体の行数は少々増えますが、Promiseを使えばパっと見分かりやすいし修正も容易だなと感じました🌟

````admin/index.vue
<template>
<div>
  <!-- ここにプレビューが再生される -->
  <div id="preview"></div>
</div>
</template>
<script>
import AgoraRTC from "agora-rtc-sdk";

export default {
  data() {
    return {
      client: null,
      localStream: null,
    }
  },
  async mounted() {
    //Client 作成
    this.client = this.createClient();
    //Client初期化
    await this.initClient();
    //入室
    await this.clientJoinChannel();
    //ローカルストリームを作成
    this.localStream = this.createLocalStream();
    //ローカルストリーム初期化
    await this.initLocalStream();
    //LocalStreamのプレビューを再生（id="preview" の子要素に再生される）
    this.localStream.play("preview");
  },
  methods: {
    //Client 作成
    createClient() {
      return AgoraRTC.createClient({
        mode: "live",
        codec: "vp8",
      });
    },
    //Client初期化
    initClient() {
      return new Promise((resolve) => {
        this.client.init('あなたのAppID', () => {
          //成功
          resolve();
        }, (err) => {
          //失敗
        })
      })
    },
    //入室
    clientJoinChannel() {
      return new Promise((resolve) => {
        this.client.join('トークン', 'チャンネル名', 'uid(ユーザーIDのようなもの)', () => {
          //成功
          resolve();
        }, (err) => {
          //失敗
        });
      });
    },
    //ローカルストリームを作成
    createLocalStream() {
      return AgoraRTC.createStream({ audio: true, video: true });
    },
    //ローカルストリームを初期化
    initLocalStream() {
      return new Promise((resolve) => {
        this.localStream.init(() => {
          //成功
          resolve();
        }, (err) => {
          //失敗
        });
      });
    },
  },
}
</script>
````

という感じです👀


# 最後に
## 動画の配信まで実装したい方へ
以下は配信する際に最低限の処理をかきました。

こちらの`mixin-common-methods.js`はプラグインのディレクトリに入れて共通使用できるメソッドとして定義してあります。

````plugins/mixin-common-methods.js
import Vue from 'vue'

Vue.mixin({
  methods: {
    //エラー出力
    handleError(err) {
      console.log("[Error]  ", err);
    },
    //成功時のメッセージ
    successMessage(message) {
      console.log(message);
    },
    //１〜999999までの数値をランダムで取得したい場合
    getRandNumber() {
      const min = 1;
      const max = 999999;
      return Math.floor( Math.random() * (max + 1 - min) ) + min;
    },
    //文字列をランダムで取得したい場合
    getRandString() {
      const strings = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY";
      const max = 25;
      return Array.from(Array(max)).map(() => strings[Math.floor(Math.random() * strings.length)]).join('');
    },
  }
})

````

````nuxt.config.js

plugins: [
  { src: '@/plugins/mixin-common-methods', ssr: false },
],
````

データをクライアント側で保持させる処理
補足：リロードしても情報を保持しておきたいので`vuex-persistedstate`というものをインストールすると便利です。
→　https://qiita.com/chenglin/items/0fd9baf386227a5ca614

````store/agora.js
export const state = () => ({
  uid: null,
  channel: null,
  token: '',
})

export const getters = {
  uid(state) {
    return state.uid;
  },
  channel(state) {
    return state.channel;
  },
  token(state) {
    return state.token;
  },
}

export const mutations = {
  setUid(state, uid) {
    state.uid = uid;
  },
  setChannel(state, channel) {
    state.channel = channel;
  },
  setToken(state, token) {
    state.token = token;
  },
}
````

Agoraの実装部分
補足：`.env`を使用したかったので`dotenv`というものもインストールしています。
→ https://qiita.com/fj_yohei/items/c77bff6f0177b4ff219e

```` admin/index.vue
<template>
  <div class="container">
    <!-- プレビュー画面 -->
    <div id="preview">
    </div>

   <!-- 相手方のカメラの映像 -->
   <div id="remoteStream">
   </div>

   <!-- 配信開始ボタン -->
   <button type="button" @click="publishStream">
     配信開始
   </button>
       
   <!-- 配信停止ボタン -->
   <button type="button" @click="rtcClientLeaveChannel">
     配信停止
   </button>

   <!-- 退出ボタン -->
   <button type="button" @click="exit">
     退出する
   </button>
  </div>
</template>

<script>
import AgoraRTC from "agora-rtc-sdk";
export default {
  data() {
    return {
      appId: 'あなたのAppID',
      token: '',
      uid: ' ユーザーID',
      channel: 'チャンネル名',
      path: 'トークン生成に使うApiGatewayのパス',

      loading: false, //axios非同期通信中は true にしてね

      //Agora情報
      role: '設定したい権限',
      rtcClient: null,
      localStream: null,
    }
  },
  async mounted() {
    //storeのstateの変更を検知する
    this.storeDataWatcher();
    //配信準備
    await this.createRoom();
  },
  methods: {
    //配信準備
    async createRoom() {
      try {
        //トークンを取得　→　ApiGateway　→　Lambdaでトークンを生成
        const res = await this.getToken();
        //トークン情報をStoreにコミット
        this.responseSave(res);
        //通信中解除
        this.loading = false;
        //Client新規作成
        this.rtcClient = this.createRtcClient();
        //Clientを初期化
        await this.initRtcClient();
        //ClientRoleをセット
        this.setRoleRtcClient();
        //入室
        await this.rtcClientJoinChannel();
        //ストリーム情報を作成
        await this.createStream();

      } catch (err) {
        this.loading = false;
        this.setStreaming(false);
        this.handleError(`createRoomに失敗しました ${err}`);
      }
    },
    //Client 作成
    createRtcClient() {
      return AgoraRTC.createClient({
        mode: "live",
        codec: "vp8",
      });
    },
    //Client初期化
    initRtcClient() {
      return new Promise((resolve) => {
        this.rtcClient.init(this.appId, () => {
          this.successMessage(`Clientの初期化に成功しました`);
          resolve();
        }, (err) => {
          this.handleError(`Clientの初期化に失敗しました ${err}`);
        })
      })
    },
    //権限をセット
    setRoleRtcClient() {
      this.rtcClient.setClientRole(this.role, (err) => {
        if (err !== null) this.handleError(`Clientの権限の設定に失敗しました ${err}`);
      });
    },
    //ストリーム情報を作成
    async createStream() {
      //ローカルストリームを作成
      this.localStream = this.createLocalStream();
      //ローカルストリーム初期化
      await this.initLocalStream();
      //ローカルストリームを再生
      this.playLocalStream();
    },
    //入室
    rtcClientJoinChannel() {
      return new Promise((resolve) => {
        this.rtcClient.join(this.token, this.channel, this.uid, () => {
          this.successMessage(`入室に成功しました`);
          resolve();
        }, (err) => {
          this.handleError(`入室に失敗しました ${err}`);
        });
      });
    },
    //ローカルストリームを作成
    createLocalStream() {
      return AgoraRTC.createStream({
        streamID: this.uid,
        audio: true,
        //cameraId: getDevicesというメソッドで利用できるカメラを取得してから指定してください（例: 全面カメラ、背面カメラ）,
        //microphoneId: getDevicesというメソッドで利用できるマイクを取得してから指定してください（例:defaultのマイク、外部マイク）,
        video: true,
        screen: false, //画面共有
      });
    },
    //ローカルストリームを初期化
    initLocalStream() {
      return new Promise((resolve) => {
        this.localStream.init(() => {
          this.successMessage(`localStreamの初期化に成功しました`);
          resolve();
        }, (err) => {
          this.handleError(`localStreamの初期化に失敗しました ${err}`);
        });
      });
    },
    //ローカルストリームを再生
    playLocalStream() {
      this.localStream.play("preview", {fit: "cover"}, (err) => {
        if(err !== null) this.handleError(`localStreamの再生に失敗しています ${err}`);
      });
    },
    //ストリームを公開
    publishStream() {
      this.rtcClient.publish(this.localStream, (err) => {
        //失敗した場合
        this.handleError(`localStreamの公開に失敗しました ${err}`);
      });
    },
    //トークン取得
    async getToken() {
      //通信中の重複防止
      if (this.loading) return;
      //通信中にする
      this.loading = true;
      //トークン取得
      return await this.$axios.$post(this.path, {
        uid: this.uid,
        channel: this.channel,
      })
      .catch((err) => {
        //エラー出力
        this.handleError(`トークンの取得に失敗しました ${err}`);
        //通信中解除
        this.loading = false;
      });
    },
    //レスポンス情報を反映
    responseSave(res) {
      //レスポンス情報をStoreにコミット（永続化）
      this.$store.commit("agora/setToken", res.token);
      this.$store.commit("agora/setUid", res.uid);
      this.$store.commit("agora/setChannel", res.channel);
    },
    //配信停止
    rtcClientLeaveChannel() {
      //公開をやめる
      this.unpublishStream();
    },
    //退室
    leaveRtcClient() {
      return new Promise((resolve) => {
        this.rtcClient.leave(() => {
          this.successMessage(`退出に成功しました`);
          resolve();
        }, (err) => {
          this.handleError(`退出に失敗しました ${err}`);
        });
      })
    },
    //公開をやめる
    unpublishStream() {
      this.rtcClient.unpublish(this.localStream, (err) => {
        this.handleError(`localStreamの公開停止に失敗しました ${err}`);
      });
    },
    //#previewの子要素を削除する
    removeLocalStreamElement() {
      const preview = document.getElementById("preview");
      preview.innerHTML = '';
      this.localStream.close();
      this.localStream = null;
    },
    //退出
    exit() {
      //配信停止
      this.rtcClientLeaveChannel();
      //カメラの使用をやめる
      this.localStream.close();
      //退出
      this.leaveRtcClient();
      //トップに戻す(カメラの使用中を解除させるため)
      window.location.href = process.env.BASE_URL;
    },
    //store.stateの変更を検知する
    storeDataWatcher() {
      this.$store.watch((state, getters) => getters["agora/uid"],
        (newValue, oldValue) => {
          this.uid = newValue;
          this.successMessage(`uidを${oldValue} → ${newValue} に変更しました`);
        }
      );
      this.$store.watch((state, getters) => getters["agora/channel"],
        (newValue, oldValue) => {
          this.channel = newValue;
          this.successMessage(`channelを${oldValue} → ${newValue} に変更しました`);
        }
      );
      this.$store.watch((state, getters) => getters["agora/token"],
        (newValue) => {
          this.token = newValue;
        }
      );
    },
  }
}
</script>

````


おわり🌟