接入bilibili直播开放平台

前往原站点查看

2022-08-13 21:18:32

    在前几天,无意间看到了b站直播互动平台开放了开发者接入的功能,所以继接入qq和baidu登录授权功能后决定研究一下b站的直播互动平台接入有哪些不同。对于这篇文章酝酿了好些天,因为实在是有些不大好下手,不知道怎样写才能讲的更加清晰、易懂。

回顾开发的总结

    先来对这些天开发的内容进行一个总结和比较


qq互联baidu网盘开放平台bilibili直播开放平台
接口功能登录与基本信息登录与基本信息直播弹幕、礼物、舰队时时信息
审核严格,需要网站提前准备好各项功能、
开放授权即能使用,否则不予通过。并且众多审核都需要一一进行。
宽松,只要申请就能通过,没有特殊条件,
只要实名认证可以进行任何操作,不过一般用户似乎无法调用上传文件的功能
较为宽松,申请接入后第二天就会发携带key和secret的邮件。上架应用需要审核,测试无需审核
授权方式Oauth2.0Oauth2.0请求头携带authorization获得长链信息,
直播长链使用长链信息授权建立链接
文档清晰清晰清晰不清晰,很多接口表述有歧义。文档冗余,排版杂乱
网址https://connect.qq.com/manage.html#/https://pan.baidu.com/union/homehttps://open-live.bilibili.com/document/

Oauth2.0方式流程

    对于oauth2.0方式的授权方式,当运用被创建后,即可获得基本的clientId和clientSecret以及appid。

    之后我们需要设置callback地址,该地址必须填写公网地址/域名,而不能是内网地址。在自行测试阶段,为了方便,可以修改本地host文件的127.0.0.1为自己设置的公网地址/域名(因为host文件在开发过程中经常使用,所以建议设置指令快捷启动该文件)。

    前端通过发送官方提供的授权码code获取请求,将会进入其授权页面,用户授权通过后,授权页面将会跳转到我们的回调地址,并且url携带了授权码code。利用code再次向获取token的url发起请求,即可获得token,其余数据获取携带该token即可成功得到。

bilibili的接入流程

    回归到正题,我们这节重点总结的是阿b的直播接入方式,因为其中包含的很多知识都是之前未接触的,或者说没有成体系的解决方案。

     先来上一个官方提供的流程图,总体来说分为两个部分,第一部分是获取长链信息,第二部分是进行长链接入并且保活。

image

对于第一步

    请求头携带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然后取消掉账号密码的设置即可,非常方便欸!

    好了,今天的总结就到这里,溜了溜了,今晚原神版本直播还没看,现在回去补看了,拜拜,下次见!



上一篇: 高自由度QQ机器人制作
下一篇: 服务器子用户创建运用