• 自己动手实现java断点/单步调试(一)


      又是好长时间没有写博客了,今天我们就来谈一下java程序的断点调试。写这篇主题的主要原因是身边的公司或者个人都执着于做apaas平台,简单来说apaas平台就是一个零代码或者低代码的配置平台,通过配置平台相对快速的配置出web端和移动端的代码。这种系统我15年的时候和一个前端朋友为了方便快速的接外包也做过这种配置平台,做了2年多,后面又在某家公司做了一年多apaas平台,我算是深有体会。首先零代码明显只是适合少儿编程领域的玩具,觉得零代码可以包打所有的人大有人在,个人猜想要么是代码写的不够多,或者是被面向对象洗脑了,如果这个世界上所有系统的业务都是简单的通过各种对象调用各种方法,然后通过一定逻辑组合起来,那么确实可以用零代码,但是对象里的具体逻辑难道不是更加复杂的面向过程的代码吗,循环几次,几层嵌套循环,循环里各种判断跳转,传递若干个局部变量,跳出若干层循环之外等等,那么这种要怎么用零代码的逻辑图画出来呢?我想就算真的能画出来,肯定比直接撸代码更加困难了。相比较低代码比较靠谱一些,低代码也就是要写代码,如果要写代码不提供调试,那是不是在耍流氓呢?

      其实15年那会我也遇到了需要在配置平台提供调试功能的尴尬,当时在网上找了一遍,根本找不到可以直接使用的断点调试代码,为此还自己设计了一个蹩脚的解释性语言,这个可以从之前博客找到相关内容。后面Nashorn引擎出来后,就替换成了使用js写业务代码,调试使用aop+方法拦截器+stack的方式实现了一个简单的断点调试,不过实现的一直都觉得很蹩脚,刚好现在有点时间了,决定研究一下java的断点调试,百度找了下一堆JDI的实现,但是基本上全部都是介绍一堆理论,然后给出一个helloword式的例子,给待调试程序打个断点,然后发出断点事件,然后消费断点事件,打印日志,然后就没有然后了。我在想如果要打印日志用aop它不香吗?说好的调试呢?更有进一步的:使用JDI调试多线程应用,点进去还是无脑的消费事件,打印一堆日志。想不到时隔多年网上还是找不到一个可以直接用的断点调试程序,不过我也要感谢你们,如果已经有了,那我就没有这篇文章什么事情了。鉴于网上一堆JDI的理论文章,本着只写网上找不到的原创作品,本篇就决定不讲理论了,理论百度上一大堆,搜索“JDI调试”即可。

      进入正题,其实jdk的tools.jar中已经实现了一套调试的JDI的api,也就是java的调试接口,只不过用起来真的是很花时间,为了增强各位看官的兴趣,先演示一下断点调试的效果,下面进入我最喜欢的贴代码环节,先准备如下被调试服务

     1 package com.rdpaas.debugger.test.controller;
     2 
     3 import com.rdpaas.debugger.test.bean.Person;
     4 import com.rdpaas.debugger.test.service.TestService;
     5 import com.rdpaas.debugger.test.utils.MyList;
     6 import com.rdpaas.debugger.test.utils.MyMap;
     7 import org.springframework.beans.factory.annotation.Autowired;
     8 import org.springframework.web.bind.annotation.RequestMapping;
     9 import org.springframework.web.bind.annotation.RequestParam;
    10 import org.springframework.web.bind.annotation.RestController;
    11 
    12 import java.util.Arrays;
    13 
    14 /**
    15  * 被调试接口
    16  * @author rongdi
    17  * @date 2021/1/24
    18  */
    19 @RestController
    20 public class TestController {
    21 
    22     @Autowired
    23     private TestService testService;
    24 
    25     private Integer flag = 1;
    26 
    27     @RequestMapping("/test")
    28     public Person test(@RequestParam String name) throws Exception {
    29         Person ret = testService.getPerson(name);
    30         MyList list1 = new MyList();
    31         list1.addAll(Arrays.asList(1,2,3));
    32         MyList list2 = new MyList();
    33         list2.add(new Person("张三",20));
    34         MyMap map1 = new MyMap();
    35         map1.put("name","小明");
    36         MyMap map2 = new MyMap();
    37         map2.put("person",new Person("李四",30));
    38         return ret;
    39     }
    40 
    41 
    42 }
     1 package com.rdpaas.debugger.test.service;
     2 
     3 import com.rdpaas.debugger.test.bean.Person;
     4 import org.springframework.stereotype.Service;
     5 
     6 @Service
     7 public class TestService {
     8 
     9     public Person getPerson(String name) {
    10         Person p = new Person();
    11         p.setAge(20);
    12         p.setName(name);
    13         return p;
    14     }
    15 
    16 }
     1 package com.rdpaas.debugger.test.bean;
     2 
     3 public class Person {
     4 
     5     private String name;
     6 
     7     private Integer age;
     8 
     9     public Person(String name, Integer age) {
    10         this.name = name;
    11         this.age = age;
    12     }
    13 
    14     public Person() {
    15     }
    16 
    17     public String getName() {
    18         return name;
    19     }
    20 
    21     public void setName(String name) {
    22         this.name = name;
    23     }
    24 
    25     public Integer getAge() {
    26         return age;
    27     }
    28 
    29     public void setAge(Integer age) {
    30         this.age = age;
    31     }
    32 }
     1 package com.rdpaas.debugger.test.utils;
     2 
     3 import java.util.ArrayList;
     4 
     5 /**
     6  * 故意定义一个集合的实现类,看看调试程序是否可以识别,并显示
     7  */
     8 public class MyList extends ArrayList {
     9 
    10 }
     1 package com.rdpaas.debugger.test.utils;
     2 
     3 import java.util.HashMap;
     4 
     5 /**
     6  * 故意定义一个map的实现类,看看调试程序是否可以识别,并显示
     7  */
     8 public class MyMap extends HashMap {
     9 
    10 }
     1 package com.rdpaas.debugger.test;
     2 
     3 import org.springframework.boot.SpringApplication;
     4 import org.springframework.boot.autoconfigure.SpringBootApplication;
     5 
     6 /**
     7  * @author rongdi
     8  * @date 2021/1/24
     9  */
    10 @SpringBootApplication
    11 public class RunTestApplication {
    12 
    13     public static void main(String[] args) {
    14         SpringApplication.run(RunTestApplication.class,args);
    15     }
    16 }
    我们先使用调试接口给TestController类29行打上断点

     断点接口处于阻塞状态,然后请求打了断点的服务

     这时候被调试的接口处于阻塞状态了,然后再看断点接口已经返回了

     最后我们结束调试,这时候被调试服务也会解除阻塞成功返回

     依赖如下

    <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </dependency>
            <dependency>
                <groupId>jdk.tools</groupId>
                <artifactId>jdk.tools</artifactId>
                <version>1.8</version>
                <scope>system</scope>
                <systemPath>${JAVA_HOME}lib/tools.jar</systemPath>
            </dependency>
    </dependencies>

       好了,看到如上效果感兴趣的应该可以坚持看下去了。远程调试不同一JVM的本地调试,JDI远程调试返回的对象全部是镜像对象,哪怕是最简单的一般数据类型,这就为处理数据都提升了很大的难度,特别是集合,映射那些,后面再说。

      首先我们需要先准备一个被调试的程序,不管是web服务或者是本地的程序都可以,但是一定要添加如下启动参数,目的就是为了配置调试需要的各种参数

    -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5000

    如上就是开放了一个端口为5000的socket端口用于远程调试的连接端口,连接代码如下

     /**
         * 连接指定主机的指定调试端口返回一个虚拟主机对象,以下属于公式代码就不做解释了
         * @param hostname 待调试程序的主机地址
         * @param port 调试程序开放的后门调试端口
         * @return
         * @throws Exception
         */
        private VirtualMachine connJVM(String hostname, Integer port) throws Exception {
    
            VirtualMachineManager vmm = Bootstrap.virtualMachineManager();
            List<AttachingConnector> connectors = vmm.attachingConnectors();
            SocketAttachingConnector sac = null;
            for(AttachingConnector ac:connectors) {
                if(ac instanceof SocketAttachingConnector) {
                    sac = (SocketAttachingConnector) ac;
                }
            }
            if(sac == null) {
                throw new Exception("未找到SocketAttachingConnector连接器");
            }
            Map<String, Connector.Argument> arguments = sac.defaultArguments();
            arguments.get("hostname").setValue(hostname);
            arguments.get("port").setValue(String.valueOf(port));
            return sac.attach(arguments);
        }

    如上连接代码网上一大把,属于公式代码了,好吧,我承认还是直接贴出代码,然后在代码里加上详细的注释说起来容易一些,这样又贴代码又在外面解释感觉很别扭,下面直接贴上调试的所有代码:

    /**
         * 当断点到最后一行后,调用断开连接结束调试
         */
        public DebugInfo disconnect() throws Exception {
            virtualMachine.dispose();
            map.remove(tag);
            return getInfo();
        }/**
         * 在指定类的指定行打上断点
         * @param className 类的全限定名
         * @param line 断点所在的有效行号(不要不讲武德打在空白行上)
         * @throws Exception
         */
        private void markBreakpoint(String className, Integer line) throws Exception {
            /**
             * 根据虚拟主机拿到一个事件请求管理器
             */
            EventRequestManager eventRequestManager = virtualMachine.eventRequestManager();
            /**
             * 主要是为了添加当前断点是把之前断点事删掉,
             */
            if(eventRequest != null) {
                eventRequestManager.deleteEventRequest(eventRequest);
            }
            /**
             * 根据调试类的全限定名,拿到一个调试类的远程引用类型,请注意这里是远程调试,在当前调试程序的jvm中不会
             * 装载有被调试类,所以这里只能是得到一个包装后的类型,至于为啥是个集合,是因为这个被调试类可能正在被多个
             * 线程调用
             */
            List<ReferenceType> rts = virtualMachine.classesByName(className);
            if(rts == null || rts.isEmpty()) {
                throw new Exception("无法获取有效的debug类");
            }
    
            /**
             * 不要说我不讲武德,正常的本地调试在多线程环境中也只能调试最先到达的那个线程的调用,所以这里也是直接
             * 获取第一个线程调用,同样只能可怜兮兮的获取到一个Class的包装类型,谁叫我们是远程调试呢
             */
            ClassType classType = (ClassType) rts.get(0);
            /**
             * 根据行获取位置对象,这里为啥又是个集合,好吧我承认忽悠不过去了,我也不明白,谁叫这JDI是人家设计的呢
             */
            List<Location> locations = classType.locationsOfLine(line);
            if(locations == null || locations.isEmpty()) {
                throw new Exception("无法获取有效的debug行");
            }
            /**
             * 一如既往的获取第一个位置信息
             */
            Location location = locations.get(0);
    
            /**
             * 创建一个断点并激活,这是公式代码,下面的EventRequest.SUSPEND_EVENT_THREAD表示断点执行过程阻塞当前线程,
             * SUSPEND_ALL 表示阻塞所有线程。实际上创建并激活的事件请求会被放在一个时间队列中
             */
            BreakpointRequest breakpointRequest = eventRequestManager.createBreakpointRequest(location);
            breakpointRequest.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
            breakpointRequest.enable();
    
            /**
             * 当前断点创建好了,赶紧释放被调试程序,让他有机会执行到当前断点,如果不放行就会一直卡在当前断点之前的其它断点,
             * 没机会到这里了,这里选择在这里放行而不是在执行完上一个断点后马上放行是因为我们的断点调试的断点请求并不是
             * 刚开始调试就确定好的,而是执行到当前行后由前端判断本行是否有断点,然后请求到调试程序的,属于动态添加断点,
             * 如果上一个断点执行完,马上释放那么当前断点可能都还没请求就过去了。
             */
            if(eventsSet != null) {
                eventsSet.resume();
            }
    
        }
    
        /**
         * 消费调试的事件请求,然后拿到当前执行的方法,参数,变量等信息,也就是debug过程中我们关注的那一堆变量信息
         * @return
         * @throws Exception
         */
        private DebugInfo getInfo() throws Exception {
            DebugInfo debugInfo = new DebugInfo();
            EventQueue eventQueue = virtualMachine.eventQueue();
            /**
             * 这个是阻塞方法,当有事件发出这里才可以remove拿到EventsSet
             */
            eventsSet= eventQueue.remove();
            EventIterator eventIterator = eventsSet.eventIterator();
            if(eventIterator.hasNext()) {
                Event event = eventIterator.next();
                /**
                 * 一个debug程序能够debug肯定要有个断点,直接从断点事件这里拿到当前被调试程序当前的执行线程引用,
                 * 这个引用是后面可以拿到信息的关键,所以保存在成员变量中,归属于当前的调试对象
                 */
                if(event instanceof BreakpointEvent) {
                    threadReference = ((BreakpointEvent) event).thread();
                } else if(event instanceof VMDisconnectEvent) {
                    /**
                     * 这种事件是属于讲武德的判断方式,断点到最后一行之后调用virtualMachine.dispose()结束调试连接
                     */
                    debugInfo.setEnd(true);
                    return debugInfo;
                }
                try {
                    /**
                     * 获取被调试类当前执行的栈帧,然后获取当前执行的位置
                     */
                    StackFrame stackFrame = threadReference.frame(0);
                    Location location = stackFrame.location();
    
                    /**
                     * 无脑的封装返回对象
                     */
                    debugInfo.setClassName(location.declaringType().name());
                    debugInfo.setMethodName(location.method().name());
                    debugInfo.setLineNumber(location.lineNumber());
                    /**
                     * 封装成员变量
                     */
                    ObjectReference or = stackFrame.thisObject();
                    if(or != null) {
                        List<Field> fields = ((LocationImpl) location).declaringType().fields();
                        for(int i = 0;fields != null && i < fields.size();i++) {
                            Field field = fields.get(i);
                            Object val = parseValue(or.getValue(field),0);
                            DebugInfo.VarInfo varInfo = new DebugInfo.VarInfo(field.name(),field.typeName(),val);
                            debugInfo.getFields().add(varInfo);
                        }
                    }
                    /**
                     * 封装局部变量和参数,参数是方法传入的参数
                     */
                    List<LocalVariable> varList = stackFrame.visibleVariables();
                    for (LocalVariable localVariable : varList) {
                        /**
                         * 这地方使用threadReference.frame(0)而不是使用上面已经拿到的stackFrame,从代码上看是等价,
                         * 但是有个很坑的地方,如果使用stackFrame由于下面使用threadReference执行过invokeMethod会导致
                         * stackFrame的isValid为false,再次通过stackFrame.getValue就会报错,每次重新threadReference.frame(0)
                         * 就没有问题,由于看不到源码,个人推测threadReference.frame(0)这里会生成一份拷贝stackFrame,由于手动执行方法,
                         * 方法需要用到栈帧会导致执行完方法,这个拷贝的栈帧被销毁而变得不可用,而每次重新获取最上面得栈帧,就不会有问题
                         */
                        DebugInfo.VarInfo varInfo = new DebugInfo.VarInfo(localVariable.name(),localVariable.typeName(),parseValue(threadReference.frame(0).getValue(localVariable),0));
                        if(localVariable.isArgument()) {
                            debugInfo.getArgs().add(varInfo);
                        } else {
                            debugInfo.getVars().add(varInfo);
                        }
                    }
                } catch(AbsentInformationException | VMDisconnectedException e1) {
                    debugInfo.setEnd(true);
                    return debugInfo;
                } catch(Exception e) {
                    debugInfo.setEnd(true);
                    return debugInfo;
                }
    
            }
    
            return debugInfo;
        }
    
        /**
         * 费劲的转换,一切都是因为调试类和被调试类不在一个JVM中,所以拿到的对象都只是一个包装类,拿不到源对象
         * @param value 待解析的值
         * @param depth 当前深度编号
         * @return
         * @throws Exception
         */
        private Object parseValue(Value value,int depth) throws Exception {
            if(value instanceof StringReference || value instanceof IntegerValue || value instanceof BooleanValue
                    || value instanceof ByteValue || value instanceof CharValue || value instanceof ShortValue
                    || value instanceof LongValue || value instanceof FloatValue || value instanceof DoubleValue) {
                return parseCommonValue(value);
            } else if(value instanceof ObjectReference) {
                int localDepth = depth;
                ObjectReference obj = (ObjectReference) value;
                String type = obj.referenceType().name();
                if("java.lang.Integer".equals(type) || "java.lang.Boolean".equals(type) || "java.lang.Float".equals(type)
                        || "java.lang.Double".equals(type) || "java.lang.Long".equals(type) || "java.lang.Byte".equals(type)
                        || "java.lang.Character".equals(type)) {
                    Field f = obj.referenceType().fieldByName("value");
                    return parseCommonValue(obj.getValue(f));
                } else if("java.util.Date".equals(type)) {
                    Field field = obj.referenceType().fieldByName("fastTime");
                    Date date = new Date(Long.parseLong("" + obj.getValue(field)));
                    return date;
                } else if(value instanceof ArrayReference) {
                    ArrayReference ar = (ArrayReference) value;
                    List<Value> values = ar.getValues();
                    List<Object> list = new ArrayList<>();
                    for(int i = 0;i < values.size();i++) {
                        list.add(parseValue(values.get(i),depth));
                    }
                    return list;
                    /**
                     * 个人感觉都已经有点不讲武德了,实在没有找到更优雅的方法了
                     */
                } else if(isCollection(obj)) {
                    Method toArrayMethod = obj.referenceType().methodsByName("toArray").get(0);
                    value = obj.invokeMethod(threadReference, toArrayMethod, Collections.emptyList(), 0);
                    return parseValue(value,++localDepth);
                }  else if(isMap(obj)) {
                    /**
                     * 这里是一个比较巧妙的利用递归方式,将map先转成集合,然后再调用本方法转成数组,然后就可以走到ArrayReference进行处理
                     */
                    Method entrySetMethod = obj.referenceType().methodsByName("entrySet").get(0);
                    value = obj.invokeMethod(threadReference, entrySetMethod, Collections.emptyList(), 0);
                    return parseValue(value,++localDepth);
                } else {
                    Map<String,Object> map = new HashMap<>();
                    String className = obj.referenceType().name();
                    map.put("class",className);
                    /**
                     * 到了Object就不继续了
                     */
                    if("java.lang.Object".equals(className)) {
                        return map;
                    }
                    List<Field> fields = obj.referenceType().allFields();
                    for(int i = 0;fields != null && i < fields.size();i++) {
                        localDepth = depth;
                        /**
                         * 这里有个递归,万一被调试类不讲武德搞一个无限自循环的对象,比如Person类里有个成员变量p直接声明的时候
                         * 就new一个Person,这样这个Person对象的深度是无限的,为了防止内存溢出,限制深度不超过2,你要是不信邪,
                         * 你改成5试试,就本例的例子,执行到最后一行后,继续stepOver,可以给你返回上十万行数据,呵呵
                         */
                        if(localDepth < 2) {
                            Field f = fields.get(i);
                            map.put(f.name(), parseValue(obj.getValue(f), ++localDepth));
                        }
                    }
                    return map;
                }
            }
            return null;
        }
    
        /**
         * 万恶的穷举,真的是很恶心,如果不转直接放这个包装的Value出去变成json后就拿不到真实的value值,
         * 别看打印的时候可以打印,还好这些鬼东西是有规律的,调试的时候试出来了一个,其余都出来了
         * @param value
         * @return
         */
        private Object parseCommonValue(Value value) {
            if(value instanceof StringReference) {
                return ((StringReferenceImpl) value).value();
            } else if(value instanceof IntegerValue) {
                return ((IntegerValueImpl) value).value();
            } else if(value instanceof BooleanValue) {
                return ((BooleanValueImpl) value).value();
            } else if(value instanceof ByteValue) {
                return ((ByteValueImpl) value).value();
            } else if(value instanceof CharValue) {
                return ((CharValueImpl) value).value();
            } else if(value instanceof ShortValue) {
                return ((ShortValueImpl) value).value();
            } else if(value instanceof LongValue) {
                return ((LongValueImpl) value).value();
            } else if(value instanceof FloatValue) {
                return ((FloatValueImpl) value).value();
            } else if(value instanceof DoubleValue) {
                return ((DoubleValueImpl) value).value();
            } else {
                return null;
            }
        }
    
        /**
         * 判断是不是集合,经过了多轮的纠结,最开始尝试使用java.util开头,包含List的,如:
         * type.startsWith("java.util.") && ((type.indexOf("List") != -1) || (type.indexOf("Set") != -1))
         * 结果发现太片面,不讲武德都没法形容了,如果是List的实现类就没办法了,只能通过这种方式了,毕竟找了很多api找不到直接判断
         * 这个调试的镜像对象是否是集合的方法。请不要作死,明明不是集合,非要给自己的类定义一个toArray方法
         */
        private boolean isCollection(ObjectReference obj) throws ClassNotLoadedException {
            List<Method> toArrayMethods = obj.referenceType().methodsByName("toArray");
            boolean flag = false;
            for(int i = 0;i < toArrayMethods.size();i++) {
                Method toArrayMethod = toArrayMethods.get(i);
                flag = (toArrayMethod.argumentTypes().size() == 0);
                if(flag) {
                    break;
                }
            }
            return flag;
        }
    
        /**
         * 判断是不是Map,经过了多轮的纠结,最开始尝试使用java.util开头,包含Map的,如:
         * (type.startsWith("java.util.") && (type.indexOf("Map") != -1) && !type.endsWith("$Node"))
         * 还是发现太片面,如果是Map的实现类就没办法了,只能通过这种判断是否有不带桉树的entrySet方法的方式了,你自己实现
         * 的类总不会明明不是一个map,你非要定义一个entrySet方法,这种作死的情况,我就不管了,毕竟找了很多api找不到
         * 直接判断这个调试的镜像对象是否是map的方法。
         */
        private boolean isMap(ObjectReference obj) throws ClassNotLoadedException {
            List<Method> toArrayMethods = obj.referenceType().methodsByName("entrySet");
            boolean flag = false;
            for(int i = 0;i < toArrayMethods.size();i++) {
                Method toArrayMethod = toArrayMethods.get(i);
                flag = (toArrayMethod.argumentTypes().size() == 0);
                if(flag) {
                    break;
                }
            }
            return flag;
        }
    

      如上代码,其实在我写这份代码时的所有思路和纠结全部在注释里面了,个人感觉JDI提供的api是真的很难用,需要很强的耐心去断点和一个个api去尝试,而且由于没找到tools.jar的源码,更增加了使用的难度。

    最后相关依赖如下

    <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </dependency>
            <dependency>
                <groupId>jdk.tools</groupId>
                <artifactId>jdk.tools</artifactId>
                <version>1.8</version>
                <scope>system</scope>
                <systemPath>${JAVA_HOME}lib/tools.jar</systemPath>
            </dependency>
    </dependencies>

      一个类搞定所有,是我最喜欢的方式,有些人可能不认同,非要搞一些花里胡哨的设计模式,调用层次搞得很深,我想说,如果JVM没有方法内联(你写了一堆方法编译的时候直接给你把代码copy到一个方法里去执行)之类的优化,那市面上绝大多数代码没有性能可言,要不然为啥尾递归优化是将递归优化成循环。每次调用一个方法都涉及到新建方法栈,保存本地变量,销毁方法栈等繁琐的过程,所以每次调用方法都是有代价的,作为开发者,我一直认为非必要不要优化结构,直来直去是最容易理解的结构,优化结构不能带来性能的提升,优化算法才是,性能最极限的代码往往都是最简单的。

      当然为了扩展性确实需要使用一些设计模式,但是那也是要有需要扩展的地方才需要用到,屁大点项目,屁大的个工具,刚开始就设计的那么复杂,你是要扩展啥,又是遇到了啥扩展瓶颈。往往很多开源项目写出来其实根本不是为了让别人方便的看懂,随便一个调用都搞个2位数的调用深度,这到底是是炫技还是故意增加技术壁垒,我要是写个方法一直往里面调用,调用20多个方法,你愿意硬着头皮看下去还是果断放弃呢?有些大厂的代码要不是不看看不好找工作,是真的没有勇气看下去。有些开源软件不是人家不想参与进去,是你代码足够复杂,技术足够牛,很少有人看得懂,呵呵。下一篇博客继续实现断点调试的单步调试相关功能,感兴趣可以关注同名公众号,方便实时推送更新和获取完整源码。

  • 相关阅读:
    IDirect3DDevice9::Clear
    Width vs Pitch
    5- vue django restful framework 打造生鲜超市 -完成商品列表页(上)
    4- vue django restful framework 打造生鲜超市 -restful api 与前端源码介绍
    3- vue django restful framework 打造生鲜超市
    2- vue django restful framework 打造生鲜超市 -环境搭建
    1- vue django restful framework 打造生鲜超市
    Scrapy分布式爬虫打造搜索引擎- (二)伯乐在线爬取所有文章
    windows10上安装mysql
    博客开通第一天
  • 原文地址:https://www.cnblogs.com/rongdi/p/14348508.html
Copyright © 2020-2023  润新知