手写SpringMVC 框架
手写SpringMVC框架
细嗅蔷薇 心有猛虎
背景:Spring 想必大家都听说过,可能现在更多流行的是Spring Boot 和Spring Cloud 框架;但是SpringMVC 作为一款实现了MVC 设计模式的web (表现层) 层框架,其高开发效率和高性能也是现在很多公司仍在采用的框架;除此之外,Spring 源码大师级的代码规范和设计思想都十分值得学习;退一步说,Spring Boot 框架底层也有很多Spring 的东西,而且面试的时候还会经常被问到SpringMVC 原理,一般人可能也就是只能把SpringMVC 的运行原理背出来罢了,至于问到有没有了解其底层实现(代码层面),那很可能就歇菜了,但您要是可以手写SpringMVC 框架就肯定可以令面试官刮目相看,所以手写SpringMVC 值得一试。
在设计自己的SpringMVC 框架之前,需要了解下其运行流程。
一、SpringMVC 运行流程
图1. SpringMVC 运行流程

1、用户向服务器发送请求,请求被Spring 前端控制器DispatcherServlet 捕获;
2、DispatcherServlet 收到请求后调用HandlerMapping 处理器映射器;
3、处理器映射器对请求URL 进行解析,得到请求资源标识符(URI);然后根据该URI,调用HandlerMapping 获得该Handler 配置的所有相关的对象(包括Handler 对象以及Handler 对象对应的拦截器),再以HandlerExecutionChain 对象的形式返回给DispatcherServlet;
4、DispatcherServlet 根据获得的Handler,通过HandlerAdapter 处理器适配器选择一个合适的HandlerAdapter;(附注:如果成功获得HandlerAdapter 后,此时将开始执行拦截器的preHandler(...)方法);
5、提取Request 中的模型数据,填充Handler 入参,开始执行Handler(即Controller);【在填充Handler的入参过程中,根据你的配置,Spring 将帮你做一些额外的工作如:HttpMessageConveter:将请求消息(如Json、xml等数据)转换成一个对象,将对象转换为指定的响应信息;数据转换:对请求消息进行数据转换,如String转换成Integer、Double等;数据格式化:对请求消息进行数据格式化,如将字符串转换成格式化数字或格式化日期等;数据验证:验证数据的有效性(长度、格式等),验证结果存储到BindingResult或Error中 】
6、Controller 执行完成返回ModelAndView 对象;
7、HandlerAdapter 将controller 执行结果ModelAndView 对象返回给DispatcherServlet;
8、DispatcherServlet 将ModelAndView 对象传给ViewReslover 视图解析器;
9、ViewReslover 根据返回的ModelAndView,选择一个适合的ViewResolver (必须是已经注册到Spring容器中的ViewResolver)返回给DispatcherServlet;
10、DispatcherServlet 对View 进行渲染视图(即将模型数据填充至视图中);
11、DispatcherServlet 将渲染结果响应用户(客户端)。
二、SpringMVC 框架设计思路
1、读取配置阶段
图2. SpringMVC 继承关系

第一步就是配置web.xml,加载自定义的DispatcherServlet。而从图中可以看出,SpringMVC 本质上是一个Servlet,这个Servlet 继承自HttpServlet,此外,FrameworkServlet 负责初始SpringMVC的容器,并将Spring 容器设置为父容器;为了读取web.xml 中的配置,需要用到ServletConfig 这个类,它代表当前Servlet 在web.xml 中的配置信息,然后通过web.xml 中加载我们自己写的MyDispatcherServlet 和读取配置文件。
2、初始化阶段
初始化阶段会在DispatcherServlet 类中,按顺序实现下面几个步骤:
1、加载配置文件;
2、扫描当前项目下的所有文件;
3、拿到扫描到的类,通过反射机制将其实例化,并且放到ioc 容器中(Map的键值对 beanName-bean) beanName默认是首字母小写;
4、初始化path 与方法的映射;
5、获取请求传入的参数并处理参数通过初始化好的handlerMapping 中拿出url 对应的方法名,反射调用。
3、运行阶段
运行阶段,每一次请求将会调用doGet 或doPost 方法,它会根据url 请求去HandlerMapping 中匹配到对应的Method,然后利用反射机制调用Controller 中的url 对应的方法,并得到结果返回。
三、实现SpringMVC 框架
首先,小老弟SpringMVC 框架只实现自己的@Controller 和@RequestMapping 注解,其它注解功能实现方式类似,实现注解较少所以项目比较简单,可以看到如下工程文件及目录截图。
图3. 工程文件及目录

1、创建Java Web 工程
创建Java Web 工程,勾选JavaEE 下方的Web Application 选项,Next。
图4. 创建Java Web 工程

2、在工程WEB-INF 下的web.xml 中加入下方配置
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0"> <servlet>
<servlet-name>DispatcherServlet</servlet-name>
<servlet-class>com.tjt.springmvc.DispatcherServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>DispatcherServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping> </web-app>
3、创建自定义Controller 注解
package com.tjt.springmvc; import java.lang.annotation.*; /**
* @MyController 自定义注解类
*
* @@Target(ElementType.TYPE)
* 表示该注解可以作用在类上;
*
* @Retention(RetentionPolicy.RUNTIME)
* 表示该注解会在class 字节码文件中存在,在运行时可以通过反射获取到
*
* @Documented
* 标记注解,表示可以生成文档
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyController { /**
* public class MyController
* 把 class 替换成 @interface 该类即成为注解类
*/ /**
* 为Controller 注册别名
* @return
*/
String value() default ""; }
4、创建自定义RequestMapping 注解
package com.tjt.springmvc; import java.lang.annotation.*; /**
* @MyRequestMapping 自定义注解类
*
* @Target({ElementType.METHOD,ElementType.TYPE})
* 表示该注解可以作用在方法、类上;
*
* @Retention(RetentionPolicy.RUNTIME)
* 表示该注解会在class 字节码文件中存在,在运行时可以通过反射获取到
*
* @Documented
* 标记注解,表示可以生成文档
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyRequestMapping { /**
* public @interface MyRequestMapping
* 把 class 替换成 @interface 该类即成为注解类
*/ /**
* 表示访问该方法的url
* @return
*/
String value() default ""; }
5、设计用于获取项目工程下所有的class 文件的封装工具类
package com.tjt.springmvc; import java.io.File;
import java.io.FileFilter;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile; /**
* 从项目工程包package 中获取所有的Class 工具类
*/
public class ClassUtils { /**
* 静态常量
*/
private static String FILE_CONSTANT = "file";
private static String UTF8_CONSTANT = "UTF-8";
private static String JAR_CONSTANT = "jar";
private static String POINT_CLASS_CONSTANT = ".class";
private static char POINT_CONSTANT = '.';
private static char LEFT_LINE_CONSTANT = '/'; /**
* 定义私有构造函数来屏蔽隐式公有构造函数
*/
private ClassUtils() {
} /**
* 从项目工程包package 中获取所有的Class
* getClasses
*
* @param packageName
* @return
*/
public static List<Class<?>> getClasses(String packageName) throws Exception { List<Class<?>> classes = new ArrayList<Class<?>>(); // 定义一个class 类的泛型集合
boolean recursive = true; // recursive 是否循环迭代
String packageDirName = packageName.replace(POINT_CONSTANT, LEFT_LINE_CONSTANT); // 获取包的名字 并进行替换
Enumeration<URL> dirs; // 定义一个枚举的集合 分别保存该目录下的所有java 类文件及Jar 包等内容
dirs = Thread.currentThread().getContextClassLoader().getResources(packageDirName);
/**
* 循环迭代 处理这个目录下的things
*/
while (dirs.hasMoreElements()) {
URL url = dirs.nextElement(); // 获取下一个元素
String protocol = url.getProtocol(); // 得到协议的名称 protocol
// 如果是
/**
* 若protocol 是文件形式
*/
if (FILE_CONSTANT.equals(protocol)) {
String filePath = URLDecoder.decode(url.getFile(), UTF8_CONSTANT); // 获取包的物理路径
findAndAddClassesInPackageByFile(packageName, filePath, recursive, classes); // 以文件的方式扫描整个包下的文件 并添加到集合中
/**
* 若protocol 是jar 包文件
*/
} else if (JAR_CONSTANT.equals(protocol)) {
JarFile jar; // 定义一个JarFile
jar = ((JarURLConnection) url.openConnection()).getJarFile(); // 获取jar
Enumeration<JarEntry> entries = jar.entries(); // 从jar 包中获取枚举类
/**
* 循环迭代从Jar 包中获得的枚举类
*/
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement(); // 获取jar里的一个实体,如目录、META-INF等文件
String name = entry.getName();
/**
* 若实体名是以 / 开头
*/
if (name.charAt(0) == LEFT_LINE_CONSTANT) {
name = name.substring(1); // 获取后面的字符串
}
// 如果
/**
* 若实体名前半部分和定义的包名相同
*/
if (name.startsWith(packageDirName)) {
int idx = name.lastIndexOf(LEFT_LINE_CONSTANT);
/**
* 并且实体名以为'/' 结尾
* 若其以'/' 结尾则是一个包
*/
if (idx != -1) {
packageName = name.substring(0, idx).replace(LEFT_LINE_CONSTANT, POINT_CONSTANT); // 获取包名 并把'/' 替换成'.'
}
/**
* 若实体是一个包 且可以继续迭代
*/
if ((idx != -1) || recursive) {
if (name.endsWith(POINT_CLASS_CONSTANT) && !entry.isDirectory()) { // 若为.class 文件 且不是目录
String className = name.substring(packageName.length() + 1, name.length() - 6); // 则去掉.class 后缀并获取真正的类名
classes.add(Class.forName(packageName + '.' + className)); // 把获得到的类名添加到classes
}
}
}
}
}
} return classes;
} /**
* 以文件的形式来获取包下的所有Class
* findAndAddClassesInPackageByFile
*
* @param packageName
* @param packagePath
* @param recursive
* @param classes
*/
public static void findAndAddClassesInPackageByFile(
String packageName, String packagePath,
final boolean recursive,
List<Class<?>> classes) throws Exception { File dir = new File(packagePath); // 获取此包的目录并建立一个File if (!dir.exists() || !dir.isDirectory()) { // 若dir 不存在或者 也不是目录就直接返回
return;
} File[] dirfiles = dir.listFiles(new FileFilter() { // 若dir 存在 则获取包下的所有文件、目录 /**
* 自定义过滤规则 如果可以循环(包含子目录) 或则是以.class 结尾的文件(编译好的java 字节码文件)
* @param file
* @return
*/
@Override
public boolean accept(File file) {
return (recursive && file.isDirectory()) || (file.getName().endsWith(POINT_CLASS_CONSTANT));
}
}); /**
* 循环所有文件获取java 类文件并添加到集合中
*/
for (File file : dirfiles) {
if (file.isDirectory()) { // 若file 为目录 则继续扫描
findAndAddClassesInPackageByFile(packageName + "." + file.getName(), file.getAbsolutePath(), recursive,
classes);
} else { // 若file 为java 类文件 则去掉后面的.class 只留下类名
String className = file.getName().substring(0, file.getName().length() - 6);
classes.add(Class.forName(packageName + '.' + className)); // 把className 添加到集合中去 }
}
}
}
6、访问跳转页面index.jsp
<%--
Created by IntelliJ IDEA.
User: apple
Date: 2019-11-07
Time: 13:28
To change this template use File | Settings | File Templates.
--%>
<%--
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
--%>
<html>
<head>
<title>My Fucking SpringMVC</title>
</head>
<body>
<h2>The Lie We Live!</h2>
<H2>My Fucking SpringMVC</H2>
</body>
</html>
7、自定义DispatcherServlet 设计,继承HttpServlet,重写init 方法、doGet、doPost 等方法,以及自定义注解要实现的功能。
package com.tjt.springmvc; import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; /**
* DispatcherServlet 处理SpringMVC 框架流程
* 主要流程:
* 1、包扫描获取包下面所有的类
* 2、初始化包下面所有的类
* 3、初始化HandlerMapping 方法,将url 和方法对应上
* 4、实现HttpServlet 重写doPost 方法
*
*/
public class DispatcherServlet extends HttpServlet { /**
* 部分静态常量
*/
private static String PACKAGE_CLASS_NULL_EX = "包扫描后的classes为null";
private static String HTTP_NOT_EXIST = "sorry http is not exit 404";
private static String METHOD_NOT_EXIST = "sorry method is not exit 404";
private static String POINT_JSP = ".jsp";
private static String LEFT_LINE = "/"; /**
* 用于存放SpringMVC bean 的容器
*/
private ConcurrentHashMap<String, Object> mvcBeans = new ConcurrentHashMap<>();
private ConcurrentHashMap<String, Object> mvcBeanUrl = new ConcurrentHashMap<>();
private ConcurrentHashMap<String, String> mvcMethodUrl = new ConcurrentHashMap<>();
private static String PROJECT_PACKAGE_PATH = "com.tjt.springmvc"; /**
* 按顺序初始化组件
* @param config
*/
@Override
public void init(ServletConfig config) {
String packagePath = PROJECT_PACKAGE_PATH;
try {
//1.进行报扫描获取当前包下面所有的类
List<Class<?>> classes = comscanPackage(packagePath);
//2.初始化springmvcbean
initSpringMvcBean(classes);
} catch (Exception e) {
e.printStackTrace();
}
//3.将请求地址和方法进行映射
initHandMapping(mvcBeans);
} /**
* 调用ClassUtils 工具类获取工程中所有的class
* @param packagePath
* @return
* @throws Exception
*/
public List<Class<?>> comscanPackage(String packagePath) throws Exception {
List<Class<?>> classes = ClassUtils.getClasses(packagePath);
return classes;
} /**
* 初始化SpringMVC bean
*
* @param classes
* @throws Exception
*/
public void initSpringMvcBean(List<Class<?>> classes) throws Exception {
/**
* 若包扫描出的classes 为空则直接抛异常
*/
if (classes.isEmpty()) {
throw new Exception(PACKAGE_CLASS_NULL_EX);
} /**
* 遍历所有classes 获取@MyController 注解
*/
for (Class<?> aClass : classes) {
//获取被自定义注解的controller 将其初始化到自定义springmvc 容器中
MyController declaredAnnotation = aClass.getDeclaredAnnotation(MyController.class);
if (declaredAnnotation != null) {
//获取类的名字
String beanid = lowerFirstCapse(aClass.getSimpleName());
//获取对象
Object beanObj = aClass.newInstance();
//放入spring 容器
mvcBeans.put(beanid, beanObj);
}
} } /**
* 初始化HandlerMapping 方法
*
* @param mvcBeans
*/
public void initHandMapping(ConcurrentHashMap<String, Object> mvcBeans) {
/**
* 遍历springmvc 获取注入的对象值
*/
for (Map.Entry<String, Object> entry : mvcBeans.entrySet()) {
Object objValue = entry.getValue();
Class<?> aClass = objValue.getClass();
//获取当前类 判断其是否有自定义的requestMapping 注解
String mappingUrl = null;
MyRequestMapping anRequestMapping = aClass.getDeclaredAnnotation(MyRequestMapping.class);
if (anRequestMapping != null) {
mappingUrl = anRequestMapping.value();
}
//获取当前类所有方法,判断方法上是否有注解
Method[] declaredMethods = aClass.getDeclaredMethods();
/**
* 遍历注解
*/
for (Method method : declaredMethods) {
MyRequestMapping methodDeclaredAnnotation = method.getDeclaredAnnotation(MyRequestMapping.class);
if (methodDeclaredAnnotation != null) {
String methodUrl = methodDeclaredAnnotation.value();
mvcBeanUrl.put(mappingUrl + methodUrl, objValue);
mvcMethodUrl.put(mappingUrl + methodUrl, method.getName());
}
} } } /**
* @param str
* @return 类名首字母小写
*/
public static String lowerFirstCapse(String str) {
char[] chars = str.toCharArray();
chars[0] += 32;
return String.valueOf(chars); } /**
* doPost 请求
* @param req
* @param resp
* @throws ServletException
* @throws IOException
*/
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
try {
/**
* 处理请求
*/
doServelt(req, resp);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
} /**
* doServelt 处理请求
* @param req
* @param resp
* @throws IOException
* @throws NoSuchMethodException
* @throws InvocationTargetException
* @throws IllegalAccessException
* @throws ServletException
*/
private void doServelt(HttpServletRequest req, HttpServletResponse resp) throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, ServletException {
//获取请求地址
String requestUrl = req.getRequestURI();
//查找地址所对应bean
Object object = mvcBeanUrl.get(requestUrl);
if (Objects.isNull(object)) {
resp.getWriter().println(HTTP_NOT_EXIST);
return;
}
//获取请求的方法
String methodName = mvcMethodUrl.get(requestUrl);
if (methodName == null) {
resp.getWriter().println(METHOD_NOT_EXIST);
return;
} //通过构反射执行方法
Class<?> aClass = object.getClass();
Method method = aClass.getMethod(methodName); String invoke = (String) method.invoke(object);
// 获取后缀信息
String suffix = POINT_JSP;
// 页面目录地址
String prefix = LEFT_LINE;
req.getRequestDispatcher(prefix + invoke + suffix).forward(req, resp); } /**
* doGet 请求
* @param req
* @param resp
* @throws ServletException
* @throws IOException
*/
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
} }
8、测试手写SpringMVC 框架效果类TestMySpringMVC 。
package com.tjt.springmvc; /**
* 手写SpringMVC 测试类
* TestMySpringMVC
*/
@MyController
@MyRequestMapping(value = "/tjt")
public class TestMySpringMVC { /**
* 测试手写SpringMVC 框架效果 testMyMVC1
* @return
*/
@MyRequestMapping("/mvc")
public String testMyMVC1() {
System.out.println("he Lie We Live!");
return "index";
} }
9、配置Tomcat 用于运行Web 项目。
图5. 配置tomcat

10、运行项目,访问测试。
1、输入正常路径 http://localhost:8080/tjt/mvc 访问测试效果如下:
图6. 正常路径测试效果

2、输入非法(不存在)路径 http://localhost:8080/tjt/mvc8 访问测试效果如下:
图7. 非法路径测试效果

3、控制台打印“The Lie We Live”如下:
图8. 控制台打印

测试效果如上则证明成功手写SpringMVC 框架,恭喜。
细嗅蔷薇 心有猛虎
手写SpringMVC 框架的更多相关文章
- (二)springMvc原理和手写springMvc框架
我们从两个方面了解springmvc执行原理,首先我们去熟悉springmvc执行的过程,然后知道原理后通过手写springmvc去深入了解代码中执行过程. (一)SpringMVC流程图 (二)Sp ...
- 手写SpringMVC框架(三)-------具体方法的实现
续接前文 手写SpringMVC框架(二)结构开发设计 本节我们来开始具体方法的代码实现. doLoadConfig()方法的开发 思路:我们需要将contextConfigLocation路径读取过 ...
- 手写SpringMVC框架(二)-------结构开发设计
续接前文, 手写SpringMVC框架(一)项目搭建 本节我们来开始手写SpringMVC框架的第二阶段:结构开发设计. 新建一个空的springmvc.properties, 里面写我们要扫描的包名 ...
- 手写SpringMVC框架(一)-------项目搭建
SpringMVC处理请求的大致流程: 我们来开始着手手写一个SpringMVC框架. 新建一个springMVC项目,流程参见 SpringMVC框架搭建流程 引入servlet相关的jar包: & ...
- 纯手写SpringMVC框架,用注解实现springmvc过程
闲话不多说,直接上代码! 1.第一步,首先搭建如下架构,其中,annotation中放置自己编写的注解,主要包括service controller qualifier RequestMapping ...
- 二. 手写SpringMVC框架
1.1 新建DispatcherServlet 1.2 在src目录下,新建applicationContext.xml <?xml version="1.0" encodi ...
- 五,手写SpringMVC框架,过滤器的使用
8. 过滤器 8.1 编写字符过滤器 CharacterEncodingFilter 复制项目mymvc4,新建项目mymvc5 package com.hy.filter; import java. ...
- 深度解析SpringMvc实现原理手写SpringMvc框架
http://www.toutiao.com/a6340568603607171329/?tt_from=mobile_qq&utm_campaign=client_share&app ...
- 《四 spring源码》手写springmvc
手写SpringMVC思路 1.web.xml加载 为了读取web.xml中的配置,我们用到ServletConfig这个类,它代表当前Servlet在web.xml中的配置信息.通过web.xml ...
随机推荐
- JSON说明
1. JSON 数据的书写格式 对象:是一个无序的“‘名称/值’对”集合.一个对象以“{”(左括号)开始,“}”(右括号)结束.每个“名称”后跟一个“:”(冒号):“‘名称/值’ 对”之间使用“,”( ...
- Spring 源码阅读 一
终于,有一天我也来看Spring的源码了,看了一阵之后感觉心情那叫一个舒畅,对Spring底层的实现也有了进一步的了解, 最直观的感受就是Spring的命名风格很赞,很长,真的长到使人见名知意, 闲言 ...
- 从单片机到操作系统⑦——深入了解FreeRTOS的延时机制
>没研究过操作系统的源码都不算学过操作系统 # FreeRTOS 时间管理 时间管理包括两个方面:系统节拍以及任务延时管理. ## 系统节拍: 在前面的文章也讲得很多,想要系统正常运行,那么时钟 ...
- .NET GC垃圾回收器
GC垃圾回收器简介 全名: Garbage Collector 原理: 以应用程序的根(root)为基础,遍历应用程序堆(heap)上动态分配的所有对象,通过识别它们是否被引用来确定哪些对象是已经死亡 ...
- mac上git安装与github基本使用
目录 安装git 创建ssh key.配置git 提交本地项目到GitHub 一.安装Git MAC安装Git 首先查看电脑是否安装Git,终端输入: git 1.通过homebrew安装Git 1. ...
- ES三节点重启后报错no known master node
问题 一直在研究ES的监控怎么做,想偷点懒,不去通过API获取然后计算,就想找个现成的插件或者监控软件,只要装个agent就可以,然后就找到了x-pack,插件装好了之后,需要重启ES集群,线上的ES ...
- Luogu1119灾后重建
题目背景 BBB 地区在地震过后,所有村庄都造成了一定的损毁,而这场地震却没对公路造成什么影响.但是在村庄重建好之前,所有与未重建完成的村庄的公路均无法通车.换句话说,只有连接着两个重建完成的村庄的公 ...
- 14.Nginx四层负载均衡
1.七层负载均衡: 根据url 调度不同的集群 url.cheng.com 10.0.0.5 10.0.0.7 /pass 10.0.0.8 /user 1.web01和web02配置 (只不过代码不 ...
- Bugku SQL注入2的思考
网络安全初学者,欢迎评论交流学习,若内容中有错误欢迎各位指正. 题目地址:http://123.206.87.240:8007/web2/ 题目提示:都过滤了绝望吗?,提示 !,!=,=,+,-,^, ...
- git jenkins 基本部署之git远程仓库
1.git远程仓库如何使用? 实战一.如何将本地仓库与远程Gitee进行关联? 1.注册gitee 2.创建一个远程仓库? 3.配置使用远程仓库 ...