SSO單點登錄詳解-------四、手寫單點登錄客戶端和服務端

一、前言

我們自己動手寫單點登錄的服務端目的是爲了加深對單點登錄的理解.如果你們公司想實現單點登錄/單點註銷功能,推薦使用開源的單點登錄框架CAS.我們後面的章節也會帶同學們快速搭建CAS Server和CAS Client的環境.

二、條件

如果沒看前面章節的同學,請返回去觀看這幾章內容,不然這代碼是不太好理解的.

  • SSO單點登錄教程(一)多系統的複雜性
  • SSO單點登錄教程(二)單點登錄流程分析
  • SSO單點登錄教程(三)單點註銷流程分析

三、環境要求

  • JDK1.7+
  • Maven3.3
  • Eclipse/IDEA

四、準備工作

因爲我們主要講的是跨域的單點登錄,所以我們需要把不同項目部署到不同域名下.不可能爲了完成這個代碼,讓同學們去阿里雲買三臺主機,映射三個IP.所以我們的實驗就在本機來實現.我們需要修改host文件,讓三個域名映射到本機.
host文件存放的位置:C:\Windows\System32\drivers\etc
打開host文件之後,在最後追加如下配置:

127.0.0.1 www.sso.com
127.0.0.1 www.crm.com
127.0.0.1 www.wms.com

這段配置的意思是,我們在瀏覽器中輸入:
http://www.sso.com
http://www.crm.com
http://www.wms.com
其實訪問的都是本機:127.0.0.1

PS:有些同學打開這個文件之後,保存的時候可能被拒絕.原因可能是權限不夠.解決方法:把host文件拷貝到桌面(有權限的地方即可),修改好之後再把:C:\Windows\System32\drivers\etc的host文件覆蓋.

五、下載基礎項目

基礎項目代碼下載鏈接在頁面底部.

我在github上傳的是maven結構的項目.如果需要導入到Eclipse/IDEA中需要生成對應的Eclipse/IDEA的配置文件.
cmd命令進入到項目的根目錄 $項目存放位置/sso-server-base-project

  • 如果是Eclipse,運行mvn eclipse:eclipse
  • 如果是IDEA,運行mvn idea:idea

處理好之後,把項目導入到工具中,我們就可以開始開發了.

六、項目結構說明

服務端
sso-server-base-project目錄
  src
      main
        java
        resources
           -applicationContext.xml
        webapp
          static
          WEB-INF
              views
                -login.jsp
                -logOut.jsp
              -web.xml
  -pom.xml  

服務端項目就只配置了SpringMVC的環境.
pom.xml:項目的pom文件,已經配置的Tomcat插件端口爲:8443
applicationContext.xml:spring配置文件
static:靜態資源目錄,存放css,js
login.jsp:登陸頁面
logOut.jsp:登出頁面
web.xml:web的配置文件,配置前端請求DispatherServlet

客戶端
sso-client-base-project目錄
  src
      main
        java
          -cn.wolfcode.sso.controller.MainServlet.java
          -cn.wolfcode.sso.controller.LogOutServlet.java
        webapp
          WEB-INF
              views
                -main.jsp
              -web.xml

客戶端沒有使用Spring框架.使用Servlet3.0

@WebServlet(name = "mainServlet", urlPatterns = "/main")

在Servlet類上貼這個註解就可以進行映射.
MainServlet.java:處理主頁請求/main的servlet.
LogOutServlet.java:處理登出的請求/logOut的servlet
main.jsp:首頁

客戶端項目導入之後,運行tomcat7:run命令,在瀏覽器中輸入
http://www.crm.com:8088/main
會看到如下界面:

CRM項目首頁

七、執行流程圖

我們代碼的開發就參考着單點登錄流程圖來實現,所以我在這也把這張圖放過來.

單點登錄原理

八、代碼實現

準備階段:

一:在resources目錄創建sso.properties,內容如下:

#統一認證中心的地址
server-url-prefix=http://www.sso.com:8443
#本項目的地址
client-host-url=http://www.crm.com:8088

二:添加工具類.

我們在後續的開發中需要使用這個工具類,寫得比較簡單,可以先看看,我們用到再給同學們解釋啥意思.

二:添加工具類.
我們在後續的開發中需要使用的工具類。

SSOClientUtil.java

package cn.wolfcode.sso.util;

import java.io.IOException;
import java.util.Properties;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SSOClientUtil {
    private static Properties ssoProperties = new Properties();
    public static String SERVER_URL_PREFIX;//統一認證中心地址:http://www.sso.com:8443,在sso.properties配置
    public static String CLIENT_HOST_URL;//當前客戶端地址:http://www.crm.com:8088,在sso.properties配置
    static{
        try {
            ssoProperties.load(SSOClientUtil.class.getClassLoader().getResourceAsStream("sso.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        SERVER_URL_PREFIX = ssoProperties.getProperty("server-url-prefix");
        CLIENT_HOST_URL = ssoProperties.getProperty("client-host-url");
    }
    /**
     * 當客戶端請求被攔截,跳往統一認證中心,需要帶redirectUrl的參數,統一認證中心登錄後回調的地址
     * 通過Request獲取這次請求的地址 http://www.crm.com:8088/main
     * 
     * @param request
     * @return
     */
    public static String getRedirectUrl(HttpServletRequest request){
        //獲取請求URL
        return CLIENT_HOST_URL+request.getServletPath();
    }
    /**
     * 根據request獲取跳轉到統一認證中心的地址 http://www.sso.com:8443//checkLogin?redirectUrl=http://www.crm.com:8088/main
     * 通過Response跳轉到指定的地址
     * @param request
     * @param response
     * @throws IOException
     */
    public static void redirectToSSOURL(HttpServletRequest request,HttpServletResponse response) throws IOException {
        String redirectUrl = getRedirectUrl(request);
        StringBuilder url = new StringBuilder(50)
                .append(SERVER_URL_PREFIX)
                .append("/checkLogin?redirectUrl=")
                .append(redirectUrl);
        response.sendRedirect(url.toString());
    }
    
    
    /**
     * 獲取客戶端的完整登出地址 http://www.crm.com:8088/logOut
     * @return
     */
    public static String getClientLogOutUrl(){
        return CLIENT_HOST_URL+"/logOut";
    }
    /**
     * 獲取認證中心的登出地址 http://www.sso.com:8443/logOut
     * @return
     */
    public static String getServerLogOutUrl(){
        return SERVER_URL_PREFIX+"/logOut";
    }
}

HttpUtil.java

package cn.wolfcode.sso.util;

import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Map.Entry;

import org.springframework.util.StreamUtils;

public class HttpUtil {
    /**
     * 模擬瀏覽器的請求
     * @param httpURL 發送請求的地址
     * @param params  請求參數
     * @return
     * @throws Exception
     */
    public static String sendHttpRequest(String httpURL,Map<String,String> params) throws Exception{
        //建立URL連接對象
        URL url = new URL(httpURL);
        //創建連接
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        //設置請求的方式(需要是大寫的)
        conn.setRequestMethod("POST");
        //設置需要輸出
        conn.setDoOutput(true);
        //判斷是否有參數.
        if(params!=null&&params.size()>0){
            StringBuilder sb = new StringBuilder();
            for(Entry<String,String> entry:params.entrySet()){
                sb.append("&").append(entry.getKey()).append("=").append(entry.getValue());
            }
            //sb.substring(1)去除最前面的&
            conn.getOutputStream().write(sb.substring(1).toString().getBytes("utf-8"));
        }
        //發送請求到服務器
        conn.connect();
        //獲取遠程響應的內容.
        String responseContent = StreamUtils.copyToString(conn.getInputStream(),Charset.forName("utf-8"));
        conn.disconnect();
        return responseContent;
    }
    /**
     * 模擬瀏覽器的請求
     * @param httpURL 發送請求的地址
     * @param jesssionId 會話Id
     * @return
     * @throws Exception
     */
    public static void sendHttpRequest(String httpURL,String jesssionId) throws Exception{
        //建立URL連接對象
        URL url = new URL(httpURL);
        //創建連接
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        //設置請求的方式(需要是大寫的)
        conn.setRequestMethod("POST");
        //設置需要輸出
        conn.setDoOutput(true);
        conn.addRequestProperty("Cookie","JSESSIONID="+jesssionId);
        //發送請求到服務器
        conn.connect();
        conn.getInputStream();
        conn.disconnect();
    }
}

階段一:

階段一代碼下載鏈接在頁面底部.

第一階段我們先完成,攔截客戶端的請求,判斷是否有局部會話,沒有局部會話就重定向到統一認證中心的登陸界面.

需求分析:
我們要在客戶端攔截請求,應該使用啥技術呢?如果使用的是Spring框架,我們可以使用攔截器.但我們的客戶端啥框架都沒用.要攔截請求,可以使用過濾器Filter.

客戶端
創建:SSOClientFilter.java,實現javax.servlet.Filter接口,並貼上Servlet3.0的註解

@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
  ....
}

步驟:
1.判斷是否有局部會話
2.如果有局部會話,直接放行
3.如果沒有,重定向到統一認證中心的checkLogin方法,檢查是否有全局會話.

package cn.wolfcode.sso.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import cn.wolfcode.sso.util.SSOClientUtil;
@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession();
        //1.判斷是否有局部的會話
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin!=null && isLogin){
            //有局部會話,直接放行.
            chain.doFilter(request, response);
            return;
        }
        //沒有局部會話,重定向到統一認證中心,檢查是否有其他的系統已經登錄過.
        // http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
        //這是我們自己寫工具類的方法,同學們可以自己看一下,很簡單能看懂的.
        SSOClientUtil.redirectToSSOURL(req, resp);
    }

    @Override
    public void destroy() {}
}

服務端

步驟:

  1. 接受重定向過來的checkLogin請求.判斷是否有全局的會話
  2. 如果沒有全局會話,獲取地址欄的redirectUrl參數,放入到request域中.並轉發到登陸頁面.
  3. 如果有全局會話,目前還沒到這個階段,這個邏輯我們先不寫.我們先按執行流程來寫代碼.

在java目錄創建SSOServerController.java,並貼上@Controller註解

@Controller
public class SSOServerController {
}

編寫checkLogin方法.

package cn.wolfcode.sso.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpSession;

/**
 * Created by wolfcode-lanxw
 */
@Controller
public class SSOServerController {
    /**
     * 檢查是否有全局會話.
     * @param redirectUrl 客戶端被攔截的請求地址
     * @param session      統一認證中心的會話對象
     * @param model        數據模型
     * @return              視圖地址
     */
    @RequestMapping("/checkLogin")
    public String checkLogin(String redirectUrl, HttpSession session, Model model){
        //1.判斷是否有全局的會話
        //從會話中獲取令牌信息,如果取不到說明沒有全局會話,如果能取到說明有全局會話
        String token = (String) session.getAttribute("token");
        if(StringUtils.isEmpty(token)){
            //表示沒有全局會話
            model.addAttribute("redirectUrl",redirectUrl);
            //跳轉到統一認證中心的登陸頁面.已經配置視圖解析器,
            // 會找/WEB-INF/views/login.jsp視圖
            return "login";
        }else{
            //有全局會話
            //目前這段邏輯我們先不寫,按着圖解流程編寫代碼
            return "";
        }
    }
}
測試:

服務端和客戶端代碼寫好之後,兩個項目都運行tomcat7:run的命令.
在瀏覽器地址欄輸入:
www.crm.com:8088/main
發現我們的這個請求被攔截了,跳轉到了統一認證中心的登陸界面.如下圖所示:

在這裏插入圖片描述

統一認證中心登錄頁

階段二:

基礎項目代碼下載鏈接在頁面底部.

服務端:

步驟:
1.編寫登陸方法,實現認證功能.
2.認證通過,創建令牌.
3.創建全局會話存儲令牌信息
4.把令牌存入到數據庫t_token表中.

爲了減低學習的難度,我們這個案例裏面就不去連接數據庫(當然要連接數據庫也不難),我們的認證就使用靜態的認證(賬戶名:zhangsan,密碼:666).
我們使用java中的Set集合來模擬t_token表.
創建MockDatabaseUtil.java來模擬數據庫

package cn.wolfcode.sso.util;
import java.util.*;
/**
 * Created by wolfcode-lanxw
 */
public class MockDatabaseUtil {
    //模擬數據庫中的t_token表
    public static Set<String> T_TOKEN = new HashSet<String>();
}

編寫統一認證中心的登陸方法,在SSOServerController.java中添加login方法.

/**
     * 登陸方法
     * @param username      前臺登陸的用戶名
     * @param password      前臺登陸的密碼
     * @param redirectUrl   客戶端被攔截的地址
     * @param session       服務端會話對象
     * @param model         模型數據
     * @return               響應的視圖地址
     */
    @RequestMapping("/login")
    public String login(String username,String password,String redirectUrl,HttpSession session,Model model){
        if("zhangsan".equals(username)&&"666".equals(password)){
            //賬號密碼匹配
            //1.創建令牌信息,只要保證唯一即可,我們就使用UUID.
            String token = UUID.randomUUID().toString();
            //2.創建全局的會話,把令牌信息放入會話中.
            session.setAttribute("token",token);
            //3.需要把令牌信息放到數據庫中.
            MockDatabaseUtil.T_TOKEN.add(token);
            //4.重定向到redirectUrl,把令牌信息帶上.  http://www.crm.com:8088/main?token=
            model.addAttribute("token",token);
            return "redirect:"+redirectUrl;
        }
        //如果賬號密碼有誤,重新回到登錄頁面,還需要把redirectUrl放入request域中.
        model.addAttribute("redirectUrl",redirectUrl);
        return "login";
    }
客戶端:

1.統一認證中心登錄成功之後,會重定向到之前客戶端被攔截的地址,並會把令牌信息在地址欄中作爲參數http://www.crm.com:8088/main?token=VcnVMguCDWJX5zHa
此時訪問的是客戶端的地址,這個地址會被SSOClientFilter攔截到.
我們在Filter裏面需要判斷用戶地址欄中是否有攜帶token信息,如果有,說明擁有令牌信息.但是我們得校驗令牌token的有效性,使用HttpUrlConnection發送請求到統一認證中心進行校驗.
2.如果統一認證中心給我們返回true,表示令牌有效.
3.我們創建局部會話,並放行請求.

SSOClientFilter.java中添加如下代碼

public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession();
        //1.判斷是否有局部的會話
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin!=null && isLogin){
            //有局部會話,直接放行.
            chain.doFilter(request, response);
            return;
        }
        /**-------------------------階段二添加的代碼start---------------------------------**/
        //判斷地址欄中是否有攜帶token參數.
        String token = req.getParameter("token");
        if(StringUtils.isNoneBlank(token)){
            //token信息不爲null,說明地址中包含了token,擁有令牌.
            //判斷token信息是否由認證中心產生的.
            //驗證地址爲:http://www.sso.com:8443/verify
            String httpURL = SSOClientUtil.SERVER_URL_PREFIX+"/verify";
            Map<String,String> params = new HashMap<String,String>();
            //把客戶端地址欄添加到的token信息傳遞給統一認證中心進行校驗
            params.put("token", token);
            try {
                String isVerify = HttpUtil.sendHttpRequest(httpURL, params);
                if("true".equals(isVerify)){
                    //如果返回的字符串是true,說明這個token是由統一認證中心產生的.
                    //創建局部的會話.
                    session.setAttribute("isLogin", true);
                    //放行該次的請求
                    chain.doFilter(request, response);
                    return;
                }
            } catch (Exception e) {
                //這裏可以完善,比如出現異常在前臺顯示具體頁面
                //我們這個案例就不做這個哈.
                e.printStackTrace();
            }
        }
        /**-------------------------階段二添加的代碼end---------------------------------**/
        //沒有局部會話,重定向到統一認證中心,檢查是否有其他的系統已經登錄過.
        // http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
        SSOClientUtil.redirectToSSOURL(req, resp);
    }
服務端:

1.需要在統一認證中心添加一個認證令牌信息的方法.
SSOServerController.java中添加verifyToken方法,具體代碼如下:

 /**
     * 校驗客戶端傳過來的令牌信息是否有效
     * @param token 客戶端傳過來的令牌信息
     * @return
     */
    @RequestMapping("/verify")
    @ResponseBody
    public String verifyToken(String token){
        //在模擬的數據庫表t_token中查找是否有這條記錄
        if(MockDatabaseUtil.T_TOKEN.contains(token)){
            //說明令牌有效,返回true
            return "true";
        }
        return "false";
    }
測試:

到這裏爲止,階段二代碼就搞定了.單點登錄功能的95%代碼完成.
客戶端和服務端都運行tomcat7:run命令
在瀏覽器中按下Ctrl+Shift+Delete按鍵清楚cookie和緩存,避免干擾.
在瀏覽器中輸入:http://www.crm.com:8088/main,瀏覽器跳轉到統一認證中心的登陸頁面.輸入zhangsan:666,點擊登陸.此時就訪問到了CRM系統的首頁.界面如下.

在這裏插入圖片描述

CRM系統首頁

階段三:

階段三代碼下載鏈接在頁面底部.
在前面的代碼我們完成了單系統的登陸,現在我們先看看如果在多系統的環境下,我們是否能實現多系統的下一次登陸,處處運行的功能.

客戶端:

1.拷貝sso-client-base-project項目,命名爲sso-client-base-project2
2.修改新項目的pom.xml文件第41行,Tomcat插件的啓動端口,修改爲:8089
3.修改sso.properties文件,修改如下:

server-url-prefix=http://www.sso.com:8443
client-host-url=http://www.wms.com:8089

4.修改/WEB-INF/views/main.jsp的標題,和內容,主要方便測試的時候看到不同的效果.(可改可不改)

服務端:

需要完善checkLogin方法,添加如果有全局會話的邏輯.

  @RequestMapping("/checkLogin")
    public String checkLogin(String redirectUrl, HttpSession session, Model model){
        //1.判斷是否有全局的會話
        //從會話中獲取令牌信息,如果取不到說明沒有全局會話,如果能取到說明有全局會話
        String token = (String) session.getAttribute("token");
        if(StringUtils.isEmpty(token)){
            //表示沒有全局會話
            model.addAttribute("redirectUrl",redirectUrl);
            //跳轉到統一認證中心的登陸頁面.已經配置視圖解析器,
            // 會找/WEB-INF/views/login.jsp視圖
            return "login";
        }else{
            /**---------------------------階段三添加的代碼start--------------------**/
            //有全局會話
            //取出令牌信息,重定向到redirectUrl,把令牌帶上  
            // http://www.wms.com:8089/main?token=
            model.addAttribute("token",token);
            /**---------------------------階段三添加的代碼end-----------------------**/
            return "redirect:"+redirectUrl;
        }
    }
測試:

在服務端和兩個客戶端運行tomcat7:run命令.
在瀏覽器中按下Ctrl+Shift+Delete按鍵清楚cookie和緩存,避免干擾.
在瀏覽器中輸入:http://www.crm.com:8088/main,瀏覽器跳轉到統一認證中心的登陸頁面.輸入zhangsan:666,點擊登陸.此時就訪問到了CRM系統的首頁.說明已經登錄成功.
接着瀏覽器中輸入:http://www.wms.com:8089/main,發現這次請求就不需要登陸,可以直接訪問了.到此爲止,我們就完成單點登錄所有的代碼.可以實現一次登陸,處處穿梭.

九、單點登錄步驟梳理:

客戶端
1.攔截客戶端的請求判斷是否有局部的session
    
    2.1如果有局部的session,放行請求.
    
    2.2如果沒有局部session
        
          2.2.1請求中有攜帶token參數

                    2.2.1.1如果有,使用HttpURLConnection發送請求校驗token是否有效.
       
                                  2.2.1.1.1如果token有效,建立局部的session.

                                  2.2.1.1.2如果token無效,重定向到統一認證中心頁面進行登陸.

                    2.2.1.2如果沒有,重定向到統一認證中心頁面進行登陸.

         2.2.2請求中沒有攜帶token參數,重定向到統一認證中心頁面進行登陸.

服務端
1.檢測客戶端在服務端是否已經登錄了.(checkLogin方法)
    1.1獲取session中的token.
    1.2如果token不爲空,說明服務端已經登錄過了,此時重定向到客戶端的地址,並把token帶上
    1.3如果token爲空,跳轉到統一認證中心的的登錄頁面,並把redirectUrl放入到request域中.

2.統一認證中心的登錄方法(login方法)
    2.1判斷用戶提交的賬號密碼是否正確.
    2.2如果正確
        2.2.1創建token(可以使用UUID,保證唯一就可以)
        2.2.2把token放入到session中,還需要把token放入到數據庫表t_token中
        2.2.3這個token要知道有哪些客戶端登陸了,存入數據庫t_client_info表中.);
        2.2.4轉發到redirectUrl地址,把token帶上.
    2.3如果錯誤
        轉發到login.jsp,還需要把redirectUrl參數放入到request域中.

3.統一認證中心認證token方法(verifyToken方法),返回值爲String,@ResponseBody
    3.1如果MockDatabaseUtil.T_TOKEN.contains(token)結果爲true,說明token是有效的.
        3.1.1返回true字符串.
    3.1如果MockDatabaseUtil.T_TOKEN.contains(token)結果爲false,說明token是無效的,返回false字符串.

十、代碼下載

0.初始項目Demo

熟悉git命令的同學:

客戶端的基礎項目:

git clone git@github.com:javalanxiongwei/sso-client-base-project.git
cd sso-client-base-project/
git reset --hard 8401333ea845eb32e5f6091e7326ada1983d1ea3

服務頓的基礎項目:

git clone git@github.com:javalanxiongwei/sso-server-base-project.git
cd sso-server-base-project/
git reset --hard 6334d9afa08b3d5fc886ad212b3ec62376f5ff32
不熟悉git命令的同學

客戶端的基礎項目
服務端的基礎項目


1.階段一Demo

熟悉git命令的同學:

客戶端階段一:

git reset --hard b53e0234895b2044ed3042f8f856676c69160281

服務頓階段一:

git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45
不熟悉git命令的同學

客戶端階段一
服務頓階段一


2.階段二Demo

熟悉git命令的同學:

客戶端階段二:

git reset --hard b53e0234895b2044ed3042f8f856676c69160281

服務頓階段二:

git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45
不熟悉git命令的同學

客戶端階段二
服務端階段二


3.階段三Demo

熟悉git命令的同學:

客戶端2階段三下載:

git clone git@github.com:javalanxiongwei/sso-client-base-project2.git
cd sso-client-base-project2/
git reset --hard 01db6af390ff9f765121d3f9e9b1895b0e671bd5

服務頓階段三:

git reset --hard 80e7ad5a1d67b5d63d00e3532fed9ef58fe74fd9
不熟悉git命令的同學

客戶端2階段三
服務端階段三



原文鏈接:https://www.jianshu.com/p/79cfab236877

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章