zoukankan      html  css  js  c++  java
  • 源码分析系列 | 从零开始写MVC框架

    1. 前言

    源码地址:https://github.com/Evan43789596/eshare-it-subject-learning.git

    前段时间在网上无意中上参与了一节腾讯课堂的公开课,里面讲到了一些分析思路,感觉挺有意思,也学习到了别人的一些讲课技巧,正好自己也打算对过往知识网络做个整理回顾,计划后面开展一系列源码分析教程,本章先从一个入门简单的手写MVC框架入门,模仿springMVC一些基本原理带领大家通过自己实现MVC框架的过程了解到一些MVC框架的核心原理,从而在以后学习一些新的MVC框架或者使用springMVC过程中更从容。在本章学习过程中,大家可以暂时抛开springMVC的概念,通过实现过程去理解。

    友情提示:

    大家在一开始实现代码过程中,可以不必纠结太多设计模式、代码风格的问题,先按照基本思路实现功能,再优化

    2. 为什么要自己手写框架

    模仿优秀的开源框架可以加深我们对框架的理解,让我们更加深刻地理解其原理,从而在日后使用框架过程中遇到问题也可以快速定位解决,甚至能开发更优秀的框架来满足业务需求,而不仅仅只是停留在使用阶段

    以车主和4S店修理员对话举个栗子:
    以下是车主和4S店修理员的对话,车主是一个不了解汽车的小白,大家想象下他在修车过程中会发生什么事?

    场景:是汽车电池没电,启动不了

    这里写图片描述

    如上图情景,假如车主自己对汽车结构和原理有一定了解的话,那他就可以对这次的维修费用项心里有底,不会随便被维修员忽悠,减少无谓的金钱损失。如果我们自己对框架有深入的了解,就可以对框架使用更得心应手,对使用的框架有更多的思考,而不仅仅是一个只会用工具的马畜。

    3. 简单MVC框架设计思路

    按照以往经验,框架应用一般会分为3个阶段:
    - 配置阶段
    - 初始化阶段
    - 运行阶段
    这里写图片描述

    4. 课程目标

    成功根据用户请求URL交给对应的contoller处理,并响应结果到浏览器
    这里写图片描述
    这里写图片描述

    5. 编码实战

    5.1 配置阶段

    这个阶段是完成框架启动或者运行时依赖的一些配置前期准备操作。
    例如:
    如果没有配置web.xml,tomcat容器启动的时候就不会拦截请求交给你MVC框架指定的servlet类上;
    如果没有指定一定的扫描规则(注解/xml),MVC框架就不知道该怎么加载用户自定义的类或者反射调用哪些元素;

    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/j2ee" xmlns:javaee="http://java.sun.com/xml/ns/javaee"
        xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
        xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"
        version="2.4">
        <display-name>Eshare Web Application</display-name>
    
        <!-- mvcframework config start -->
        <servlet>
          <servlet-name>dispatcher</servlet-name>
          <servlet-class>com.eshare.framework.mvc.servlet.EsDispatcherServlet</servlet-class>
          <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath*:config.properties</param-value>
          </init-param>
          <load-on-startup>1</load-on-startup>
        </servlet>
        <servlet-mapping>
          <servlet-name>dispatcher</servlet-name>
          <url-pattern>*.do</url-pattern>
        </servlet-mapping>
        <!-- mvcframework config end -->
        <!-- welcome page -->
        <welcome-file-list>
          <welcome-file>/index.html</welcome-file>
        </welcome-file-list>
    </web-app>
    • 第12行:配置自定义分发器用于处理用户请求分到到具体的处理类
    • 第15行:配置配置文件的路径
    • 第21行:配置请求拦截规则

    config.properties

    创建config.properties,指定框架加载的包名(配置文件是由框架使用者自己创建,这里为了演示方便提前创建好)

    package-scan=用户自定义包名

    自定义注解

    自定义注解用于标记哪些元素需要交给框架IOC管理或者需要框架去做一些列操作的,在本次Demo主要是定义以下几个注解:

    • EsController 标识用作控制器的类,放在类上方
    • EsService 标识用作具体业务处理服务类,放在类上方
    • EsAutowired 标识需要注入的属性,放在属性上方
    • EsRequestMapping 请求规则映射,放在控制器的类或者方法上方
    • EsRequestParam 标识方法中自定义参数,放在处理方法参数左方

    EsController

    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface EsController {
        String value() default "";
    }
    
    • 第1行:@Target({ElementType.TYPE})指定放在目标类、接口、枚举声明上
    • 第2行:@Retention(RetentionPolicy.RUNTIME)指定作用范围是运行时

    EsService

    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface EsService {
        String value() default "";
    }
    • 第1行:@Target({ElementType.TYPE})指定放在目标类、接口、枚举声明上
    • 第2行:@Retention(RetentionPolicy.RUNTIME)指定作用范围是运行时

    EsAutowired

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface EsAutowired {
        String value() default "";
    }
    
    • 第1行:@Target({ElementType.FIELD})指定放在目标属性字段声明上
    • 第2行:@Retention(RetentionPolicy.RUNTIME)指定作用范围是运行时

    EsRequestMapping

    @Target({ElementType.TYPE,ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface EsRequestMapping {
        String value() default "";
    }
    • 第1行:@Target({ElementType.TYPE})指定放在目标类、接口、枚举声明上
    • 第2行:@Retention(RetentionPolicy.RUNTIME)指定作用范围是运行时

    EsRequestParam

    @Target({ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface EsRequestParam {
        String value() default "";
    }
    • 第1行:@Target({ElementType.PARAMETER})指定放在目标参数声明上
    • 第2行:@Retention(RetentionPolicy.RUNTIME)指定作用范围是运行时

    5.2 初始化阶段

    创建自定义EsDispatcherServlet

    EsDispatcherServlet是继承HttpServlet

    public class EsDispatcherServlet extends HttpServlet 

    重写HttpServlet的init(ServletConfig config),定义好我们接下来要实现的几个步骤,然后在针对每个方法一一填充逻辑,如下:

      @Override
        public void init(ServletConfig config) throws ServletException {
            try {
                //1.读取配置
                doLoadConfig(config);
                String packageName = configProperties.getProperty("package-scan");
                //2.扫描指定包下的类
                doScanClass(packageName);
                //3.对扫描出来的类实例化
                doInitializeInstance();
                //4.执行类依赖自动注入
                doAutowired();
                //5.配置url和handler映射关系handlerMapping
                doHandlerMapping();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    • 第5行:doLoadConfig方法用于读取用户自定义配置
    • 第6行:根据配置中的package-scan提取用户指定扫描的包路径
    • 第8行:doScanClass方法用于扫描用户指定包下的类,然后把要加载的类名存放下来
    • 第10行:doInitializeInstance方法用于执行初始化实例,这个阶段就是IOC初始化阶段
    • 第12行:doAutowired方法用户对用户加上该注解的属性,由MVC框架反射注入
    • 第14行:doHandlerMapping方法用于配置url和handler映射关系,用于后续MVC处理用户请求映射到具体类的某个方法

    doLoadConfig加载用户自定义配置

    这里需要先定义一个全局的properties实例,用于加载文件后存放配置文件信息

    /**
         * 配置
         */
        private Properties configProperties = new Properties();

    根据web.xml上的配置信息,去找到用户自定义配置

     /**
         * 加载配置
         *
         * @param config
         */
        private void doLoadConfig(ServletConfig config) throws IOException {
            String configFilePath = config.getInitParameter("contextConfigLocation");
            String configName = configFilePath.replace("classpath*:", "");
            InputStream in = this.getClass().getClassLoader().getResourceAsStream(configName);
            configProperties.load(in);
        }
    
    • 第7行:从web.xml配置中,根据contextConfigLocation去找到文件具体路径值
    • 第8行:这里是为了方便演示,直接把classpath相关的去掉,把classpath*:后的值作为加载路径
    • 第9行:利用类加载器去加载指定路径下的文件流

    doScanClass扫描指定包下的类

    根据上一步获取的指定包路径,去扫描对应的类,并把要加载的类名存放起来,这里需要先定义一个集合存放类名

     /**
         * 需要加载的类列表
         */
        private List<String> classNames = new ArrayList<String>();

    接下来开始遍历指定包目录,递归扫描class文件

       private void doScanClass(String packageName) {
            URL url = this.getClass().getClassLoader().getResource("/" + packageName.replaceAll("\.", "/"));
            File file = new File(url.getFile());
            for (File f : file.listFiles()) {
                if (f.isDirectory()) {
                    doScanClass(packageName + "." + f.getName());
                } else {
                    String className = packageName + "." + f.getName().replace(".class", "");
                    //存放到类名集合
                    classNames.add(className);
                }
            }
        }
    • 第1-2行:这里是对包名转换成字节码目录相对路径,例如com.eshare.demo转换成/com/eshare/demo,然后根据url去加载文件
    • 第4-13行:遍历目录下的文件,遇到目录就以包名方式继续递归寻找,如果是文件就对文件名称转换成类全限定名,放到集合里

    doInitializeInstance类实例化

    这里对应的是spring里面IOC容器初始化阶段,对类实例化并存放到容器中,这里我们定义一个集合模拟IOC容器

     /**
         * 类全限定名与实例映射集合,模拟applicationContext容器
         */
        private Map<String, Object> applicationContext = new ConcurrentHashMap<String, Object>();

    接下来我们完成类实例化工作,原理很简单,其实就是利用反射创建类实例

     private void doInitializeInstance() {
            //如果不存在要加载的类,则直接返回
            if (classNames.isEmpty()) {
                return;
            }
    
            for (String className : classNames) {
                try {
                    Class<?> clazz = Class.forName(className);
                    //查看当前类字节码是否存在EsController、EsService注解
                    if (clazz.isAnnotationPresent(EsController.class)) {
                        String beanName = lowerInitial(className);
                        applicationContext.put(beanName, clazz.newInstance());
                    } else if (clazz.isAnnotationPresent(EsService.class)) {
                        EsService esService = clazz.getAnnotation(EsService.class);
                        //检查是否存在自定义名称
                        String beanName = esService.value().trim();
                        if (!"".equals(beanName.trim())) {
                            applicationContext.put(beanName, clazz.newInstance());
                            continue;
                        }
                        //没有自定义名称,则以接口名称作为key
                        Class<?>[] interfaces = clazz.getInterfaces();
                        for (Class i : interfaces) {
                            applicationContext.put(i.getName(), clazz.newInstance());
                        }
    
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    • 第3-6行:先判断是否存需要实例化的类,不存在则直接返回
    • 第11-13行:这里是对作为控制器的类进行实例化,这里默认对类名首字母转为小写作为KEY,存放在IOC容器中
    • 第14-28行:这里是是针对业务服务类进行实例化,@EsCotroller和@EsService标注的类实例化有点不同,前者是控制器,后者是业务服务类,一般来说@EsService的类还会充当其他类的属性使用,因此这里的IOC容器中的key名会跟后面依赖注入的名称有一定联系,这里优先是使用用户自定义名称作为key,否则以接口名称与实例作为映射存放

    上面还依赖到一个工具类方法lowerInitial,这里也顺便写下

     private String lowerInitial(String className) {
            char[] chars = className.toCharArray();
            chars[0] += 32;
            return String.valueOf(chars);
    
        }
    • 第3行:这里使用acsii码方式进行转换,提升转换性能

    doAutowired依赖注入

    这一步原理也比较简单,就是利用反射调用目标类的setObject方法进行注入,主要私有成员变量要进行暴力破解,否则访问不了

      private void doAutowired() {
            if (applicationContext.isEmpty()) {
                return;
            }
    
            for (Map.Entry<String, Object> entry : applicationContext.entrySet()) {
                Field[] fields = entry.getValue().getClass().getDeclaredFields();
                for (Field field : fields) {
                    //暴力破解权限
                    field.setAccessible(true);
                    //检查是否带有@Autowired注解
                    if (field.isAnnotationPresent(EsAutowired.class)) {
                        EsAutowired esAutowired = field.getAnnotation(EsAutowired.class);
                        String beanName = esAutowired.value().trim();
                        if ("".equals(beanName)) {
                            beanName = field.getType().getName();
                        }
                        try {
                            field.set(entry.getValue(), applicationContext.get(beanName));
                        } catch (IllegalAccessException e) {
                            e.printStackTrace();
                            continue;
                        }
    
                    }
                }
            }
    
        }
    • 第7行:获取所有声明的成员变量
    • 第10行:对私有变量进行暴力破解,这样后面才可以对私有变量进行复制
    • 第12-23行:对带有@Autowired注解的成员变量才进行注入,优先使用用户自定义的名称,否则使用成员变量名称作为key从IOC容器找到对应的类实力进行赋值

    doHandlerMapping配置URL和具体Handler方法映射规则

    这一步最为复杂,我们首先要想到在运行阶段MVC框架要根据用户请求URL怎样可以找到具体的对应方法,这时候我们应该是需要有一个URL匹配规则和method的映射关系,这样才可以所有URL和method对应起来,但是只有这两个已知条件并不够,用过反射调用方法都了解,反射调用具体方法时,需要的条件有参数和参数顺序、目标类、目标方法,我们需要有一个东西可以让URL和这些我们需要的条件装起来,提供给运行阶段使用,这时候我们可以创建一个Handler类去把我们需要反射用到的已知条件装起来。

    简单整理下思路过程:
    这里写图片描述
    接下来我们就先创建这个Handler类,用于存放URL匹配规则和其他已知条件

     class Handler {
            private Pattern pattern;
            private Object controller;
            private Method method;
            private Map<String, Integer> paramMapping;
    
    
            public Handler(Pattern pattern, Object controller, Method method, Map<String, Integer> paramMapping) {
                this.pattern = pattern;
                this.controller = controller;
                this.method = method;
                this.paramMapping = paramMapping;
            }
    
            public Pattern getPattern() {
                return pattern;
            }
    
            public void setPattern(Pattern pattern) {
                this.pattern = pattern;
            }
    
            public Object getController() {
                return controller;
            }
    
            public void setController(Object controller) {
                this.controller = controller;
            }
    
            public Method getMethod() {
                return method;
            }
    
            public void setMethod(Method method) {
                this.method = method;
            }
    
            public Map<String, Integer> getParamMapping() {
                return paramMapping;
            }
    
            public void setParamMapping(Map<String, Integer> paramMapping) {
                this.paramMapping = paramMapping;
            }
        }

    因此定义的控制器可能会有多个,这里需要用集合把handler存放起来

     /**
         * 处理器映射列表
         */
        private List<Handler> handlerMapping = new ArrayList<Handler>();

    接下来我们开始配置URL匹配规则和需要的已知条件的映射

    /**
         * 执行处理类映射
         */
        private void doHandlerMapping() {
            if (applicationContext.isEmpty()) return;
            for (Map.Entry entry : applicationContext.entrySet()) {
                //先获取controller上的requestMapping值
                Class<?> clazz = entry.getValue().getClass();
                String baseUrl = "";
                //检查是否为controller
                if (clazz.isAnnotationPresent(EsController.class)) {
                    if (clazz.isAnnotationPresent(EsRequestMapping.class)) {
                        EsRequestMapping esRequestMapping = clazz.getAnnotation(EsRequestMapping.class);
                        baseUrl = esRequestMapping.value();
                    }
                }
                //获取当前类的所有方法
                Method[] method = clazz.getMethods();
    
                for (Method m : method) {
    
    
                    //方法上是否存在EsRequestMapping注解
                    if (!m.isAnnotationPresent(EsRequestMapping.class)) {
                        continue;
                    }
    
                    EsRequestMapping esRequestMapping = m.getAnnotation(EsRequestMapping.class);
                    String customRegex = ("/" + baseUrl + esRequestMapping.value()).replaceAll("/+", "/");
                    String reqRegex = customRegex.replaceAll("\*", ".*");
    
                    Map<String, Integer> paramMapping = new HashMap<String, Integer>();
    
                    //获取方法中的参数,对有自定义注解的参数将其名称和顺序映射放到集合中
                    Annotation[][] paramAnnotations = m.getParameterAnnotations();
    
                    for (int i = 0; i < paramAnnotations.length; i++) {
                        for (Annotation a : paramAnnotations[i]) {
                            //如果注解是EsRequestParam类型
                            if (a instanceof EsRequestParam) {
                                String paramName = ((EsRequestParam) a).value();
                                if (!"".equals(paramName)) {
                                    paramMapping.put(paramName, i);
                                }
                            }
                        }
                    }
                    //处理非自定义注解参数,request,response
                    Class<?>[] types = m.getParameterTypes();
                    for (int i = 0; i < types.length; i++) {
                        Class<?> type = types[i];
                        if (type == HttpServletRequest.class || type == HttpServletResponse.class) {
                            String paramName = type.getName();
                            paramMapping.put(paramName, i);
                        }
                    }
                    //创建handler
                    handlerMapping.add(new Handler(Pattern.compile(reqRegex), entry.getValue(), m, paramMapping));
                    System.out.println("Mapping " + reqRegex + " " + m);
                }
            }
    
        }
    • 第5行:IOC容器如果没有任何元素,则直接返回,不作处理
    • 第6行:开始对IOC容器里面的元素进行遍历
    • 第8-16行:先获取controller上@EsRequestMapping注解的自定义的URL请求路径,作为baseURL
    • 第18-30行:获取当前类的所有方法,然后根据方法上的@EsRequestMapping注解定义的URL请求路径与baseURL进行拼接成一个完整的方法请求路径,然后对URL路径转换成所有请求URL的通用正则表达式,/xxx/xxx.*
    • 第35-47:遍历方法中的所有带有自定义注解的参数,获取@EsRequestParam注解中定义的参数名,参数名和对应的参数顺序存放到paramMapping
    • 第48-56:遍历所有非自定义注解的参数,如reqeust,respon原生自带的参数取其类名作为key与参数顺序映射存放到paramMapping

    以上所有MVC框架初始化阶段的工作已经完成了

    5.3 运行阶段

    运行阶段主要就是通过用户URL请求路径匹配到对应的目标控制器中的方法,利用反射将参数传到方法进行调用,这里我们先重写doPost和doGet方法

     @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            doPost(req, resp);
        }
    
        @Override
        protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
            boolean isSuccess = processRequest(req, resp);
            if (!isSuccess) {
                resp.getWriter().write("404 Page Not Found !");
            }
    
        }
    • 第8-10行:把请求传到我们自定义的执行方法中,如果执行失败则模拟一个异常输出到浏览器

    5.3.1 processRequest处理用户请求

    /**
         * 处理请求映射
         *
         * @param req
         * @param resp
         * @return
         */
        private boolean processRequest(HttpServletRequest req, HttpServletResponse resp) {
    
            if (handlerMapping.isEmpty()) {
                return false;
            }
            try {
                String url = req.getRequestURI();
                String contextPath = req.getContextPath();
                //去掉Url中的上下文,保留请求资源路径
                url = url.replace(contextPath, "").replaceAll("/+", "/");
                //遍历handlerMapping,查找匹配的handler去处理
                for (Handler handler : handlerMapping) {
                    Matcher matcher = handler.getPattern().matcher(url);
                    if (!matcher.matches()) {
                        continue;
                    }
                    Method targetMethod = handler.getMethod();
                    //获取方法参数类型
                    Class<?>[] paramTypes = targetMethod.getParameterTypes();
                    Object[] targetParamsValues = new Object[paramTypes.length];
    
                    //对于用户自定义参数,从请求参数获取用户传参
                    Map<String, String[]> paramMap = req.getParameterMap();
    
    
                    for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
                        //转换数组格式为字符串,去掉"[]",注意存在多个参数时要把单词变为“,”分割
                        String value = Arrays.toString(entry.getValue()).replaceAll("\[|\]", "").replaceAll("\s", ",");
                        //假如参数集合不包含当前请求参数,直接跳过
                        if (!handler.getParamMapping().containsKey(entry.getKey())) {
                            continue;
                        }
                        //取出当前传入参数在方法里的索引位置
                        Integer index = handler.getParamMapping().get(entry.getKey());
                        //进行类型转换并复制
                        targetParamsValues[index] = castStringToTargetType(value, paramTypes[index]);
                    }
                    //对于非用户自定义参数,如容器自带request和response,获取它们在集合中的索引,直接注入
                    Integer reqIndex = handler.getParamMapping().get(HttpServletRequest.class.getName());
                    targetParamsValues[reqIndex] = req;
                    Integer respIndex = handler.getParamMapping().get(HttpServletResponse.class.getName());
                    targetParamsValues[respIndex] = resp;
                    resp.getWriter().write((String) targetMethod.invoke(handler.getController(), targetParamsValues));
                    return true;
                }
    
            } catch (Exception e) {
                e.printStackTrace();
            }
            return true;
        }
    • 第10-11行:如果用户没有定义任何作为控制器的类,则直接返回
    • 第14-17行:获取请求的url,这里注意的是用户请求URL会带有项目上下文路径,而我们handler匹配的url规则里面是没有上下文路径的,因此要把它去掉,如:用户定义的它项目名是test,那么他请求路径就会变成localhost:8080/test/hello/sayHello.do,这里会截断/test这部分,只保留/hello/sayHello.do
    • 第20-23行:利用现有的handler中的pattern去匹配请求URL是否符合规则,不符合则直接获取下一个handler
    • 第24-30行:获取匹配到对应的method,声明后面调用方法需要用到的一些已知条件
    • 第33-51行:遍历请求的参数集合,根据用户的请求参数类型从之前存放好的参数名称与参数顺序集合中匹配,获取对应的参数顺序,然后一个一个将请求参数按顺序填充到targetParamsValues数组中,用作反射调用方法的传参,这里执行完直接调用resp.getWriter().write输出结果到浏览器。第35行要注意的是,因为一个传参可能是一个可变数组会有多个参数[arg0][arg1][arg2],所以这里把数据转换成字符串再进行格式转换为arg0,arg1,arg2。第43行castStringToTargetType会对传参与目标方法的参数类型进行转换,因为这里获取的请求参数都是字符串类型,需要转为目标方法的参数类型才能正常调用。

    以上已经完成MVC框架的开发了,接下来我们创建一个demo去测试下框架是否正常运行

    6. 框架测试

    配置config

    配置扫描包名是com.eshare.demo

    package-scan=com.eshare.demo

    创建控制器Controller类

    /**
     * hello控制器
     * Created by liangyh on 2018/6/20.
     */
    @EsController
    @EsRequestMapping("/hello")
    public class HelloController {
    
        @EsAutowired
        private HelloService helloService;
    
        @EsRequestMapping("/sayHello.do")
        public String sayHello(HttpServletRequest request, HttpServletResponse response,
                               @EsRequestParam("message") String message){
            return helloService.sayHello(message);
        }
    
    }

    创建业务处理Service类

    /**
     * Hello业务处理类接口
     * Created by liangyh on 2018/6/23.
     */
    public interface HelloService {
    
        public String sayHello(String message);
    }

    创建业务处理Service接口实现类

    /**
     * Created by liangyh on 2018/6/23.
     */
    @EsService
    public class HelloServiceImpl implements HelloService {
        @Override
        public String sayHello(String message) {
            return "hello :"+message;
        }
    }

    浏览器输入URL测试

    在浏览器输入http://localhost:8080/hello/sayHello.do?message=springMVC,浏览器显示如下:
    这里写图片描述

  • 相关阅读:
    JS实现类似网页的测试考卷
    Following Orders(poj1270)
    1007
    Intervals(poj1201)
    LightOJ
    1002
    King's Order(hdu5642)
    Beautiful Walls
    A. Watchmen(Codeforces 650A)
    Shortest Path(hdu5636)
  • 原文地址:https://www.cnblogs.com/evan-liang/p/12233936.html
Copyright © 2011-2022 走看看