在前几天,无意间看到了b站直播互动平台开放了开发者接入的功能,所以继接入qq和baidu登录授权功能后决定研究一下b站的直播互动平台接入有哪些不同。对于这篇文章酝酿了好些天,因为实在是有些不大好下手,不知道怎样写才能讲的更加清晰、易懂。
(资料图片)
先来对这些天开发的内容进行一个总结和比较
qq互联 | baidu网盘开放平台 | bilibili直播开放平台 | |
---|---|---|---|
接口功能 | 登录与基本信息 | 登录与基本信息 | 直播弹幕、礼物、舰队时时信息 |
审核 | 严格,需要网站提前准备好各项功能、开放授权即能使用,否则不予通过。并且众多审核都需要一一进行。 | 宽松,只要申请就能通过,没有特殊条件,只要实名认证可以进行任何操作,不过一般用户似乎无法调用上传文件的功能 | 较为宽松,申请接入后第二天就会发携带key和secret的邮件。上架应用需要审核,测试无需审核 |
授权方式 | Oauth2.0 | Oauth2.0 | 请求头携带authorization获得长链信息,直播长链使用长链信息授权建立链接 |
文档清晰 | 清晰 | 清晰 | 不清晰,很多接口表述有歧义。文档冗余,排版杂乱 |
网址 | https://connect.qq.com/manage.html#/ | https://pan.baidu.com/union/home | https://open-live.bilibili.com/document/ |
对于oauth2.0方式的授权方式,当运用被创建后,即可获得基本的clientId和clientSecret以及appid。
之后我们需要设置callback地址,该地址必须填写公网地址/域名,而不能是内网地址。在自行测试阶段,为了方便,可以修改本地host文件的127.0.0.1为自己设置的公网地址/域名(因为host文件在开发过程中经常使用,所以建议设置指令快捷启动该文件)。
前端通过发送官方提供的授权码code获取请求,将会进入其授权页面,用户授权通过后,授权页面将会跳转到我们的回调地址,并且url携带了授权码code。利用code再次向获取token的url发起请求,即可获得token,其余数据获取携带该token即可成功得到。
回归到正题,我们这节重点总结的是阿b的直播接入方式,因为其中包含的很多知识都是之前未接触的,或者说没有成体系的解决方案。
先来上一个官方提供的流程图,总体来说分为两个部分,第一部分是获取长链信息,第二部分是进行长链接入并且保活。
请求头携带authorization获得长链信息的流程,在之前接入图床doge云时已经有了类似的实现思路,不过阿b的规则和doge云的方式有所不同。
doge云基本校验过程是先将 REQUEST_URI+"\n"+HTTP_BODY 拼接得到签名字符串,与密钥通过hmacSHA1加密签名sign,最后:
Authorization = "Token" + (accessKey + ":" + sign) 即可获得授权串。
bilibili则是将指定规则的几个header按字典序不带空白字符串拼接、并且与secret进行Hmac-SHA256加密即可获得authorization串。下面来详细讲一下阿b的授权码获取细节。
首先是header部分的设置,因为header的一系列要求,很自然的就让我想用Map进行暂存,之后可以迭代map来对请求头进行设置。不过对于部分header请求头,还要参与签名的流程,而签名要求是请求头按字典排序(不然签名肯定不是唯一的,毕竟一般的HashMap等都是散列存储的,如果不止一对kv,是无法保证按加入顺序取出的),所以自然又会想到有没有一种map可以有序存储呢,当然备选的有TreeMap和LinkedHashMap两种,其中TreeMap采用的是红黑树实现,LinkedHashMap则是有一个链表连接,如果按顺序存储,都能达到效果,而且数据量没有多大,只有6条,性能差距不大,但是方便起见,用链式的大致还是快一点点的。所以header设置和签名串获取如下代码实现:
URL url = new URL(DOMAIN_NAME + path); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Accept","application/json"); connection.setRequestProperty("Content-Type","application/json");// SET HEADERS Map xBili = new LinkedHashMap<>(6); xBili.put("x-bili-accesskeyid",ACCESS_KEY_ID); xBili.put("x-bili-content-md5",DigestUtils.md5DigestAsHex(content.getBytes())); xBili.put("x-bili-signature-method",SIGNATURE_METHOD); xBili.put("x-bili-signature-nonce", UUID.randomUUID().toString()); xBili.put("x-bili-signature-version","1.0"); xBili.put("x-bili-timestamp",String.valueOf(System.currentTimeMillis()/1000)); // load x-bili-* to a [String] & load into [headers] StringBuilder xBiliStr = new StringBuilder(); for (Map.Entry next : xBili.entrySet()) { connection.setRequestProperty(next.getKey(), next.getValue()); xBiliStr.append(next.getKey()).append(":").append(next.getValue()).append("\n"); } xBiliStr.deleteCharAt(xBiliStr.length()-1);
接下来是对获取到的签名进行hmacSHA256加密,其中需要注意字节数组转为要转为HEX十六进制形式,只有一位要补0。对于java的Mac(message authorization code)需要三部实现:Mac.getInstance("HmacSHA256")获取实例、mac.init(SecretKeySpec(secret,"HmacSHA256"))对实例初始化密钥、mac.doFinal(signStr)加密。即可实现获取byte数组,最后通过简单的转换算法即可获得sign签名。
// AuthorizationString hmacSha56 = "HmacSHA256";Mac hmac = Mac.getInstance(hmacSha56);SecretKeySpec secretKey = new SecretKeySpec(ACCESS_KEY_SECRET.getBytes(StandardCharsets.UTF_8),hmacSha56);hmac.init(secretKey);byte[] arr = hmac.doFinal(xBiliStr.toString().getBytes(StandardCharsets.UTF_8));StringBuilder sb = new StringBuilder();for (byte item : arr) {sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3));}connection.setRequestProperty("Authorization",sb.toString().toLowerCase());
最后,设置输出流获取输入流,第一部分就算完成了。后续可以定义一些常用的接口接入的方法入口进行快捷操作。
// SET CONTENTconnection.setDoOutput(true);connection.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8));// GET RESULTBufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()));// only one line, so write this wayreturn br.readLine();
对于bilibili直播接入,首先我们需要获得websocket_info长链信息,可以通过接口 /v2/app/start 获得,需要code(自己直播间的身份码)和appid即可获得包含该信息的json数据。websocket_info包含了auth_body和wss_link两个个部分。我们可以将其封装进一个实体类方便后续调用:
public static BasicConnectInfo start(){String start = "{\"code\":\""+CID+"\",\"app_id\":"+PROJECT_ID+"}";String tmp = null;try {tmp = commonRequest("/v2/app/start", start);//上述的签名认证函数} catch (Exception e) {e.printStackTrace();}// handle the resultif (tmp == null){System.err.println("[start] \t read line from INPUT - NULL");return null;}JSONObject jsonObject = new JSONObject(tmp);int code = jsonObject.getInt("code");if (code != 0) {System.err.println(tmp);return null;} else {JSONObject data = jsonObject.getJSONObject("data");JSONArray jsonArray = data.getJSONObject("websocket_info").getJSONArray("wss_link");String gameID = data.getJSONObject("game_info").getString("game_id");String authBody = data.getJSONObject("websocket_info").getString("auth_body");List wssLink = new ArrayList<>();for (int i = 0; i < jsonArray.length(); i++) {wssLink.add(jsonArray.getString(i));}BasicConnectInfo info = new BasicConnectInfo();info.setGameID(gameID);info.setAuthBody(authBody);info.setWssLink(wssLink);return info;}}
由第一步我们获得了长链的信息,接下来我们前端需要获取该信息,并且在连接建立的时候将该信息发送给长连服务,通过鉴定成功后,每过30s还需要发送一个心跳包进行双向保活确认,否则将会被断开。这部分主要战场在前端。对于传输的协议,官方已经给出图解:
首先需要构建符合协议的pack包,方便后续其它信息的交互。
function reqPack(op,body){let buffer = new ArrayBuffer(1500)let view = new DataView(buffer,0)let len = 4view.setInt16(len,16);len += 2 // Header Length [default:16]view.setInt16(len, 1);len += 2 // Version [0:raw, 2:zlib]view.setInt32(len, op);len += 4 // Operation [OP_AUTH:7, HEART_BEAT:2]view.setInt32(len, 0);len += 4 // Sequence ID [remain (ignore)]let arr = strUTF8Encode(body)for (let i = 0; i < arr.length; i++,len++) {view.setInt8(len, arr[i]);}view.setInt32(0, len)return buffer.slice(0, len)}
可能会注意到strUTF8Encode函数,是自定义的将字符串进行编码得到byte[]的函数,参考了网上比较高效的处理方式,即通过encodeURIComponent编码后去掉%后将后面两个十六进制字符合并转场byte即可:
function strUTF8Encode(str){let s = encodeURIComponent(str)let arr = []for (let i = 0; i < s.length; i++) {let tmp = s.charAt(i)if (tmp === "%") {let hex = s.charAt(i+1) + s.charAt(i+2)let val = parseInt(hex,16)arr.push(val)i += 2} else {arr.push(tmp.charCodeAt(0))}}return arr}
接下来就是利用js的原生websocket创建连接并且发送心跳包、接受回参的流程了。对于部分代码需要解释一下,首先对于心跳包,我采用的是setInterval函数操作,里面添加了一个mutex变量即success,如果长链的auth包正确,那么success不会改变,表明可以不断发送心跳包,否则说明建立失败,后续也不需要发送心跳包了,所以销毁该interval,并且在连接主动关闭(可能断网等情况)的时候,也需要将success设置为false,因为连接已经断开了,除非在断开时进行重连操作,不过我这里没有重连尝试了。
firstUsed也是一个锁,是当我们进行第一次auth后,后续无需再auth,那么收到的数据必然不会是认证包。(总感觉设计上有点问题,处理方式还是太杂乱了,虽然有用函数处理不同结果,看到一连串的右括号依然觉得难受!)
function wwsServiceRun(wssLink0,bodyStr0){let websocket = nulllet firstUsed = false // if not first meetif ("WebSocket" in window){websocket = new WebSocket(wssLink0)let success = truewebsocket.onopen = function (ev) {console.log("connect")websocket.send(reqPack(7,bodyStr0))let timer = setInterval(function () {if (success) {websocket.send(reqPack(2,""))} else {this.close(timer)}},30000)}websocket.onmessage = function (ev){if (typeof ev.data == "string") console.log(ev.data)else {let blob = new Blob([ev.data.slice(16,ev.data.byteLength)],{type:"application/text"})let txt = new FileReader()txt.readAsText(blob)txt.onload = function (){if (!firstUsed){let meet = JSON.parse(txt.result)if (meet.code !== 0){success = false} else {firstUsed = true}} else {// if === 1 ,that means a heartbeatif (txt.result.charCodeAt(3) !== 1){handleData(txt.result)}}}}}websocket.onerror = function (ev){ console.log("err") }websocket.onclose = function (ev){console.log("close")success = false}window.onbeforeunload = function (){ websocket.close() }} else alert("this browser does not support websocket")}
对于阿b,当长链建立成功后,当弹幕和礼物等来袭会主动推送带CMD的json数据(二进制),我们使用switch进行分流到不同函数处理即可。
function handleData(raw){let jsonRes =JSON.parse(raw)let data = jsonRes.dataswitch (jsonRes.cmd) {case "LIVE_OPEN_PLATFORM_DM": DM(data);breakcase "LIVE_OPEN_PLATFORM_SEND_GIFT":GIFT(data);break;case "LIVE_OPEN_PLATFORM_SUPER_CHAT":break;case "LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL":break;case "LIVE_OPEN_PLATFORM_GUARD":break;default: console.log(j)}}
至于之后的业务逻辑代码,就看自己的需求进行改变了。这里不多赘述。
既然说到长链,而且这也是自己第一次解除长链,肯定不能只会接受这么简单,肯定也要学会如何创建一个ws服务。于是有了接下来的部分。
首先依然使用的是springboot,我们需要引入依赖 spring-boot-starter-websocket ,然后添加配置类:
@Configurationpublic class WebSocketConfig { // 会暴露所有的端点服务 @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } // 如果websocket中想要调用业务,如果再其内部直接autowired获取到的是null,只能主动赋值给已经定义了的static变量 @Autowired public void setAcountService(AccountService service){ CommonWebSocket.service = service; }}
之后编写业务逻辑(不做筛选关键代码了):
@Component@Slf4j@ServerEndpoint("/commonWebSocket")public class CommonWebSocket { private Session session; public static AccountService service; private final CopyOnWriteArraySet webSockets = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session){ this.session = session; webSockets.add(this); } @OnMessage public void onMessage(ByteBuffer bb){ int pakLen = bb.getInt(0); int headerLen = bb.getChar(4); int len = pakLen - headerLen; /* op 0: user create 1: game msg 2: user heartbeat 3: user info upgrade # NOT SUPPORT NOW 4: list give */ int op = bb.getInt(8); String msg = new String(bb.array(),headerLen,len,StandardCharsets.UTF_8); JSONObject json = new JSONObject(msg);// USER CREATE if (op == 0) { //uid,name,avatar long uid = json.getLong("uid"); String name = json.getString("name"); String avatar = json.getString("avatar"); if (!service.exist(uid).getData()){ service.create(new Account(uid,name,avatar,10)); } }// GAME PLAY if (op == 1){ //uid,delta long uid = json.getLong("uid"); int delta = json.getInt("delta"); service.deltaUpgrade(delta,uid); } sendForList(); } private void sendForList(){// COMMON LIST PUSH List data = service.selectSort().getData(); JSONObject res = new JSONObject(); res.put("code",100); res.put("data",data); sendMessage(4, res.toString()); } public void sendMessage(int op, String body){ for (CommonWebSocket webSocket : webSockets) { try { webSocket.session.getBasicRemote().sendBinary(pack(op,body)); } catch (IOException e) { e.printStackTrace(); } } } private ByteBuffer pack(int op,String body){ byte[] bytes = body.getBytes(StandardCharsets.UTF_8); int totalLen = 16 + bytes.length; ByteBuffer buffer = ByteBuffer.allocate(totalLen); int index = 0; buffer.putInt(index, totalLen);index+=4; buffer.putChar(index, (char) 16);index+=2; buffer.putChar(index, (char) 1);index+=2; buffer.putInt(index,op);index+=4; buffer.putInt(index,0);index+=4; for (byte tmp : bytes) { buffer.put(index++,tmp); } return buffer; } @OnClose public void onClose(){ webSockets.remove(this); log.info("[websocket]\tconnect exit"); }}
之前总结都是突兀的在文章后面换一行总结,现在想想那样和正文难以分清,所以总结也加一个大标题吧!
通过这次对接bilibili的接口,第一次的接触了长链的创建和使用方式,虽然在之前的tcp/ip课程中有过类似的装包操作,但是那时候包的设计可扩展性和设计的数据安全性不高,这次学习了阿b的协议设计方式,感觉确实不错(包含总包长、包头长、版本号、操作码、保留位、实体消息),估计也会沿用到我的后续设计之中。当然,我也成功的通过接入该开放平台,实现了全民弹幕互动扫雷的项目,虽然没啥人来测试(;´д`)ゞ
哦,还有,springboot切换数据库真的是非常方便,我一开始连接的mysql,配置依赖时候用的是mysql的connect,后来由于个人业务需求,不得不换sqlite,结果发现只需要改变依赖为sqlite-jdbc然后取消掉账号密码的设置即可,非常方便欸!
好了,今天的总结就到这里,溜了溜了,今晚原神版本直播还没看,现在回去补看了,拜拜,下次见!
X 关闭