简单实现springmvc框架(servlet+自定义注解)

时间:2023-03-09 03:44:47
简单实现springmvc框架(servlet+自定义注解)

  个人水平比较菜,没有这么高的实力简单实现springmvc框架,我是看了一个老哥的博客,这老哥才是大神!

  原文链接:https://www.cnblogs.com/xdp-gacl/p/4101727.html

  ok,开始瞎扯一下,我们用springmvc的时候是不是要导入依赖或者是jar包啊,那是由于别人将很多功能都给设计好了,我们直接拿过来用,这有好处也有坏处;好处是用起来很方便,直接用就好了,但是坏处就是封装的太好了我们很难真正的弄清楚其中的运行原理,虽然说可以走源码,但是总是感觉差了一点什么东西。

  反正我走源码就是总感觉有点欠缺,但是又说不上来哪里不对劲,直到现在醒悟了一点,其实就是对原理了解的不够彻底,还有就是源码都是一层套一层,层层封装,走着走着我也不知道上一步做了什么事了,简直就是山路十八弯,哈哈哈。

  瞎扯结束,接下来我根据那个老哥的博客自己稍微整理一下,简单实现一个springmvc的框架,只为更透彻的了解springmvc的原理!

  在看本篇博客之前,可以先忘记以前springmvc的所有东西,我们以 servlet+自定义注解 实现

  新建一个普通的java web项目,我的目录结构如下:

简单实现springmvc框架(servlet+自定义注解)

1.工具类

  自己简单实现一些什么框架,必不可少的就是要提前准备很多的工具类

  首先就是要把下面这七个工具类准备好,我把代码复制过来;

  1.1 BeanUtils:对反射的一些操作,可以根据传入xxx.Class参数,进行实例化或者找到其中某个方法

package com.wyq.utils;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier; /**
* 对java反射的一些封装
*/
public class BeanUtils {
/**
* 实例化一个class
*/
public static <T> T instanceClass(Class<T> clazz){
if(!clazz.isInterface()){
try {
return clazz.newInstance();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
return null;
} /**
* 通过构造函数实例化
*
*/
public static <T> T instanceClass(Constructor<T> ctor, Object... args)
throws IllegalArgumentException, InstantiationException,
IllegalAccessException, InvocationTargetException{
makeAccessible(ctor);
return ctor.newInstance(args);//调用构造方法实例化
} /**
* 查找某个class的方法
*
*/
public static Method findMethod(Class<?> clazz, String methodName, Class<?>... paramTypes){
try {
return clazz.getMethod(methodName, paramTypes);
} catch (NoSuchMethodException e) {
return findDeclaredMethod(clazz, methodName, paramTypes);//返回共有的方法
}
} public static Method findDeclaredMethod(Class<?> clazz, String methodName, Class<?>[] paramTypes){
try {
return clazz.getDeclaredMethod(methodName, paramTypes);
}
catch (NoSuchMethodException ex) {
if (clazz.getSuperclass() != null) {
return findDeclaredMethod(clazz.getSuperclass(), methodName, paramTypes);
}
return null;
}
} public static Method [] findDeclaredMethods(Class<?> clazz){
return clazz.getDeclaredMethods();
} public static void makeAccessible(Constructor<?> ctor) {
if ((!Modifier.isPublic(ctor.getModifiers())
|| !Modifier.isPublic(ctor.getDeclaringClass().getModifiers()))
&& !ctor.isAccessible()) {
ctor.setAccessible(true);//如果是私有的 设置为true 使其可以访问
}
} public static Field[] findDeclaredFields(Class<?> clazz){
return clazz.getDeclaredFields();
} }

  1.2 DispatchActionConstant:里面定义两个常量,“forward”和“redirect”,就是为了取值方便,没什么特殊的

package com.wyq.utils;

public class DispatchActionConstant {
public static String FORWARD = "forward";//转发 public static String REDIRECT = "redirect";//重定向 }

  1.3 RequestMapingMap:这里面封装了一个HashMap,键是@RequestMapping注解的value的值,就是那个路径;值为当前被@Controller注解修饰的类的xxx.Class;这个键值对很关键,等一下好好体会一下;例如这样的形式{“/aaa”:“MyController.class”,“xxx”:“xxxx.Class”}

package com.wyq.utils;

import java.util.HashMap;
import java.util.Map; /**
* 存储方法的访问路径
*/
public class RequestMapingMap { /**
* requesetMap:用于存储方法的访问路径和类
*/
private static Map<String, Class<?>> requesetMap = new HashMap<String, Class<?>>(); public static Class<?> getClassName(String path) {
return requesetMap.get(path);
} public static void put(String path, Class<?> className) {
requesetMap.put(path, className);
} public static Map<String, Class<?>> getRequesetMap() {
return requesetMap;
}
}

  1.4ScanClassUtil:可以根据传入的包名或者jar包名称,获取到包下所有类的xxx.Class,然后放进集合中返回给你

package com.wyq.utils;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile; /**
* 扫描指定包或者jar包下面的class
*/
public class ScanClassUtil { /**
* 从包package中获取所有的Class
*/
public static Set<Class<?>> getClasses(String pack) { // 第一个class类的集合
Set<Class<?>> classes = new LinkedHashSet<Class<?>>();
// 是否循环迭代
boolean recursive = true;
// 获取包的名字 并进行替换
String packageName = pack;
String packageDirName = packageName.replace('.', '/');
// 定义一个枚举的集合 并进行循环来处理这个目录下的things
Enumeration<URL> dirs;
try {
dirs = Thread.currentThread().getContextClassLoader().getResources(
packageDirName);
// 循环迭代下去
while (dirs.hasMoreElements()) {
// 获取下一个元素
URL url = dirs.nextElement();
// 得到协议的名称
String protocol = url.getProtocol();
// 如果是以文件的形式保存在服务器上
if ("file".equals(protocol)) {
System.err.println("file类型的扫描");
// 获取包的物理路径
String filePath = URLDecoder.decode(url.getFile(), "UTF-8");
// 以文件的方式扫描整个包下的文件 并添加到集合中
findAndAddClassesInPackageByFile(packageName, filePath,
recursive, classes);
} else if ("jar".equals(protocol)) {
// 如果是jar包文件
// 定义一个JarFile
System.err.println("jar类型的扫描");
JarFile jar;
try {
// 获取jar
jar = ((JarURLConnection) url.openConnection())
.getJarFile();
// 从这个jar包得到一个枚举类
Enumeration<JarEntry> entries = jar.entries();
// 同样的进行循环迭代
while (entries.hasMoreElements()) {
// 获取jar里的一个实体 可以是目录 和一些jar包里的其他文件 如META-INF等文件
JarEntry entry = entries.nextElement();
String name = entry.getName();
// 如果是以/开头的
if (name.charAt(0) == '/') {
// 获取后面的字符串
name = name.substring(1);
}
// 如果前半部分和定义的包名相同
if (name.startsWith(packageDirName)) {
int idx = name.lastIndexOf('/');
// 如果以"/"结尾 是一个包
if (idx != -1) {
// 获取包名 把"/"替换成"."
packageName = name.substring(0, idx)
.replace('/', '.');
}
// 如果可以迭代下去 并且是一个包
if ((idx != -1) || recursive) {
// 如果是一个.class文件 而且不是目录
if (name.endsWith(".class")
&& !entry.isDirectory()) {
// 去掉后面的".class" 获取真正的类名
String className = name.substring(
packageName.length() + 1, name
.length() - 6);
try {
// 添加到classes
classes.add(Class
.forName(packageName + '.'
+ className));
} catch (ClassNotFoundException e) {
// log
// .error("添加用户自定义视图类错误 找不到此类的.class文件");
e.printStackTrace();
}
}
}
}
}
} catch (IOException e) {
// log.error("在扫描用户定义视图时从jar包获取文件出错");
e.printStackTrace();
}
}
}
} catch (IOException e) {
e.printStackTrace();
} return classes;
} /**
* 以文件的形式来获取包下的所有Class
*/
public static void findAndAddClassesInPackageByFile(String packageName,
String packagePath, final boolean recursive, Set<Class<?>> classes) {
// 获取此包的目录 建立一个File
File dir = new File(packagePath);
// 如果不存在或者 也不是目录就直接返回
if (!dir.exists() || !dir.isDirectory()) {
// log.warn("用户定义包名 " + packageName + " 下没有任何文件");
return;
}
// 如果存在 就获取包下的所有文件 包括目录
File[] dirfiles = dir.listFiles(new FileFilter() {
// 自定义过滤规则 如果可以循环(包含子目录) 或则是以.class结尾的文件(编译好的java类文件)
public boolean accept(File file) {
return (recursive && file.isDirectory())
|| (file.getName().endsWith(".class"));
}
});
// 循环所有文件
for (File file : dirfiles) {
// 如果是目录 则继续扫描
if (file.isDirectory()) {
findAndAddClassesInPackageByFile(packageName + "."
+ file.getName(), file.getAbsolutePath(), recursive,
classes);
} else {
// 如果是java类文件 去掉后面的.class 只留下类名
String className = file.getName().substring(0,
file.getName().length() - 6);
try {
// 添加到集合中去
//classes.add(Class.forName(packageName + '.' + className));
//经过回复同学的提醒,这里用forName有一些不好,会触发static方法,没有使用classLoader的load干净
classes.add(Thread.currentThread().getContextClassLoader().loadClass(packageName + '.' + className));
} catch (ClassNotFoundException e) {
// log.error("添加用户自定义视图类错误 找不到此类的.class文件");
e.printStackTrace();
}
}
}
}
}

  1.5 View:就是一个普通类,里面存放了要跳转的url,用在Controller中方法的返回值那里;这里还可以往VIewDate中放数据(最后就是放进request中了)

package com.wyq.utils;

/**
* 视图模型
**/
public class View { private String url;//跳转路径 private String dispathAction = DispatchActionConstant.FORWARD;//跳转方式 public View(String url) {
this.url = url;
} public View(String url,String name,Object value) {
this.url = url;
ViewData view = new ViewData();
view.put(name, value);
} public View(String url,String name,String dispathAction ,Object value) {
this.dispathAction = dispathAction;
this.url = url;
ViewData view = new ViewData();//请看后面的代码
view.put(name, value);
} public String getUrl() {
return url;
} public void setUrl(String url) {
this.url = url;
} public String getDispathAction() {
return dispathAction;
} public void setDispathAction(String dispathAction) {
this.dispathAction = dispathAction;
}
}

  1.6 ViewData:封装了HttpServletRequest对象,可以往request中放数据,在jsp中可以取到数据

package com.wyq.utils;

import javax.servlet.http.HttpServletRequest;

/**
* 需要发送到客户端显示的数据模型
*/
public class ViewData { private HttpServletRequest request; public ViewData() {
initRequest();
} private void initRequest(){
//从requestHodler中获取request对象
this.request = WebContext.requestHodler.get();
} public void put(String name,Object value){
this.request.setAttribute(name, value);
}
}

  1.7 WebContext:这里是为了多线程设计的,这里保存了当前线程的HttpServletReques和HttpServletReponse对象,可以在其他类中随时获取这两个对象  

package com.wyq.utils;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; /**
* WebContext主要是用来存储当前线程中的HttpServletRequest和HttpServletResponse
* 当别的地方需要使用HttpServletRequest和HttpServletResponse,就可以通过requestHodler和responseHodler获取
**/
public class WebContext { public static ThreadLocal<HttpServletRequest> requestHodler = new ThreadLocal<HttpServletRequest>();
public static ThreadLocal<HttpServletResponse> responseHodler = new ThreadLocal<HttpServletResponse>(); public HttpServletRequest getRequest(){
return requestHodler.get();
} public HttpSession getSession(){
return requestHodler.get().getSession();
} public ServletContext getServletContext(){
return requestHodler.get().getSession().getServletContext();
} public HttpServletResponse getResponse(){
return responseHodler.get();
}
}

2.自定义注解@MyController和@RequestMapping

  这两个注解名字随意取,我觉得最好区分我们熟悉的@Controller和@RequestMapping

package com.wyq.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; /**
* 我们自定义的Controller注解
*/ @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyController {
public String value() default ""; }
package com.wyq.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; /**
* 我们自定义的ResponseMapping注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyRequestMapping {
public String value() default "";
}

3.我们自己的Controller

  注意其中的注解哦~~

package com.wyq.test;

import com.wyq.annotation.MyController;
import com.wyq.annotation.MyRequestMapping;
import com.wyq.utils.View; @MyController
public class LoginUI { //使用MyRequestMapping注解指明forward1方法的访问路径
@MyRequestMapping("aaa")
public View forward1(){
//执行完forward1方法之后返回的视图
return new View("/WEB-INF/login.jsp");
} //使用MyRequestMapping注解指明forward2方法的访问路径
@MyRequestMapping("bbb")
public View forward2(){
//执行完forward2方法之后返回的视图
return new View("/bbb.jsp");
} }

4.注解处理器(最重要!!本质上就是一个普通的servlet,没什么稀奇)

  说一下大概逻辑:

  这里由于我们自定义的两个注解只有我们自己认识,但是程序不认识啊,于是我们要写一个servlet,当我们用浏览器发送请求的时候,服务器首先会自动执行这个servlet,然后去解析拿到有@MyController注解的类,遍历其中所有的方法,将带有@MyRequestMapping注解的value的值作为键,@MyController注解的类作为值,保存在一个HashMap中,就是放在前面说的工具 RequestMapingMap中;

  然后我们的请求到服务器的时候,假如请求路径为http://localhost:8090/myspringmvc/aaa.do,这个servlet中就会对这个路径进行分割,变成取到“aaa”,然后根据这个“aaa”为键,从 RequestMapingMap中取出我们要的那个类(被注解@MyController修饰的),再拿到这个类下的所有方法,遍历,看看哪个方法被@MyRequestMapping(“aaa”)注解修饰,这个方法就是我们的目标!最后用反射调用就这个方法,比如返回一个   View("/aaa.jsp")   对象,根据View中的这个url就可以用request或者response进行转发或者重定向操作,跳转到相应的jsp中返回给浏览器。

  

package com.wyq.handler;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Set; import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import com.wyq.annotation.MyController;
import com.wyq.annotation.MyRequestMapping;
import com.wyq.utils.BeanUtils;
import com.wyq.utils.DispatchActionConstant;
import com.wyq.utils.RequestMapingMap;
import com.wyq.utils.ScanClassUtil;
import com.wyq.utils.View;
import com.wyq.utils.WebContext; /**
* Description: AnnotationHandleServlet作为自定义注解的核心处理器以及负责调用目标业务方法处理用户请求<p>
*/
public class AnnotationHandleServlet extends HttpServlet { private String pareRequestURI(HttpServletRequest request){
String path = request.getContextPath()+"/";
String requestUri = request.getRequestURI();
String midUrl = requestUri.replaceFirst(path, "");
String lasturl = midUrl.substring(0, midUrl.lastIndexOf("."));
return lasturl;
} public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.excute(request, response);
} public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.excute(request, response);
} private void excute(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException{
//将当前线程中HttpServletRequest对象存储到ThreadLocal中,以便在Controller类中使用
WebContext.requestHodler.set(request);
//将当前线程中HttpServletResponse对象存储到ThreadLocal中,以便在Controller类中使用
WebContext.responseHodler.set(response);
//解析url
String lasturl = pareRequestURI(request);
//获取要使用的类
Class<?> clazz = RequestMapingMap.getRequesetMap().get(lasturl);
//创建类的实例
Object classInstance = BeanUtils.instanceClass(clazz);
//获取类中定义的方法
Method [] methods = BeanUtils.findDeclaredMethods(clazz);
Method method = null;
for(Method m:methods){//循环方法,找匹配的方法进行执行
if(m.isAnnotationPresent(MyRequestMapping.class)){
String anoPath = m.getAnnotation(MyRequestMapping.class).value();
if(anoPath!=null && !"".equals(anoPath.trim()) && lasturl.equals(anoPath.trim())){
//找到要执行的目标方法
method = m;
break;
}
}
}
try {
if(method!=null){
//执行目标方法处理用户请求
Object retObject = method.invoke(classInstance);
//如果方法有返回值,那么就表示用户需要返回视图
if (retObject!=null) {
View view = (View)retObject;
//判断要使用的跳转方式
if(view.getDispathAction().equals(DispatchActionConstant.FORWARD)){
//使用转发方式
request.getRequestDispatcher(view.getUrl()).forward(request, response);
}else if(view.getDispathAction().equals(DispatchActionConstant.REDIRECT)){
//使用重定向方式
response.sendRedirect(request.getContextPath()+view.getUrl());
}else{
request.getRequestDispatcher(view.getUrl()).forward(request, response);
}
}
}
} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
} @Override
public void init(ServletConfig config) throws ServletException {
/**
* 重写了Servlet的init方法后一定要记得调用父类的init方法,
* 否则在service/doGet/doPost方法中使用getServletContext()方法获取ServletContext对象时
* 就会出现java.lang.NullPointerException异常
*/
super.init(config);
System.out.println("---初始化开始---");
//获取web.xml中配置的要扫描的包
String basePackage = config.getInitParameter("basePackage");
//如果配置了多个包,例如:<param-value>me.gacl.web.controller,me.gacl.web.UI</param-value>
if (basePackage.indexOf(",")>0) {
//按逗号进行分隔
String[] packageNameArr = basePackage.split(",");
for (String packageName : packageNameArr) {
initRequestMapingMap(packageName);
}
}else {
initRequestMapingMap(basePackage);
}
System.out.println("----初始化结束---");
} /**
* @Method: initRequestMapingMap
* @Description:添加使用了Controller注解的Class到RequestMapingMap中
* @Anthor:孤傲苍狼
* @param packageName
*/
private void initRequestMapingMap(String packageName){
Set<Class<?>> setClasses = ScanClassUtil.getClasses(packageName);
for (Class<?> clazz :setClasses) {
if (clazz.isAnnotationPresent(MyController.class)) {
Method [] methods = BeanUtils.findDeclaredMethods(clazz);
for(Method m:methods){//循环方法,找匹配的方法进行执行
if(m.isAnnotationPresent(MyRequestMapping.class)){
String anoPath = m.getAnnotation(MyRequestMapping.class).value();
if(anoPath!=null && !"".equals(anoPath.trim())){
if (RequestMapingMap.getRequesetMap().containsKey(anoPath)) {
throw new RuntimeException("RequestMapping映射的地址不允许重复!");
}
RequestMapingMap.put(anoPath, clazz);
}
}
}
}
}
}
}

5.将注解处理器(servlet)配置到web.xml中

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" id="WebApp_ID" version="2.5">
<display-name>myspringmvc</display-name>
<welcome-file-list>
<welcome-file>index.html</welcome-file>
<welcome-file>index.htm</welcome-file>
<welcome-file>index.jsp</welcome-file>
<welcome-file>default.html</welcome-file>
<welcome-file>default.htm</welcome-file>
<welcome-file>default.jsp</welcome-file>
</welcome-file-list>
<servlet>
<servlet-name>AnnotationHandleServlet</servlet-name>
<servlet-class>com.wyq.handler.AnnotationHandleServlet</servlet-class>
<init-param>
<description>扫描包含@MyController注解的包; 如果有多个包,以逗号分隔</description>
<param-name>basePackage</param-name>
<param-value>com.wyq.test</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet> <servlet-mapping>
<servlet-name>AnnotationHandleServlet</servlet-name>
<!-- 拦截所有以.do后缀结尾的请求 -->
<url-pattern>*.do</url-pattern>
</servlet-mapping>
</web-app>

6.两个简单的jsp文件

aaa.jsp

简单实现springmvc框架(servlet+自定义注解)

bbb.jsp

简单实现springmvc框架(servlet+自定义注解)

多加一个jsp,新建webapps/WEB-INF/login.jsp,随便写点什么东西

简单实现springmvc框架(servlet+自定义注解)

7.简单测试

  现在就可以启动tomcat输入localhost:8090/myspringmvc/aaa.do看到效果了(由于我以前改过tomcat端口8090,你们的端口应该是8080),可以访问WEB-INF中的jsp

简单实现springmvc框架(servlet+自定义注解)

  

8.稍微提一下

    注意:我们在web.xml中拦截的是xxx.do请求,所以我们的url路径要类似这样的  http://localhost:8090/myspringmvc/aaa.do

   也可以根据自己需要设置拦截类型,比如"*action",或者直接"/",这个时候你就需要在注解处理器做点修改就ok了;

   举个例子,比如我要直接用http://localhost:8090/myspringmvc/aaa去访问,不加do了,注解处理器修改如下:

简单实现springmvc框架(servlet+自定义注解)

  

  web.xml修改如下:

简单实现springmvc框架(servlet+自定义注解)

  然后就可以看到效果了

简单实现springmvc框架(servlet+自定义注解)

9.总结

  有没有发现我们这里最重要的就是那个注解处理器啊,没有这个注解处理器,那些所谓的@Controller,@ResponseBody,@RequestMapping等等,都是一文不值,只不过注解处理器被那些框架封装得太好了,我们一般也接触不到,我们看到的只是这样的--------->“哇,原来这些注解这么牛啊!”“好厉害的注解啊,我要记下来这个用法”....

  而注解处理器其实就是一个servlet,在tomcat服务器启动的时候会运行init()方法,将Controller路径和类存到一个map中;然后我们发请求的时候(比如Get请求),就会到service()方法,接着就分发到doGet()方法,再接着就是到真正处理请求的excute()方法,这个方法可以说是核心的核心,将请求路径进行解析,就可以找到对应的Controller,遍历这个COntroller下的所有方法并且拿到所有方法的@RequestMapping的值,找到真正的处理方法,运行,返回一个view,在这个view的内部封装了对ViewData的操作(ViewData其实就是将数据丢到request域中,以便在jsp中可以获取到数据),所以返回的View也可以是这样的   return new View("/WEB-INF/login.jsp",“name”,“这里是数据”);,最后就是在jsp中展示数据了;

  搞懂了这些我们再回头看看所谓的springmvc,简直就是一目了然啊,只是在这个基础上又封装了很多而已!比如前端控制器就很类似于我们这里的注解处理器,处理器映射器其实就是在一个全局map中根据请求路径找到对应的Controller,处理器适配器不就是从这个Controller中找到目标方法执行么,后面的视图解析器其实跟我们这也很类似啊,根据方法返回的路径去对应的目录下找相应的jsp文件,然后根绝request域中的数据填充进去就ok了,是不是突然感觉整个springmvc就不再那么云里雾里了啊!哈哈哈

  原文链接:https://www.cnblogs.com/xdp-gacl/p/4101727.html