一、前言

写这篇文章的时候我在想可能大部分程序员包括你我,常常都在忙于业务开发或奔波在日常维护与修复BUG的路上,当不能从中吸取技术营养与改变现状后,就像一台恒定运行的机器,逃不出限定宇宙速度的一个圈里。可能你也会有自己的难处,平时加班太晚没有时间学习、周末家里琐事太多没有精力投入,放假计划太满没有空闲安排。总之,学习就会被搁置。而当一年年的过去后,当自己的年龄与能力不成匹配后又会后悔没有给多投入一些时间学习成长。

尤其是一线编码的技术人,除了我们所能看到的在技术框架里(SSM)开发的业务代码,你是否有遇到过学习瓶颈,而这种瓶颈又是你自己不知道自己不会什么,就像下面这些技术列表里,你有了解多少;

1. javaagent
2. asm
3. jvmti
4. javaassit
5. netty
6. 算法,搜索引擎
7. cglib
8. 混沌工程
9. 中间件开发
10. 高级测试;压力测试、链路测试、流量回放、流量染色
11. 故障系列;突袭、重现、演练
12. 分布式的数据一致性
13. 文件操作;es、hive
14. 注册中心;zookeeper、Eureka
15. 互联网工程开发技术栈;spring、mybaits、网关、rpc(thrift, grpc, dubbo)、mq、缓存redis、分库分表、定时任务、分布式事物、限流、熔断、降级
16. 数据库binlog解析 
17. 架构设计;DDD领域驱动设计、微服务、服务治理
18. 容器;k8s, docker
19. 分布式存储;ceph
20. 服务istio
21. 压测 jmter
22. Jenkins-部署java代码项目 + ansible
23. 全链路监控,分布式追踪
24. 语音识别、语音合成
26. lvs nginx haproxy iptables
27. hadoop mapreduce hive sqoop hbase flink kylin druid

  1. 类的代理,如cglib
  2. 混沌工程
  3. 反向工程
  4. 结合 javaagent 做到非入侵式监控,方法耗时、日志、机器性能等等
  5. 破解

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

为了更方便的学习ASM,我将《ASM4使用手册》以及一些技术点整理成在线文档,可以随时方便查阅(http://asm.itstack.org);

另外关于本文中出现的代码例子,可以通过在公众号(bugstack虫洞栈)内回复,源码下载获取。

二、环境配置

  1. jdk 1.8
  2. idea 2019.3.1
  3. asm-commons 6.2.1

三、工程信息

  • itstack-demo-asm-01:字节码编程,HelloWorld
  • itstack-demo-asm-02:字节码编程,两数之和
  • itstack-demo-asm-03:字节码增强,输出入参
  • itstack-demo-asm-04:字节码增强,调用外部方法

四、HelloWorld还可以这样写

你所熟悉的HelloWorld是不这样;

public class HelloWorld {public static void main(String[] var0) {System.out.println("Hello World");}
}

那你有尝试反解析下他的类查看下汇编指令吗,javap -c HelloWorld

public class org.itstack.demo.test.HelloWorld {public org.itstack.demo.test.HelloWorld();Code:0: aload_01: invokespecial #1                  // Method java/lang/Object."<init>":()V4: returnpublic static void main(java.lang.String[]);Code:0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc           #3                  // String Hello World5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: return
}
指令 描述
getstatic 获取静态字段的值
ldc 常量池中的常量值入栈
invokevirtual 运行时方法绑定调用方法
return     void函数返回

如果你还感兴趣其他指令,可以参考这个字节码指令表:Go!

好! 以上呢,是我很熟悉的一段代码了,那么现在我们把这段代码用ASM方式写出来;

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;private static byte[] generate() {ClassWriter classWriter = new ClassWriter(0);// 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmHelloWorld", null, "java/lang/Object", null);// 添加方法;修饰符、方法名、描述符、签名、异常MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);// 执行指令;获取静态属性methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");// 加载常量 load constantmethodVisitor.visitLdcInsn("Hello World");// 调用方法methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);// 返回methodVisitor.visitInsn(Opcodes.RETURN);// 设置操作数栈的深度和局部变量的大小methodVisitor.visitMaxs(2, 1);// 方法结束methodVisitor.visitEnd();// 类完成classWriter.visitEnd();// 生成字节数组return classWriter.toByteArray();
}

以上的代码,“小朋友,你是否有很多问好???^1024”,其实以上的代码都是来自于 ASM 框架的代码,这里面所有的操作与我们使用使用 javap -c XXX 所反解析出的字节码是一样的,只不过是反过来使用指令来编写代码。

  1. 定义一个类的生成 ClassWriter
  2. 设定版本、修饰符、全类名、签名、父类、实现的接口,其实也就是那句;public class HelloWorld
  3. 接下来开始创建方法,方法同样需要设定;修饰符、方法名、描述符等。这里面有几个固定标识;

类型描述符

| Java 类型 | 类型描述符 |
|:—|:—|
| boolean | Z |
| char | C |
| byte | B |
| short | S |
| int | I |
| float | F |
| long | J |
| double | D |
| Object | Ljava/lang/Object; |
| int[] | [I |
| Object[][] | [[Ljava/lang/Object; |

方法描述符

| 源文件中的方法声明 | 方法描述符 |
|:—|:—|
| void m(int i, float f) | (IF)V |
| int m(Object o) | (Ljava/lang/Object;)I |
| int[] m(int i, String s) | (ILjava/lang/String;)[I |
| Object m(int[] i) | ([I)Ljava/lang/Object; |

([Ljava/lang/String;)V== void main(String[] args)

  1. 执行指令;获取静态属性。主要是获得 System.out
  2. 加载常量 load constant,输出我们的HelloWorld methodVisitor.visitLdcInsn("Hello World");
  3. 最后是调用输出方法并设置空返回,同时在结尾要设置操作数栈的深度和局部变量的大小

这样输出一个 HelloWorld 是不还是蛮有意思的,虽然你可能觉得这编码起来实在太难了吧,也非常难理解。首先如果你看过我的专栏,用《Java写一个Jvm虚拟机》,那么你可能会感受到这里面的知识点还是不那么陌生的。另外这里的编写,ASM还提供了插件,可以方便的让你开发字节码。接下来就介绍一下使用方式。

五、有插件的帮助字节码开发也不是很难

对于新人来说如果用字节码增强开发一些东西确实挺难,尤其是一些复杂的代码块使用字节码指令操作还是很有难度的。那么,其实也是有简单办法就是使用 ASM 插件。这个插件可以很轻松的让你看到一段代码的指令码以及如何用ASM去开发。

安装插件(ASM Bytecode Outline)

测试使用

是不是看到有插件的帮助下,心里有所激动了,至少写这样的东西有了抓手。这样你就可以很方便的去操作一些增强字节码的功能了。

六、用字节码写出一个两数之和计算

好!有了上面的插件,也有了一些基础知识的了解。那么我们开发一个计算两数之和的方法,之后运行计算结果。

这是我们的目标

public class SumOfTwoNumbers {public int sum(int i, int m) {return i + m;}}

使用字节码编程方式实现

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;private static byte[] generate() {ClassWriter classWriter = new ClassWriter(0);{MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);methodVisitor.visitInsn(Opcodes.RETURN);methodVisitor.visitMaxs(1, 1);methodVisitor.visitEnd();}{// 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmSumOfTwoNumbers", null, "java/lang/Object", null);// 添加方法;修饰符、方法名、描述符、签名、异常MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "sum", "(II)I", null, null);methodVisitor.visitVarInsn(Opcodes.ILOAD, 1);methodVisitor.visitVarInsn(Opcodes.ILOAD, 2);methodVisitor.visitInsn(Opcodes.IADD);// 返回methodVisitor.visitInsn(Opcodes.IRETURN);// 设置操作数栈的深度和局部变量的大小methodVisitor.visitMaxs(2, 3);methodVisitor.visitEnd();}// 类完成classWriter.visitEnd();// 生成字节数组return classWriter.toByteArray();
}

上面有两个括号 {},第一个是用于生成一个空的构造函数

public AsmSumOfTwoNumbers() {
}

接下来的指令就比较简单了,首先使用 ILOAD进行数值的两次压栈也就是弄到操作数栈里去操作,接下来开始执行 IADD,将两数相加。

最后返回结果 IRETURN,注意是返回的 I 类型。到此这段方法快就实现完成了。反编译后如下;

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//package org.itstack.demo.asm;public class AsmSumOfTwoNumbers {public AsmSumOfTwoNumbers() {}public int doSum(int var1, int var2) {return var1 + var2;}
}

执行代码块

public static void main(String[] args) throws Exception {// 生成二进制字节码byte[] bytes = generate();// 输出字节码outputClazz(bytes);// 加载AsmSumOfTwoNumbersGenerateSumOfTwoNumbers generateSumOfTwoNumbers = new GenerateSumOfTwoNumbers();Class<?> clazz = generateSumOfTwoNumbers.defineClass("org.itstack.demo.asm.AsmSumOfTwoNumbers", bytes, 0, bytes.length);// 反射获取 main 方法Method method = clazz.getMethod("sum", int.class, int.class);Object obj = method.invoke(clazz.newInstance(), 6, 2);System.out.println(obj);
}

这段执行操作和我们在使用 java 的反射操作一样,也是比较容易的。此时我们是调用了新的字节码类,同时还将字节码输出方便我们查看生成的 class类。

七、在原有方法上字节码增强监控耗时

到这我们基本了解到通过字节码编程,可以动态的生成一个类。但是在实际使用的过程中,我们可能有的时候是需要修改一个原有的方法,在开始和结尾添加一些代码,来监控这个方法的耗时。这也是非侵入式监控的最基本模型。

定义一个方法

public class MyMethod {public String queryUserInfo(String uid) {System.out.println("xxxx");System.out.println("xxxx");System.out.println("xxxx");System.out.println("xxxx");return uid;}}

像这个方法插入监控

public class TestMonitor extends ClassLoader {public static void main(String[] args) throws IOException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException {ClassReader cr = new ClassReader(MyMethod.class.getName());ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);{MethodVisitor methodVisitor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);methodVisitor.visitInsn(Opcodes.RETURN);methodVisitor.visitMaxs(1, 1);methodVisitor.visitEnd();}ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName());cr.accept(cv, ClassReader.EXPAND_FRAMES);byte[] bytes = cw.toByteArray();outputClazz(bytes);Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length);Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class);Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001");System.out.println("测试结果:" + obj);}static class ProfilingClassAdapter extends ClassVisitor {public ProfilingClassAdapter(final ClassVisitor cv, String innerClassName) {super(ASM5, cv);}public MethodVisitor visitMethod(int access,String name,String desc,String signature,String[] exceptions) {System.out.println("access:" + access);System.out.println("name:" + name);System.out.println("desc:" + desc);if (!"queryUserInfo".equals(name)) return null;MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);return new ProfilingMethodVisitor(mv, access, name, desc);}}static class ProfilingMethodVisitor extends AdviceAdapter {private String methodName = "";protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) {super(ASM5, methodVisitor, access, name, descriptor);this.methodName = name;}@Overrideprotected void onMethodEnter() {mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);mv.visitVarInsn(LSTORE, 2);mv.visitVarInsn(ALOAD, 1);}@Overrideprotected void onMethodExit(int opcode) {if ((IRETURN <= opcode && opcode <= RETURN) || opcode == ATHROW) {mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");mv.visitTypeInsn(NEW, "java/lang/StringBuilder");mv.visitInsn(DUP);mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);mv.visitLdcInsn("方法执行耗时(纳秒)->" + methodName+":");mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);mv.visitVarInsn(LLOAD, 2);mv.visitInsn(LSUB);mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);}}}}

整体的代码块有点大,我们可以分为块来看,如下;

  1. ClassReader cr = new ClassReader(MyMethod.class.getName());读取原有类,也是字节码增强的开始
  2. ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName());开始增强字节码
  3. onMethodEnter,onMethodExit,在方法进入和方法退出时添加耗时执行的代码。

测试结果:

直接运行TestMonitor.java;

access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-03/target/classes/AsmTestMonitor.class
xxxx
xxxx
xxxx
xxxx
方法执行耗时(纳秒)->queryUserInfo:132300
测试结果:10001

八、字节码控制打印方法的入参

那么除了可以监控方法的执行耗时,还可以将方法的入参信息进行打印出来。这样就可以在一些异常情况下,看到日志信息。

其他代码与上面相同,这里只列一下修改的地方

static class ProfilingMethodVisitor extends AdviceAdapter {private String methodName = "";protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) {super(ASM5, methodVisitor, access, name, descriptor);this.methodName = name;}@Overrideprotected void onMethodEnter() {mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");mv.visitVarInsn(ALOAD, 1);mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);}@Overrideprotected void onMethodExit(int opcode) {}
}

从这里可以看到,在方法进入时候使用指令码 GETSTATIC,获取输出对象类
接下来使用 ALOAD,从局部变量1中装载引用类型值入栈
最后输出入参信息

测试结果:

直接运行TestMonitor.java;

Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length);Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class);Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001");System.out.println("测试结果:" + obj);

结果;

access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-04/target/classes/AsmTestMonitor.class
10001...

10001 就是我们的方法入参

九、用字节码增强调用外部方法

好!那么执行到这,我们可以想到如果只是将一些信息打印到控制台还是没有办法做业务的,我们需要在这个时候将各种属性信息调用外部的类,进行发送到服务端。比如使用;mq、日志等。

定义日志信息输出类

public class MonitorLog {public static void info(String name, int... parameters) {System.out.println("方法:" + name);System.out.println("参数:" + "[" + parameters[0] + "," + parameters[1] + "]");}}

这个类主要模拟字节码增强后,方法调用输出一些信息

增强字节码

static class ProfilingMethodVisitor extends AdviceAdapter {private String name;...@Overrideprotected void onMethodEnter() {// 输出方法和参数mv.visitLdcInsn(name);mv.visitInsn(ICONST_2);mv.visitIntInsn(NEWARRAY, T_INT);mv.visitInsn(DUP);mv.visitInsn(ICONST_0);mv.visitVarInsn(ILOAD, 1);mv.visitInsn(IASTORE);mv.visitInsn(DUP);mv.visitInsn(ICONST_1);mv.visitVarInsn(ILOAD, 2);mv.visitInsn(IASTORE);mv.visitMethodInsn(INVOKESTATIC, "org/itstack/demo/asm/MonitorLog", "info", "(Ljava/lang/String;[I)V", false);}
}

这里的有一部分字节码操作,其实在增强后最终的效果如下;

public int sum(int i, int m) {Monitor.info("sum", i, m);return i + m;
}

测试结果:

access:1
name:sum
desc:(II)I
signature:null
ASM类输出路径:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-05/target/classes/AsmTestMonitor.class
方法:sum
参数:[6,2]
结果:8

通过测试内容可以看到,我们已将方法名称与参数信息打印完整。好!到这我们已经基本入门了 ASM 字节码编程的大门

十、总结

高级编程技术的内容还不止于此,不要只为了一时的功能实现,而放弃深挖深究的机会。也许就是你不断的增强拓展个人的知识技能,才让你越来越与众不同。
ASM 这种字节码编程的应用是非常广的,但可能确实平时看不到的,因为他都是与其他框架结合一起作为支撑服务使用。像这样的技术还有很多,比如 javaassit、netty等等。
对于真的要学习一样技术时,不要只看爽文,但爽文也确实给了你敲门砖。当你要彻底的掌握某个知识的时候,最重要的是成体系的学习!压榨自己的时间,做有意义的事,是3-7年开发人员最正确的事!
————————————————
版权声明:本文为CSDN博主「小傅哥」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/generalfu/article/details/105110600

如果你只写CRUD,那这种技术栈你永远碰不到相关推荐

  1. 大专java方向校招面试找工作知识点技术栈以及实习感受分享-简历分享

    大专java方向校招面试找工作知识点技术栈以及实习感受分享 专科生的Java学习以及校招面试 先说一下我的编程历程 开始求职之路 第一次求职 网上海投 第二次面试 第三次面试 学校招聘会 第四次面试 ...

  2. 阿里8年测试老鸟教你软件测试工程师简历,技术栈,项目经验怎么写

    [文章末尾给大家留下了大量的福利] 工作久了发现,在互联网这个圈子里,软件测试工程师和产品经理是两个"水分"较大的岗位,在招人的时候如果不擦亮眼睛,稍有不慎就可能会踩雷.前不久面试 ...

  3. 金九银十!阿里P8手写的内部Java核心开发成长手册,涵盖p5-p8技术栈,秋招必看!

    前言 程序员的年龄一直是大家讨论的热门话题,本人最近和不少小公司的程序员打交道.经过和他们的深入交流,我感受到了不少小公司程序员的现状,由此深深地感叹,可能真有不少小公司的程序员未必能干到30岁,甚至 ...

  4. java 实现中英文拼写检查和错误纠正?可我只会写 CRUD 啊!

    简单的需求 临近下班,小明忙完了今天的任务,正准备下班回家. 一条消息闪烁了起来. "最近发现公众号的拼写检查功能不错,帮助用户发现错别字,体验不错.给我们系统也做一个." 看着这 ...

  5. [推荐] 一个 Node.js 技术选型案例:使用 CARMEN 作为卡牌手游技术栈

    作者 @超人张宝胜 ,原文地址:https://zhuanlan.zhihu.com/p/103724412,如需转载请联系作者授权. 前言 本文介绍了在资金.人员.时间上全面告急.云服务提供商不确定 ...

  6. 学习笔记:SpringCloud 微服务技术栈_实用篇①_基础知识

    若文章内容或图片失效,请留言反馈.部分素材来自网络,若不小心影响到您的利益,请联系博主删除. 前言 学习视频链接 SpringCloud + RabbitMQ + Docker + Redis + 搜 ...

  7. 从零开始搭建创业公司后台技术栈!

    原文 : http://ju.outofmemory.cn/entry/351897 前言 说到后台技术栈,脑海中是不是浮现的是这样一幅图? 图 1 有点眼晕,以下只是我们会用到的一些语言的合集,而且 ...

  8. Redis详解和实战--云平台技术栈16

    导读:之前发布了云平台技术栈(ps:点击可查看),本文主要说一下其中的Redis! http://wdxtub.com/2016/07/05/redis-guide/ Redis 是 C 实现的基于内 ...

  9. Storm原理与实践--大数据技术栈14

    回顾:大数据平台技术栈 (ps:可点击查看),今天就来说说其中的Storm! 来自:有米加瓦 一.Storm简介 1. 引例 在介绍Storm之前,我们先看一个日志统计的例子:假如我们想要根据用户的访 ...

最新文章

  1. 二叉树的前序遍历,中序遍历,后序遍历学习 (原)
  2. 数据挖掘原理与算法:练习题1
  3. 20145234黄斐《信息安全系统设计基础》第九周学习总结(课本部分)
  4. java集合转字符串拼接_关于集合和字符串的互转实现方法
  5. Java虚拟机常量池和本地变量表、自己定义的数值自动装入常量池
  6. java 内存模型堆和本地方法
  7. DAG的深度优先搜索标记
  8. numpy细碎知识点
  9. 关于AIR 应用程序沙箱
  10. 【SRM-07 D】天才麻将少女KPM
  11. 极客大学架构师训练营 JVM虚拟机原理 JVM垃圾回收原理 Java编程优化 第17课 听课总结
  12. Linux如何下载安装软件超详细解析
  13. like语法 mysql_mysql中like语法拼接4种方式
  14. 创客教室-中小学创客教育课程介绍
  15. 对10G/40Gphy中auto-negotiation功能调试
  16. SEO人员:如何预估SEO投资回报率?
  17. OpenGL 纹理映射(贴图) 学习
  18. mysql触发器安全吗_猎八哥浅谈MYSQL触发器
  19. Mac右键增强工具Easy New File如何快速新建文件夹?
  20. 用友NC65 配置UAP时 打开sysconfig闪退

热门文章

  1. 为sap的alv的最左侧添加【选中】按钮用于同时选中多行...
  2. windows 下phpstudy 升级mysql版本5.7
  3. [实战]HM-Router configuration for TP ROOM
  4. CSS选择器分类与优先级
  5. 我用的 cordova 插件
  6. 创建一个提供数据 API 的 Node.js 网站
  7. Scrollbar中滚动条的设置
  8. ffmpeg API 笔记:使用libavcodec/libavformat/libswscale ffmpeg例子
  9. matlab 字符串替换
  10. nodejs中httpserver的安装和使用