以前寫畢業設計的時候就想加上聊天系統,當時已經用ajax長輪詢實現了一個(還不懂什麼是輪詢機制的,猛戳這裏:https://www.cnblogs.com/hoojo/p/longPolling_comet_jquery_iframe_ajax.html),但因爲種種緣由沒有加到畢設裏面。後來回校答辯後研究了一下websocket,並參照網上資料寫了一個簡單的聊天,如今又從新整理並記錄下來。
如下介紹來自維基百科:
WebSocket是一種在單個TCP鏈接上進行全雙工通訊的協議。WebSocket通訊協議於2011年被IETF定爲標準RFC 6455,並由RFC7936補充規範。WebSocket API也被W3C定爲標準。
WebSocket使得客戶端和服務器之間的數據交換變得更加簡單,容許服務端主動向客戶端推送數據。在WebSocket API中,瀏覽器和服務器只須要完成一次握手,二者之間就直接能夠建立持久性的鏈接,並進行雙向數據傳輸。
這裏能夠看一下官網介紹:http://www.websocket.org/aboutwebsocket.html
官網裏面介紹很是詳細,我就不作搬運工了,要是有像我同樣英語很差的同窗,右鍵->翻譯成簡體中文
spring對websocket的支持:https://docs.spring.io/spring/docs/4.3.13.RELEASE/spring-framework-reference/htmlsingle/#websocket
這裏有一份spring對websocket的詳細介紹:https://docs.spring.io/spring/docs/5.1.8.RELEASE/spring-framework-reference/web.html#websocket
javascript
四個大章節,內容不少,就不一一展開介紹了css
2019/04/30補充:咱們這個登陸、登出很是簡單,就一個請求地址,連頁面都沒有,因此剛開始我就沒有貼出來,致使博客文章閱讀起來比較吃力,如今在這裏補充一下(因爲項目後面有所改動,請求地址少了 springboot/,不過你們看得懂就行),這裏只是一個小demo,全部就怎麼簡單怎麼來html
登陸 http://localhost:10086/websocket/login/huanzi,java
登出 http://localhost:10086/websocket/logout/huanzijquery
上下線有提示git
若是這時候發送消息給離線的人,則會收到系統提示消息github
本例中,點擊本身是羣聊窗口web
huanzi一發送羣聊,laowang跟xiaofang都不是在當前羣聊窗口,出現小圓點+1ajax
huanzi一發送羣聊,xiaofang在當前羣聊窗口,直接追加消息,老王不在對應的聊天窗口,出現小圓點+1spring
xiaofang回覆,huanzi直接追加消息,laowang依舊小圓點+1
laowang點擊羣聊窗口,小圓點消失,追加羣聊消息
laowang參與羣聊
xiaofang切出羣聊窗口,laowang在羣聊發送消息,xiaofang出現小圓點+1
切回來,小圓點消失,聊天數據正常接收追加
三方正常參與聊天
huanzis私聊xiaofang,xiaofang聊天窗口在羣聊,小圓點+1,而laowang不受影響
xiaofang切到私聊窗口,小圓點消失,數據正常追加;huanzi恰好處於私聊窗口,數據直接追加
效果演示到此結束,下面貼出代碼
首先先介紹一下項目結構
maven
<!-- springboot websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
配置文件
#修改thymeleaf訪問根路徑 spring.thymeleaf.prefix=classpath:/view/
socketChart.css樣式
body{ background-color: #efebdc; } #hz-main{ width: 700px; height: 500px; background-color: red; margin: 0 auto; } #hz-message{ width: 500px; height: 500px; float: left; background-color: #B5B5B5; } #hz-message-body{ width: 460px; height: 340px; background-color: #E0C4DA; padding: 10px 20px; overflow:auto; } #hz-message-input{ width: 500px; height: 99px; background-color: white; overflow:auto; } #hz-group{ width: 200px; height: 500px; background-color: rosybrown; float: right; } .hz-message-list{ min-height: 30px; margin: 10px 0; } .hz-message-list-text{ padding: 7px 13px; border-radius: 15px; width: auto; max-width: 85%; display: inline-block; } .hz-message-list-username{ margin: 0; } .hz-group-body{ overflow:auto; } .hz-group-list{ padding: 10px; } .left{ float: left; color: #595a5a; background-color: #ebebeb; } .right{ float: right; color: #f7f8f8; background-color: #919292; } .hz-badge{ width: 20px; height: 20px; background-color: #FF5722; border-radius: 50%; float: right; color: white; text-align: center; line-height: 20px; font-weight: bold; opacity: 0; }
socketChart.html頁面
<!DOCTYPE> <!--解決idea thymeleaf 表達式模板報紅波浪線--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>聊天頁面</title> <!-- jquery在線版本 --> <script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script> <!--引入樣式--> <link th:href="@{/css/socketChart.css}" rel="stylesheet" type="text/css"/> </head> <body> <div id="hz-main"> <div id="hz-message"> <!-- 頭部 --> 正在與<span id="toUserName"></span>聊天 <hr style="margin: 0px;"/> <!-- 主體 --> <div id="hz-message-body"> </div> <!-- 功能條 --> <div id=""> <button>表情</button> <button>圖片</button> <button id="videoBut">視頻</button> <button onclick="send()" style="float: right;">發送</button> </div> <!-- 輸入框 --> <div contenteditable="true" id="hz-message-input"> </div> </div> <div id="hz-group"> 登陸用戶:<span id="talks" th:text="${username}">請登陸</span> <br/> 在線人數:<span id="onlineCount">0</span> <!-- 主體 --> <div id="hz-group-body"> </div> </div> </div> </body> <script type="text/javascript" th:inline="javascript"> //項目根路徑 var ctx = [[${#request.getContextPath()}]];//登陸名 var username = /*[[${username}]]*/''; </script> <script th:src="@{/js/socketChart.js}"></script> </html>
socketChart.js 邏輯代碼
//消息對象數組 var msgObjArr = new Array(); var websocket = null; //判斷當前瀏覽器是否支持WebSocket, springboot是項目名 if ('WebSocket' in window) { websocket = new WebSocket("ws://localhost:10086/springboot/websocket/"+username); } else { console.error("不支持WebSocket"); } //鏈接發生錯誤的回調方法 websocket.onerror = function (e) { console.error("WebSocket鏈接發生錯誤"); }; //鏈接成功創建的回調方法 websocket.onopen = function () { //獲取全部在線用戶 $.ajax({ type: 'post', url: ctx + "/websocket/getOnlineList", contentType: 'application/json;charset=utf-8', dataType: 'json', data: {username:username}, success: function (data) { if (data.length) { //列表 for (var i = 0; i < data.length; i++) { var userName = data[i]; $("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + userName + "</span><span id=\"" + userName + "-status\">[在線]</span><div id=\"hz-badge-" + userName + "\" class='hz-badge'>0</div></div>"); } //在線人數 $("#onlineCount").text(data.length); } }, error: function (xhr, status, error) { console.log("ajax錯誤!"); } }); } //接收到消息的回調方法 websocket.onmessage = function (event) { var messageJson = eval("(" + event.data + ")"); //普通消息(私聊) if (messageJson.type == "1") { //來源用戶 var srcUser = messageJson.srcUser; //目標用戶 var tarUser = messageJson.tarUser; //消息 var message = messageJson.message; //最加聊天數據 setMessageInnerHTML(srcUser.username,srcUser.username, message); } //普通消息(羣聊) if (messageJson.type == "2"){ //來源用戶 var srcUser = messageJson.srcUser; //目標用戶 var tarUser = messageJson.tarUser; //消息 var message = messageJson.message; //最加聊天數據 setMessageInnerHTML(username,tarUser.username, message); } //對方不在線 if (messageJson.type == "0"){ //消息 var message = messageJson.message; $("#hz-message-body").append( "<div class=\"hz-message-list\" style='text-align: center;'>" + "<div class=\"hz-message-list-text\">" + "<span>" + message + "</span>" + "</div>" + "</div>"); } //在線人數 if (messageJson.type == "onlineCount") { //取出username var onlineCount = messageJson.onlineCount; var userName = messageJson.username; var oldOnlineCount = $("#onlineCount").text(); //新舊在線人數對比 if (oldOnlineCount < onlineCount) { if($("#" + userName + "-status").length > 0){ $("#" + userName + "-status").text("[在線]"); }else{ $("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + userName + "</span><span id=\"" + userName + "-status\">[在線]</span><div id=\"hz-badge-" + userName + "\" class='hz-badge'>0</div></div>"); } } else { //有人下線 $("#" + userName + "-status").text("[離線]"); } $("#onlineCount").text(onlineCount); } } //鏈接關閉的回調方法 websocket.onclose = function () { //alert("WebSocket鏈接關閉"); } //將消息顯示在對應聊天窗口 對於接收消息來講這裏的toUserName就是來源用戶,對於發送來講則相反 function setMessageInnerHTML(srcUserName,msgUserName, message) { //判斷 var childrens = $("#hz-group-body").children(".hz-group-list"); var isExist = false; for (var i = 0; i < childrens.length; i++) { var text = $(childrens[i]).find(".hz-group-list-username").text(); if (text == srcUserName) { isExist = true; break; } } if (!isExist) { //追加聊天對象 msgObjArr.push({ toUserName: srcUserName, message: [{username: msgUserName, message: message, date: NowTime()}]//封裝數據 }); $("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + srcUserName + "</span><span id=\"" + srcUserName + "-status\">[在線]</span><div id=\"hz-badge-" + srcUserName + "\" class='hz-badge'>0</div></div>"); } else { //取出對象 var isExist = false; for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == srcUserName) { //保存最新數據 obj.message.push({username: msgUserName, message: message, date: NowTime()}); isExist = true; break; } } if (!isExist) { //追加聊天對象 msgObjArr.push({ toUserName: srcUserName, message: [{username: msgUserName, message: message, date: NowTime()}]//封裝數據 }); } } // 對於接收消息來講這裏的toUserName就是來源用戶,對於發送來講則相反 var username = $("#toUserName").text(); //恰好打開的是對應的聊天頁面 if (srcUserName == username) { $("#hz-message-body").append( "<div class=\"hz-message-list\">" + "<p class='hz-message-list-username'>"+msgUserName+":</p>" + "<div class=\"hz-message-list-text left\">" + "<span>" + message + "</span>" + "</div>" + "<div style=\" clear: both; \"></div>" + "</div>"); } else { //小圓點++ var conut = $("#hz-badge-" + srcUserName).text(); $("#hz-badge-" + srcUserName).text(parseInt(conut) + 1); $("#hz-badge-" + srcUserName).css("opacity", "1"); } } //發送消息 function send() { //消息 var message = $("#hz-message-input").html(); //目標用戶名 var tarUserName = $("#toUserName").text(); //登陸用戶名 var srcUserName = $("#talks").text(); websocket.send(JSON.stringify({ "type": "1", "tarUser": {"username": tarUserName}, "srcUser": {"username": srcUserName}, "message": message })); $("#hz-message-body").append( "<div class=\"hz-message-list\">" + "<div class=\"hz-message-list-text right\">" + "<span>" + message + "</span>" + "</div>" + "</div>"); $("#hz-message-input").html(""); //取出對象 if (msgObjArr.length > 0) { var isExist = false; for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == tarUserName) { //保存最新數據 obj.message.push({username: srcUserName, message: message, date: NowTime()}); isExist = true; break; } } if (!isExist) { //追加聊天對象 msgObjArr.push({ toUserName: tarUserName, message: [{username: srcUserName, message: message, date: NowTime()}]//封裝數據[{username:huanzi,message:"你好,我是歡子!",date:2018-04-29 22:48:00}] }); } } else { //追加聊天對象 msgObjArr.push({ toUserName: tarUserName, message: [{username: srcUserName, message: message, date: NowTime()}]//封裝數據[{username:huanzi,message:"你好,我是歡子!",date:2018-04-29 22:48:00}] }); } } //監聽點擊用戶 $("body").on("click", ".hz-group-list", function () { $(".hz-group-list").css("background-color", ""); $(this).css("background-color", "whitesmoke"); $("#toUserName").text($(this).find(".hz-group-list-username").text()); //清空舊數據,從對象中取出並追加 $("#hz-message-body").empty(); $("#hz-badge-" + $("#toUserName").text()).text("0"); $("#hz-badge-" + $("#toUserName").text()).css("opacity", "0"); if (msgObjArr.length > 0) { for (var i = 0; i < msgObjArr.length; i++) { var obj = msgObjArr[i]; if (obj.toUserName == $("#toUserName").text()) { //追加數據 var messageArr = obj.message; if (messageArr.length > 0) { for (var j = 0; j < messageArr.length; j++) { var msgObj = messageArr[j]; var leftOrRight = "right"; var message = msgObj.message; var msgUserName = msgObj.username; var toUserName = $("#toUserName").text(); //當聊天窗口與msgUserName的人相同,文字在左邊(對方/其餘人),不然在右邊(本身) if (msgUserName == toUserName) { leftOrRight = "left"; } //可是若是點擊的是本身,羣聊的邏輯就不太同樣了 if (username == toUserName && msgUserName != toUserName) { leftOrRight = "left"; } if (username == toUserName && msgUserName == toUserName) { leftOrRight = "right"; } var magUserName = leftOrRight == "left" ? "<p class='hz-message-list-username'>"+msgUserName+":</p>" : ""; $("#hz-message-body").append( "<div class=\"hz-message-list\">" + magUserName+ "<div class=\"hz-message-list-text " + leftOrRight + "\">" + "<span>" + message + "</span>" + "</div>" + "<div style=\" clear: both; \"></div>" + "</div>"); } } break; } } } }); //獲取當前時間 function NowTime() { var time = new Date(); var year = time.getFullYear();//獲取年 var month = time.getMonth() + 1;//或者月 var day = time.getDate();//或者天 var hour = time.getHours();//獲取小時 var minu = time.getMinutes();//獲取分鐘 var second = time.getSeconds();//或者秒 var data = year + "-"; if (month < 10) { data += "0"; } data += month + "-"; if (day < 10) { data += "0" } data += day + " "; if (hour < 10) { data += "0" } data += hour + ":"; if (minu < 10) { data += "0" } data += minu + ":"; if (second < 10) { data += "0" } data += second; return data; }
java代碼有三個類,MyEndpointConfigure,WebSocketConfig,WebSocketServer;
MyEndpointConfigure
/** * 解決注入其餘類的問題,詳情參考這篇帖子:webSocket沒法注入其餘類:https://blog.csdn.net/tornadojava/article/details/78781474 */ public class MyEndpointConfigure extends ServerEndpointConfig.Configurator implements ApplicationContextAware { private static volatile BeanFactory context; @Override public <T> T getEndpointInstance(Class<T> clazz){ return context.getBean(clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { MyEndpointConfigure.context = applicationContext; } }
WebSocketConfig
/** * WebSocket配置 */ @Configuration public class WebSocketConfig{ /** * 用途:掃描並註冊全部攜帶@ServerEndpoint註解的實例。 @ServerEndpoint("/websocket") * PS:若是使用外部容器 則無需提供ServerEndpointExporter。 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } /** * 支持注入其餘類 */ @Bean public MyEndpointConfigure newMyEndpointConfigure (){ return new MyEndpointConfigure (); } }
WebSocketServer
/** * WebSocket服務 */ @RestController @RequestMapping("/websocket") @ServerEndpoint(value = "/websocket/{username}", configurator = MyEndpointConfigure.class) public class WebSocketServer { /** * 在線人數 */ private static int onlineCount = 0; /** * 在線用戶的Map集合,key:用戶名,value:Session對象 */ private static Map<String, Session> sessionMap = new HashMap<String, Session>(); /** * 注入其餘類(換成本身想注入的對象) */ @Autowired private UserService userService; /** * 鏈接創建成功調用的方法 */ @OnOpen public void onOpen(Session session, @PathParam("username") String username) { //在webSocketMap新增上線用戶 sessionMap.put(username, session); //在線人數加加 WebSocketServer.onlineCount++; //通知除了本身以外的全部人 sendOnlineCount(session, "{'type':'onlineCount','onlineCount':" + WebSocketServer.onlineCount + ",username:'" + username + "'}"); } /** * 鏈接關閉調用的方法 */ @OnClose public void onClose(Session session) { //下線用戶名 String logoutUserName = ""; //從webSocketMap刪除下線用戶 for (Entry<String, Session> entry : sessionMap.entrySet()) { if (entry.getValue() == session) { sessionMap.remove(entry.getKey()); logoutUserName = entry.getKey(); break; } } //在線人數減減 WebSocketServer.onlineCount--; //通知除了本身以外的全部人 sendOnlineCount(session, "{'type':'onlineCount','onlineCount':" + WebSocketServer.onlineCount + ",username:'" + logoutUserName + "'}"); } /** * 服務器接收到客戶端消息時調用的方法 */ @OnMessage public void onMessage(String message, Session session) { try { //JSON字符串轉 HashMap HashMap hashMap = new ObjectMapper().readValue(message, HashMap.class); //消息類型 String type = (String) hashMap.get("type"); //來源用戶 Map srcUser = (Map) hashMap.get("srcUser"); //目標用戶 Map tarUser = (Map) hashMap.get("tarUser"); //若是點擊的是本身,那就是羣聊 if (srcUser.get("username").equals(tarUser.get("username"))) { //羣聊 groupChat(session,hashMap); } else { //私聊 privateChat(session, tarUser, hashMap); } //後期要作消息持久化 } catch (IOException e) { e.printStackTrace(); } } /** * 發生錯誤時調用 */ @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } /** * 通知除了本身以外的全部人 */ private void sendOnlineCount(Session session, String message) { for (Entry<String, Session> entry : sessionMap.entrySet()) { try { if (entry.getValue() != session) { entry.getValue().getBasicRemote().sendText(message); } } catch (IOException e) { e.printStackTrace(); } } } /** * 私聊 */ private void privateChat(Session session, Map tarUser, HashMap hashMap) throws IOException { //獲取目標用戶的session Session tarUserSession = sessionMap.get(tarUser.get("username")); //若是不在線則發送「對方不在線」回來源用戶 if (tarUserSession == null) { session.getBasicRemote().sendText("{\"type\":\"0\",\"message\":\"對方不在線\"}"); } else { hashMap.put("type", "1"); tarUserSession.getBasicRemote().sendText(new ObjectMapper().writeValueAsString(hashMap)); } } /** * 羣聊 */ private void groupChat(Session session,HashMap hashMap) throws IOException { for (Entry<String, Session> entry : sessionMap.entrySet()) { //本身就不用再發送消息了 if (entry.getValue() != session) { hashMap.put("type", "2"); entry.getValue().getBasicRemote().sendText(new ObjectMapper().writeValueAsString(hashMap)); } } } /** * 登陸 */ @RequestMapping("/login/{username}") public ModelAndView login(HttpServletRequest request, @PathVariable String username) { return new ModelAndView("socketChart.html", "username", username); } /** * 登出 */ @RequestMapping("/logout/{username}") public String loginOut(HttpServletRequest request, @PathVariable String username) { return "退出成功!"; } /** * 獲取在線用戶 */ @RequestMapping("/getOnlineList") private List<String> getOnlineList(String username) { List<String> list = new ArrayList<String>(); //遍歷webSocketMap for (Entry<String, Session> entry : WebSocketServer.sessionMap.entrySet()) { if (!entry.getKey().equals(username)) { list.add(entry.getKey()); } } return list; } }
後期把全部功能都補全就完美了,表情、圖片都算比較簡單,以前用輪詢實現的時候寫過了,可是沒加到這裏來;音視頻聊天的話能夠用WbeRTC來作,以前也研究了一下,不過還沒搞完,這裏貼一下維基百科對它的介紹,想了解更多的自行Google:
WebRTC,名稱源自網頁即時通訊(英語:Web Real-Time Communication)的縮寫,是一個支持網頁瀏覽器進行實時語音對話或視頻對話的API。它於2011年6月1日開源並在Google、Mozilla、Opera支持下被歸入萬維網聯盟的W3C推薦標準。
最後在加上持久化存儲,註冊後才能聊天,離線消息上線後接收,再加上用Redis或者其餘的緩存技術支持,完美。不過聊天記錄要作存儲,表設計不知如何設計才合理,若是哪位大佬願意分享能夠留言給我,你們一塊兒進步!
2019-07-03補充:這裏補充貼出pom代碼,在子類引入父類,若是咱們沒有父類,只有一個子類,把兩個整合一下就能夠了
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>cn.huanzi.qch</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.0.RELEASE</version> <relativePath/> </parent> <description>SpringBoot系列demo代碼</description> <!-- 在父類引入一下通用的依賴 --> <dependencies> <!-- spring-boot-starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- springboot web(MVC)--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- springboot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--lombok插件 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--熱部署工具dev-tools--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> <scope>runtime</scope> </dependency> </dependencies> <!--構建工具--> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <finalName>${project.artifactId}</finalName> <outputDirectory>../package</outputDirectory> </configuration> </plugin> </plugins> </build> </project>
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <artifactId>springboot-websocket</artifactId> <version>0.0.1</version> <name>springboot-websocket</name> <description>SpringBoot系列——WebSocket</description> <!--繼承父類--> <parent> <groupId>cn.huanzi.qch</groupId> <artifactId>parent</artifactId> <version>1.0.0</version> </parent> <dependencies> <!-- springboot websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
在後記的部分咱們就提到要加上持久化存儲,事實上咱們已經開始慢慢在寫一套簡單的IM即時通信,已經實現到第三版了,持續更新中...
代碼已經開源、託管到個人GitHub、碼雲: