2022-08-13 21:18:32
在前几天,无意间看到了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<String,String> 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<String, String> 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签名。
// Authorization
String 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 CONTENT
connection.setDoOutput(true);
connection.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8));
// GET RESULT
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()));
// only one line, so write this way
return 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 result
if (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<String> 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 = 4
view.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 = null
let firstUsed = false // if not first meet
if ('WebSocket' in window){
websocket = new WebSocket(wssLink0)
let success = true
websocket.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 heartbeat
if (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.data
switch (jsonRes.cmd) {
case "LIVE_OPEN_PLATFORM_DM": DM(data);break
case "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 ,然后添加配置类:
@Configuration
public 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<CommonWebSocket> 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<Account> 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然后取消掉账号密码的设置即可,非常方便欸!
好了,今天的总结就到这里,溜了溜了,今晚原神版本直播还没看,现在回去补看了,拜拜,下次见!