音视频通信(Real-Time Communication,RTC)是阿里云覆盖全球的实时音视频开发平台,依托核心音视频编解码、信道传输、网络调度技术,提供高可用、高品质、超低延时的音视频通信服务。本教程指导您如何利用阿里云音视频通信搭建在线多人音视频聊天室。

前提条件

使用本教程之前,请您务必完成以下操作:
  1. 您已经完成注册阿里云账号,并完成实名认证,具体操作请参见阿里云账号注册流程
  2. 您已经开通音视频通信服务,具体操作请参见开通服务
  3. 获取应用ID,具体操作请参见创建应用
  4. 获取AppKey,具体操作请参见查询AppKey

背景信息

本教程中的聊天室分为服务端和客户端两部分,服务端主要功能用来生成和下发频道鉴权令牌,客户端拿到令牌调用阿里云音视频通信SDK获取频道信息,客户端也可以调用本地摄像头和麦克风设备将音视频流发布到频道中。

步骤一:搭建App Server

服务端App Server负责生成和下发频道鉴权令牌,令牌是由以下字段按顺序拼接并使用SHA-256哈希加密算法生成字符串的摘要:
  • AppID:应用ID,使用控制台创建。
  • AppKey:应用密钥,使用控制台查询。
  • ChannelID:频道ID,AppServer生成。
  • UserID:您的唯一标识,AppServer生成。
  • Nonce:令牌随机码,AppServer生成。
  • Timestamp:令牌过期时间戳,AppServer生成。
  1. 构建服务端代码。
    1. pom.xml文件中加入以下内容:
        <dependencies>
              <!-- 轻量级httpserver-->
              <dependency>
                  <groupId>com.sun.net.httpserver</groupId>
                  <artifactId>http</artifactId>
                  <version>20070405</version>
                  <scope>test</scope>
              </dependency>
              <!-- 命令行参数解析工具-->
              <dependency>
                  <groupId>commons-cli</groupId>
                  <artifactId>commons-cli</artifactId>
                  <version>1.2</version>
              </dependency>
              <dependency>
                  <groupId>org.json</groupId>
                  <artifactId>json</artifactId>
                  <version>20170516</version>
              </dependency>
          </dependencies>
          <build>
              <plugins>
                  <!-- 将依赖连带打到jar包中-->
                  <plugin>
                      <groupId>org.apache.maven.plugins</groupId>
                      <artifactId>maven-assembly-plugin</artifactId>
                      <version>3.1.1</version>
                      <configuration>
                          <archive>
                              <manifest>
                                  <mainClass>com.company.App</mainClass>
                              </manifest>
                          </archive>
                          <descriptorRefs>
                              <descriptorRef>jar-with-dependencies</descriptorRef>
                          </descriptorRefs>
                      </configuration>
                      <executions>
                          <!--执行器 mvn assembly:assembly-->
                          <execution> 
                              <id>make-assembly</id>
                              <!-- 绑定到package生命周期阶段上 -->
                              <phase>package</phase> 
                              <goals>
                                  <!-- 该打包任务只运行一次 -->
                                  <goal>single</goal>
                              </goals>
                          </execution>
                      </executions>
                  </plugin>
              </plugins>
          </build>
    2. 新建App.java文件,内容如下:
      import com.sun.net.httpserver.Headers;
      import com.sun.net.httpserver.HttpExchange;
      import com.sun.net.httpserver.HttpHandler;
      import com.sun.net.httpserver.HttpServer;
      import org.apache.commons.cli.CommandLine;
      import org.apache.commons.cli.Option;
      import org.apache.commons.cli.Options;
      import org.apache.commons.cli.PosixParser;
      import org.json.JSONArray;
      import org.json.JSONObject;
      import javax.xml.bind.DatatypeConverter;
      import java.io.IOException;
      import java.io.OutputStream;
      import java.net.InetSocketAddress;
      import java.security.MessageDigest;
      import java.security.NoSuchAlgorithmException;
      import java.util.*;
      
      public class App {
          // 监听端口
          private int listen;
          // 应用ID
          private String appID;
          // 应用密钥
          private String appKey;
          // 服务地址
          private String gslb;
          // 频道随机码
          private String nonce;
          // 频道时间戳
          private Long timestamp;
          // 用户唯一标识
          private String userID;
          // 加入频道token
          private String token;
      
          // 生成token
          public static String createToken(
                  String appId, String appKey, String channelId, String userId,
                  String nonce, Long timestamp
          ) throws NoSuchAlgorithmException {
              MessageDigest digest = MessageDigest.getInstance("SHA-256");
              digest.update(appId.getBytes());
              digest.update(appKey.getBytes());
              digest.update(channelId.getBytes());
              digest.update(userId.getBytes());
              digest.update(nonce.getBytes());
              digest.update(Long.toString(timestamp).getBytes());
      
              String token = DatatypeConverter.printHexBinary(digest.digest()).toLowerCase();
              return token;
          }
      
          // 生成userID
          public static String createUserID(String channelID, String user) throws NoSuchAlgorithmException {
              MessageDigest digest = MessageDigest.getInstance("SHA-256");
              digest.update(channelID.getBytes());
              digest.update("/".getBytes());
              digest.update(user.getBytes());
      
              String uid = DatatypeConverter.printHexBinary(digest.digest()).toLowerCase();
              return uid.substring(0, 16);
          }
      
          // 写响应报文工具方法
          private void httpWrite(HttpExchange he, int code, String response) throws IOException {
              OutputStream os = he.getResponseBody();
              he.sendResponseHeaders(code, response.length());
              os.write(response.getBytes());
              os.close();
          }
      
          // 请求处理类
          class LoginHandler implements HttpHandler {
              public void handle(HttpExchange he) throws IOException {
                  if (he.getRequestHeaders().containsKey("Origin")) {
                      // 配置响应头
                      Headers headers = he.getResponseHeaders();
                      headers.set("Access-Control-Allow-Origin", "*");
                      headers.set("Access-Control-Allow-Methods", "GET,POST,HEAD,PUT,DELETE,OPTIONS");
                      headers.set("Access-Control-Expose-Headers", "Server,Range,Content-Length,Content-Range");
                      headers.set("Access-Control-Allow-Headers", "Origin,Range,Accept-Encoding,Referer,Cache-Control,X-Proxy-Authorization,X-Requested-With,Content-Type");
                  }
      
                  if (he.getRequestMethod().equalsIgnoreCase("OPTIONS")) {
                      httpWrite(he, 200, "");
                      return;
                  }
      
                  // 将请求参数放入map中
                  Map<String, String> query = new HashMap<String, String>();
                  for (String param : he.getRequestURI().getQuery().split("&")) {
                      String[] entry = param.split("=");
                      if (entry.length > 1) {
                          query.put(entry[0], entry[1]);
                      } else {
                          query.put(entry[0], "");
                      }
                  }
      
                  // 频道ID
                  String channelID = query.get("room");
                  // 用户ID
                  String user = query.get("user");
      
                  // 处理非法参数
                  if (channelID == "" || user == "") {
                      httpWrite(he, 500, "invalid parameter");
                      return;
                  }
      
                  try {
                      userID = createUserID(channelID, user);
      
                      //令牌随机码,这里使用AK-+UUID
                      nonce = String.format("AK-%s", UUID.randomUUID().toString());
      
                      Calendar nowTime = Calendar.getInstance();
                      // 令牌过期时间,48小时
                      nowTime.add(Calendar.HOUR_OF_DAY, 48);
                      timestamp = nowTime.getTimeInMillis() / 1000;
      
                      token = createToken(appID, appKey, channelID, userID, nonce, timestamp);
                  } catch (NoSuchAlgorithmException e) {
                      e.printStackTrace();
                      httpWrite(he, 500, e.getMessage());
                      return;
                  }
      
                  // 生成随机用户名
                  String username = String.format("%s?appid=%s&channel=%s&nonce=%s&timestamp=%d",
                          userID, appID, channelID, nonce, timestamp);
      
                  System.out.printf("Login: appID=%s, appKey=%s, channelID=%s, userID=%s, nonce=%s, " +
                                  "timestamp=%d, user=%s, userName=%s, token=%s\n",
                          appID, appKey, channelID, userID, nonce, timestamp, user, username, token);
      
                  // 封装响应报文,json格式
                  JSONObject response = new JSONObject()
                          .put("code", 0)
                          .put("data", new JSONObject()
                                  .put("appid", appID)
                                  .put("userid", userID)
                                  .put("gslb", new JSONArray().put(gslb))
                                  .put("token", token)
                                  .put("nonce", nonce)
                                  .put("timestamp", timestamp)
                                  .put("turn", new JSONObject()
                                          .put("username", username)
                                          .put("password", token)
                                  ));
                  he.getResponseHeaders().set("Content-Type", "application/json");
                  httpWrite(he, 200, response.toString());
              }
          }
      
          // 解析命令行参数
          public void run(String[] args) throws Exception {
              Options options = new Options();
              options.addOption(new Option("l", "listen", true, "listen port"));
              options.addOption(new Option("a", "appid", true, "the id of app"));
              options.addOption(new Option("k", "appkey", true, "the key of app"));
              options.addOption(new Option("g", "gslb", true, "the url of gslb"));
              CommandLine cli = new PosixParser().parse(options, args);
              if (!cli.hasOption("listen")) {
                  throw new Exception("no listen");
              }
              if (!cli.hasOption("appid")) {
                  throw new Exception("no appid");
              }
              if (!cli.hasOption("appkey")) {
                  throw new Exception("no appkey");
              }
              if (!cli.hasOption("gslb")) {
                  throw new Exception("no gslb");
              }
      
              // 监听端口
              listen = Integer.parseInt(cli.getOptionValue("listen"));
              // 应用ID
              appID = cli.getOptionValue("appid");
              // 应用密钥
              appKey = cli.getOptionValue("appkey");
              // 服务地址
              gslb = cli.getOptionValue("gslb");
              System.out.printf("Server listen=%d, appid=%s, appkey=%s, gslb=%s\n", listen, appID, appKey, gslb);
      
              // 创建httpserver
              HttpServer server = HttpServer.create(new InetSocketAddress(listen), 0);
              server.createContext("/app/v1/login", new LoginHandler());
              server.start();
          }
      
          public static void main(String[] args) {
              try {
                  new App().run(args);
              } catch (Exception e) {
                  System.out.println(e);
              }
          }
      }
  2. 打包项目,在命令行项目根目录下运行:
    mvn package
  3. 运行项目,在命令行target目录下运行:
    java -jar .\maven-rtc-1.0-SNAPSHOT-jar-with-dependencies.jar --listen=8080 --appid=<appid> --appkey=<appkey> --gslb=https://rgslb.rtc.aliyuncs.com
说明 如需搭建本地AppServer和Token校验服务,详情请参见搭建验证服务器

步骤二:运行客户端

Web客户端实现了阿里云音视频通信的基本功能,包括本地预览、加入频道、本地发布、订阅远端、离开频道等。Web客户端的搭建流程如下:

  1. 搭建客户端代码。新建index.html,内容如下:
    <!DOCTYPE html>
    <html>
    <head>
      <title>AliWebRTC Demo</title>
      <meta charset="UTF-8">
      <meta name="viewport"
        content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
      <link rel="stylesheet" href="./index.css" />
      <script src="./jquery-1.10.2.min.js"></script>
      <script src="./aliyun-webrtc-sdk-1.9.1.min.js"></script>
    </head>
    <body>
      <div class='local-display-name'></div>
      <div class='channel-input'>
        <input type='text'></input>
        <button>切换频道</button>
      </div>
      <div class='local-video'>
        <video autoplay playsinline></video>
      </div>
      <div class="video-container"></div>
    </body>
    </html>
    <script>
      // 必须使用https
      var AppServerUrl="https://127.0.0.1:8080/app/v1/login";
      var getQueryString = function (name) {
        var vars = [], hash;
        var hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
        for (var i = 0; i < hashes.length; i++) {
          hash = hashes[i].split('=');
          vars.push(hash[0]);
          vars[hash[0]] = hash[1];
        }
        return vars[name];
      }
      var channelId = getQueryString('channel') || 1900
      userName = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5);
      $('.local-display-name').text("User: " + userName + "        Channel Id: " + channelId);
      $('.channel-input input').val(channelId);
    
      //AliWebRTC code
      AliRtcEngine.isSupport().then(re => {
        console.log(re);
        init();
      }).catch(err => {
        alert(err.message);
      })
      var aliWebrtc;
      function init() {
        aliWebrtc = new AliRtcEngine("");
        // remote用户加入房间
        aliWebrtc.on('onJoin', (data) => {
        });
        // remote流发布事件
        aliWebrtc.on('onPublisher', (publisher) => {
          receivePublish(publisher);
        });
        // remote流结束发布事件
        aliWebrtc.on('onUnPublisher', (publisher) => {
          removePublish(publisher.userId);
        });
        // 错误信息
        aliWebrtc.on('onError', (error) => {
          var msg = error && error.message ? error.message : error;
          alert(msg);
        });
    
        // 订阅remote流成功后,显示remote流
        aliWebrtc.on('onMediaStream', (subscriber, stream) => {
          var video = getDisplayRemoteVideo(subscriber.userId, subscriber.displayName);
          aliWebrtc.setDisplayRemoteVideo(subscriber, video, stream);
        });
    
        aliWebrtc.on('OnConnecting', (data) => {
          console.log(data.displayName + "正在建立连接中...");
        });
        aliWebrtc.on('OnConnected', (data) => {
          console.log(data.displayName + "成功建立连接");
        });
    
        aliWebrtc.on('onLeave', (data) => {
          removePublish(data.userId);
        })
    
        //1.预览
        var localVideo = $('.local-video video');
        aliWebrtc.startPreview(localVideo[0]).then((obj) => {
          //2. 获取频道鉴权令牌参数
          getRTCAuthInfo().then((authInfo) => {
            //3. 加入房间
            aliWebrtc.joinChannel(authInfo, userName).then(() => {
              console.log('加入房间成功');
              // 4. 发布本地流
              aliWebrtc.publish().then((res) => {
                console.log('发布流成功');
              }, (error) => {
                alert(error.message);
              });
            }).catch((error) => {
              alert(error.message);
            })
          }).catch((error) => {
            alert(error.message);
          });
        }).catch((error) => {
          alert(error.message);
        });
      }
    
      var receivePublish = (publisher) => {
        //5.订阅remote流
        aliWebrtc.subscribe(publisher.userId).then((subscribeCallId) => {
          console.log('订阅成功')
        }, (error) => {
          alert(error.message);
        });
      };
    
      var removePublish = (userId) => {
        var id = userId;
        var videoWrapper = $('#' + id);
        videoWrapper ? videoWrapper.remove() : '';
      }
    
      var getDisplayRemoteVideo = function (userId, displayName) {
        var id = userId;
        var videoWrapper = $('#' + id);
        if (videoWrapper.length == 0) {
          videoWrapper = $('<div class="remote-subscriber" id=' + id + '> <video autoplay playsinline></video><div class="display-name"></div></div>');
          $('.video-container').append(videoWrapper);
        }
        videoWrapper.find('.display-name').text(displayName);
        return videoWrapper.find('video')[0];
      }
    
      //获取频道鉴权令牌
      var getRTCAuthInfo = () => {
        return new Promise(function (resolve, reject) {
          $.ajax({
            url: AppServerUrl+"?room=" + channelId + "&user=" + userName + "&passwd=1234",
            type: 'POST',
            contentType: 'application/json; charset=utf-8',
            dataType: 'json',
            success: (data) => {
              data.data.channel = channelId;
              resolve(data.data);
            },
            failed: (error) => {
              reject(error);
            }
          });
        });
      }
    
      $('.channel-input button').click(() => {
        var value = $('.channel-input input').val();
        if (!value) {
          return;
        }
        //aliWebrtc.leaveChannel();
        location.href = './index.html?channel=' + value;
      });
      window.onbeforeunload = function (e) {
        //aliWebrtc.leaveChannel();
      };
    </script>
    说明 获取客户端完整代码,请参见客户端代码
  2. 部署Web服务。将客户端代码放入Apache Httpd服务器的wwwroot目录下,配置https证书并启动服务器。
  3. 访问客户端。请使用支持WebRTC的浏览器例如Microsoft Edage,访问https://ip:port/index.html
    1. Demo运行成功进入首页,默认进入房间,本地已经开启预览。
      Web 本地预览
    2. 如果该频道中有其他用户,即可开始实时音视频通话。
      Web 通信