前言
在线上办公如火如荼的今天,多人协作功能是每个应用绕不开的门槛。processflow在线流程图(前身基于drawio二次开发)沉寂两年之久,经过长时间设计开发,调整,最终完成了多人协作的核心模块设计。废话不多说,上操作视频展示下效果:
Video_2023-09-04_150131
多人协作技术原理剖析
秉着都2023年了,谁还重复造轮子的理念,这里现学现用,利用了一些商业成熟的技术和软件来实现自己的目标。主要用到了两点技术:
- 利用websocket的push&subscribe实现多人之间的行为共享(鼠标点击;移动;图标内容变更)
- 利用onedrive实现历史快照文件的存储
client生产事件主要分为以下几种:
switch (data.action)
{
//传递鼠标移动和点击事件
case 'message':
processMsg(data.msg, data.from);
break;
case 'clientsList':
clientsList(data.msg);
break;
case 'signal':
signal(data.msg);
break;
case 'newClient':
newClient(data.msg);
break;
case 'clientLeft':
clientLeft(data.msg);
break;
case 'sendSignalFailed':
sendSignalFailed(data.msg);
break;
}
server端主要作用就是接收client端的各个事件,然后对事件进行处理,然后广播出去;
比如:newClient新客户端加入,server端会将clientId和session记录到缓存中,然后将新的clientId广播出去,所有的客户端接收到有新协作者加入后,会进行相关准备操作。
clientLeft:当有客户端下线,也会将session剔除,然后广播出去,所有客户端会将下线的client的光标移除。
push队列主要对各个客户端的事件进行排序。幂等操作,然后发布。
存储端:
主要用onedrive进行存储,历史版本管理,冲突解决。这里插一句,不要问为什么用onedrive,因为drawio集成了onedrive,这里不想重写一套新的文件操作api,直接用现成的了。鉴于国内对onedrive网络支持的不太友好,这也是为什么迟迟不上线的原因之一,后续考虑将接口统一走后端代理,由后端统一访问onedrive。
private Response contactOAuthServer(String authSrvUrl, String code, String refreshToken, String secret,
String client, String redirectUri, boolean directResp, int retryCount) {
HttpURLConnection con = null;
Response response = new Response();
try {
URL obj = new URL(authSrvUrl);
con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("POST");
boolean jsonResponse = false;
StringBuilder urlParameters = new StringBuilder();
if (postType == X_WWW_FORM_URLENCODED) {
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
if (withAcceptJsonHeader) {
con.setRequestProperty("Accept", "application/json");
}
urlParameters.append("client_id=");
urlParameters.append(Utils.encodeURIComponent(client, "UTF-8"));
urlParameters.append("&client_secret=");
urlParameters.append(Utils.encodeURIComponent(secret, "UTF-8"));
if (code != null) {
if (withRedirectUrl) {
urlParameters.append("&redirect_uri=");
urlParameters.append(Utils.encodeURIComponent(redirectUri, "UTF-8"));
}
urlParameters.append("&code=");
urlParameters.append(Utils.encodeURIComponent(code, "UTF-8"));
urlParameters.append("&grant_type=authorization_code");
} else {
if (withRedirectUrlInRefresh) {
urlParameters.append("&redirect_uri=");
urlParameters.append(Utils.encodeURIComponent(redirectUri, "UTF-8"));
}
urlParameters.append("&refresh_token=");
urlParameters.append(Utils.encodeURIComponent(refreshToken, "UTF-8"));
urlParameters.append("&grant_type=refresh_token");
jsonResponse = true;
}
} else if (postType == JSON) {
con.setRequestProperty("Content-Type", "application/json");
JsonObject urlParamsObj = new JsonObject();
urlParamsObj.addProperty("client_id", client);
urlParamsObj.addProperty("redirect_uri", redirectUri);
urlParamsObj.addProperty("client_secret", secret);
if (code != null) {
urlParamsObj.addProperty("code", code);
urlParamsObj.addProperty("grant_type", "authorization_code");
} else {
urlParamsObj.addProperty("refresh_token", refreshToken);
urlParamsObj.addProperty("grant_type", "refresh_token");
jsonResponse = true;
}
urlParameters.append(urlParamsObj.toString());
}
// Send post request
con.setDoOutput(true);
DataOutputStream wr = new DataOutputStream(con.getOutputStream());
wr.writeBytes(urlParameters.toString());
wr.flush();
wr.close();
BufferedReader in = new BufferedReader(
new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuffer authRes = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
authRes.append(inputLine);
}
in.close();
response.status = con.getResponseCode();
Gson gson = new Gson();
JsonObject json = gson.fromJson(authRes.toString(), JsonElement.class).getAsJsonObject();
String accessToken = getAccessToken(json);
int expiresIn = getExpiresIn(json);
response.refreshToken = getRefreshToken(json);
response.accessToken = accessToken;
JsonObject respObj = new JsonObject();
respObj.addProperty("access_token", accessToken);
if (expiresIn > -1) {
respObj.addProperty("expires_in", expiresIn);
}
if (directResp) {
response.content = respObj.toString();
} else {
// Writes JavaScript code
response.content = processAuthResponse(respObj.toString(), jsonResponse);
}
} catch (IOException e) {
StringBuilder details = new StringBuilder("");
if (con != null) {
try {
BufferedReader in = new BufferedReader(
new InputStreamReader(con.getErrorStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.err.println(inputLine);
details.append(inputLine);
details.append("\n");
}
in.close();
} catch (Exception e2) {
// Ignore
}
}
if (e.getMessage() != null && e.getMessage().contains("401")) {
response.status = HttpServletResponse.SC_UNAUTHORIZED;
} else if (retryCount > 0 && e.getMessage() != null && e.getMessage().contains("Connection timed out")) {
return contactOAuthServer(authSrvUrl, code, refreshToken, secret,
client, redirectUri, directResp, --retryCount);
} else {
response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
e.printStackTrace();
log.error("AUTH-SERVLET: [" + authSrvUrl + "] ERROR: " + e.getMessage() + " -> " + details.toString());
}
if (DEBUG) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
pw.println(details.toString());
pw.flush();
response.content = sw.toString();
}
}
return response;
}
感悟心得
开源不易,请大家多给drawio点点star,没有他,就没有中国繁荣的流程图商业化盛景...拿着人家的代码来挣钱,一个start也不愿意给人家点。可悲。
虽然作者挺操蛋的,多人协作的代码没有开源(我只能靠研究官方网站的协作功能和数据格式一点点猜测用到了哪些逻辑),白板转流程图的代码也没开源。不过我觉得他做的挺对的,农夫与蛇的故事见的太多了。所以我决定和他一样,让白嫖的人踩坑去吧,这里只做原理讲解,不提供源码内容。