一起玩转Android项目中的字节码(下)
上篇;https://blog.csdn.net/feiyu1947/article/details/84931252
如何验证行号
上面我们给每一句方法调用的前后都插入了一行日志打印,那么有没有想过,这样岂不是打乱了代码的行数,这样,万一crash了,定位堆栈岂不是乱套了。其实并不然,在上面visitMethodInsn中做的东西,其实都是在同一行中插入的代码,上面我们贴出来的代码是这样
private static void printTwo() { System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); }
无论你用idea还是eclipse打开上面的class文件,都是一行行展示的,但是其实class内部真实的行数应该是这样
private static void printTwo() { System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne"); }
idea下可以开启一个选项,让查看class内容时,保留真正的行数
开启后,你看到的是这样
我们可以发现,17行和18行,分别包含了三句代码。
而开启选项之前是这样
那么如何开启这个选项呢?Mac下cmd + shift + A
输入Registry,勾选这两个选项
其实无论字节码和ASM的代码上看,class中的所有代码,都是先声明行号X,然后开始几条字节码指令,这几条字节码对应的代码都在行号X中,直到声明下一个新的行号。
ASM code
解析来介绍,如何写出上面生成代码的逻辑。首先,我们设想一下,如果要对某个class进行修改,那需要对字节码具体做什么修改呢?最直观的方法就是,先编译生成目标class,然后看它的字节码和原来class的字节码有什么区别(查看字节码可以使用javap工具),但是这样还不够,其实我们最终并不是读写字节码,而是使用ASM来修改,我们这里先做一个区别,bytecode vs ASM code,前者就是JVM意义的字节码,而后者是用ASM描述的bytecode,其实二者非常的接近,只是ASM code用Java代码来描述。所以,我们应该是对比ASM code,而不是对比bytecode。对比ASM code的diff,基本就是我们要做的修改。
而ASM也提供了一个这样的类:ASMifier,它可以生成ASM code,但是,其实还有更快捷的工具,Intellij IDEA有一个插件
Asm Bytecode Outline,可以查看一个class文件的bytecode和ASM code。
到此为止,貌似使用对比ASM code的方式,来实现字节码修改也不难,但是,这种方式只是可以实现一些修改字节码的基础场景,还有很多场景是需要对字节码有一些基础知识才能做到,而且,要阅读懂ASM code,也是需要一定字节码的的知识。所以,如果要开发字节码工程,还是需要学习一番字节码。
ClassWriter在Android上的坑
如果我们直接按上面的套路,将ASM应用到Android编译插件中,会踩到一个坑,这个坑来自于ClassWriter,具体是因为ClassWriter其中的一个逻辑,寻找两个类的共同父类。可以看看ClassWriter中的这个方法getCommonSuperClass,
/** * Returns the common super type of the two given types. The default * implementation of this method <i>loads</i> the two given classes and uses * the java.lang.Class methods to find the common super class. It can be * overridden to compute this common super type in other ways, in particular * without actually loading any class, or to take into account the class * that is currently being generated by this ClassWriter, which can of * course not be loaded since it is under construction. * * @param type1 * the internal name of a class. * @param type2 * the internal name of another class. * @return the internal name of the common super class of the two given * classes. */ protected String getCommonSuperClass(final String type1, final String type2) { Class<?> c, d; ClassLoader classLoader = getClass().getClassLoader(); try { c = Class.forName(type1.replace('/', '.'), false, classLoader); d = Class.forName(type2.replace('/', '.'), false, classLoader); } catch (Exception e) { throw new RuntimeException(e.toString()); } if (c.isAssignableFrom(d)) { return type1; } if (d.isAssignableFrom(c)) { return type2; } if (c.isInterface() || d.isInterface()) { return "java/lang/Object"; } else { do { c = c.getSuperclass(); } while (!c.isAssignableFrom(d)); return c.getName().replace('.', '/'); } }
这个方法用于寻找两个类的共同父类,我们可以看到它是获取当前class的classLoader加载两个输入的类型,而编译期间使用的classloader并没有加载Android项目中的代码,所以我们需要一个自定义的ClassLoader,将前面提到的Transform中接收到的所有jar以及class,还有android.jar都添加到自定义ClassLoader中。(其实上面这个方法注释中已经暗示了这个方法存在的一些问题)
如下
public static URLClassLoader getClassLoader(Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, Project project) throws MalformedURLException { ImmutableList.Builder<URL> urls = new ImmutableList.Builder<>(); String androidJarPath = getAndroidJarPath(project); File file = new File(androidJarPath); URL androidJarURL = file.toURI().toURL(); urls.add(androidJarURL); for (TransformInput totalInputs : Iterables.concat(inputs, referencedInputs)) { for (DirectoryInput directoryInput : totalInputs.getDirectoryInputs()) { if (directoryInput.getFile().isDirectory()) { urls.add(directoryInput.getFile().toURI().toURL()); } } for (JarInput jarInput : totalInputs.getJarInputs()) { if (jarInput.getFile().isFile()) { urls.add(jarInput.getFile().toURI().toURL()); } } } ImmutableList<URL> allUrls = urls.build(); URL[] classLoaderUrls = allUrls.toArray(new URL[allUrls.size()]); return new URLClassLoader(classLoaderUrls); }
但是,如果只是替换了getCommonSuperClass中的Classloader,依然还有一个更深的坑,我们可以看看前面getCommonSuperClass的实现,它是如何寻找父类的呢?它是通过Class.forName加载某个类,然后再去寻找父类,但是,但是,android.jar中的类可不能随随便便加载的呀,android.jar对于Android工程来说只是编译时依赖,运行时是用Android机器上自己的android.jar。而且android.jar所有方法包括构造函数都是空实现,其中都只有一行代码
throw new RuntimeException("Stub!");
这样加载某个类时,它的静态域就会被触发,而如果有一个static的变量刚好在声明时被初始化,而初始化中只有一个RuntimeException,此时就会抛异常。
所以,我们不能通过这种方式来获取父类,能否通过不需要加载class就能获取它的父类的方式呢?谜底就在眼前,父类其实也是一个class的字节码中的一项数据,那么我们就从字节码中查询父类即可。最终实现是这样。
public class ExtendClassWriter extends ClassWriter { public static final String TAG = "ExtendClassWriter"; private static final String OBJECT = "java/lang/Object"; private ClassLoader urlClassLoader; public ExtendClassWriter(ClassLoader urlClassLoader, int flags) { super(flags); this.urlClassLoader = urlClassLoader; } @Override protected String getCommonSuperClass(final String type1, final String type2) { if (type1 == null || type1.equals(OBJECT) || type2 == null || type2.equals(OBJECT)) { return OBJECT; } if (type1.equals(type2)) { return type1; } ClassReader type1ClassReader = getClassReader(type1); ClassReader type2ClassReader = getClassReader(type2); if (type1ClassReader == null || type2ClassReader == null) { return OBJECT; } if (isInterface(type1ClassReader)) { String interfaceName = type1; if (isImplements(interfaceName, type2ClassReader)) { return interfaceName; } if (isInterface(type2ClassReader)) { interfaceName = type2; if (isImplements(interfaceName, type1ClassReader)) { return interfaceName; } } return OBJECT; } if (isInterface(type2ClassReader)) { String interfaceName = type2; if (isImplements(interfaceName, type1ClassReader)) { return interfaceName; } return OBJECT; } final Set<String> superClassNames = new HashSet<String>(); superClassNames.add(type1); superClassNames.add(type2); String type1SuperClassName = type1ClassReader.getSuperName(); if (!superClassNames.add(type1SuperClassName)) { return type1SuperClassName; } String type2SuperClassName = type2ClassReader.getSuperName(); if (!superClassNames.add(type2SuperClassName)) { return type2SuperClassName; } while (type1SuperClassName != null || type2SuperClassName != null) { if (type1SuperClassName != null) { type1SuperClassName = getSuperClassName(type1SuperClassName); if (type1SuperClassName != null) { if (!superClassNames.add(type1SuperClassName)) { return type1SuperClassName; } } } if (type2SuperClassName != null) { type2SuperClassName = getSuperClassName(type2SuperClassName); if (type2SuperClassName != null) { if (!superClassNames.add(type2SuperClassName)) { return type2SuperClassName; } } } } return OBJECT; } private boolean isImplements(final String interfaceName, final ClassReader classReader) { ClassReader classInfo = classReader; while (classInfo != null) { final String[] interfaceNames = classInfo.getInterfaces(); for (String name : interfaceNames) { if (name != null && name.equals(interfaceName)) { return true; } } for (String name : interfaceNames) { if(name != null) { final ClassReader interfaceInfo = getClassReader(name); if (interfaceInfo != null) { if (isImplements(interfaceName, interfaceInfo)) { return true; } } } } final String superClassName = classInfo.getSuperName(); if (superClassName == null || superClassName.equals(OBJECT)) { break; } classInfo = getClassReader(superClassName); } return false; } private boolean isInterface(final ClassReader classReader) { return (classReader.getAccess() & Opcodes.ACC_INTERFACE) != 0; } private String getSuperClassName(final String className) { final ClassReader classReader = getClassReader(className); if (classReader == null) { return null; } return classReader.getSuperName(); } private ClassReader getClassReader(final String className) { InputStream inputStream = urlClassLoader.getResourceAsStream(className + ".class"); try { if (inputStream != null) { return new ClassReader(inputStream); } } catch (IOException ignored) { } finally { if (inputStream != null) { try { inputStream.close(); } catch (IOException ignored) { } } } return null; } }
到此为止,我们介绍了在Android上实现修改字节码的两个基础技术Transform+ASM,介绍了其原理和应用,分析了性能优化以及在Android平台上的适配等。在此基础上,我抽象出一个轮子,让开发者写字节码插件时,只需要写少量的ASM code即可,而不需关心Transform和ASM背后的很多细节。详见
万事俱备,只欠写一个插件来玩玩了,让我们来看看几个应用案例。
应用案例
先抛结论,修改字节码其实也有套路,一种是hack代码调用,一种是hack代码实现。
比如修改Android Framework(android.jar)的实现,你是没办法在编译期间达到这个目的的,因为最终Android Framework的class在Android设备上。所以这种情况下你需要从hack代码调用入手,比如Log.i(TAG, “hello”),你不可能hack其中的实现,但是你可以把它hack成HackLog.i(TAG, “seeyou”)。
而如果是要修改第三方依赖或者工程中写的代码,则可以直接hack代码实现,但是,当如果你要插入的字节码比较多时,也可以通过一定技巧减少写ASM code的量,你可以将大部分可以抽象的逻辑抽象到某个写好的class中,然后ASM code只需写调用这个写好的class的语句。
当然上面只是目前按照我的经验做的一点总结,还是有一些更复杂的情况要具体情况具体分析,比如在实现类似JakeWharton的hugo的功能时,在代码开头获取方法参数名时我就遇到棘手的问题(用了一种二次扫描的方式解决了这个问题,可以移步项目主页参考具体实现)。
我们这里挑选OkHttp-Plugin的实现进行分析、演示如何使用Huntet框架开发一个字节码编译插件。
使用OkHttp的人知道,OkHttp里每一个OkHttp都可以设置自己独立的Intercepter/Dns/EventListener(EventListener是okhttp3.11新增),但是需要对全局所有OkHttp设置统一的Intercepter/Dns/EventListener就很麻烦,需要一处处设置,而且一些第三方依赖中的OkHttp很大可能无法设置。曾经在官方repo提过这个问题的issue,没有得到很好的回复,作者之一觉得如果是他,他会用依赖注入的方式来实现统一的Okhttp配置,但是这种方式只能说可行但是不理想,后台在reddit发 帖子安利自己Hunter这个轮子时,JakeWharton大佬竟然亲自回答了,虽然面对大佬,不过还是要正面刚!争论一波之后,总结一下他的立场,大概如下
他觉得我说的好像这是okhttp的锅,然而这其实是okhttp的一个feature,他觉得全局状态是一种不好的编码,所以在设计okhttp没有提供全局Intercepter/Dns/EventListener的接口。而第三方依赖库不能设置自定义Intercepter/Dns/EventListener这是它们的锅。
但是,他的观点我不完全同意,虽然全局状态确实是一种不好的设计,但是,如果要做性能监控之类的功能,这就很难避免或多或少的全局侵入。(不过我确实措辞不当,说得这好像是Okhttp的锅一样)
言归正传,来看看我们要怎么来对OkHttp动刀,请看以下代码
public Builder(){ this.dispatcher = new Dispatcher(); this.protocols = OkHttpClient.DEFAULT_PROTOCOLS; this.connectionSpecs = OkHttpClient.DEFAULT_CONNECTION_SPECS; this.eventListenerFactory = EventListener.factory(EventListener.NONE); this.proxySelector = ProxySelector.getDefault(); this.cookieJar = CookieJar.NO_COOKIES; this.socketFactory = SocketFactory.getDefault(); this.hostnameVerifier = OkHostnameVerifier.INSTANCE; this.certificatePinner = CertificatePinner.DEFAULT; this.proxyAuthenticator = Authenticator.NONE; this.authenticator = Authenticator.NONE; this.connectionPool = new ConnectionPool(); this.dns = Dns.SYSTEM; this.followSslRedirects = true; this.followRedirects = true; this.retryOnConnectionFailure = true; this.connectTimeout = 10000; this.readTimeout = 10000; this.writeTimeout = 10000; this.pingInterval = 0; this.eventListenerFactory = OkHttpHooker.globalEventFactory; this.dns = OkHttpHooker.globalDns; this.interceptors.addAll(OkHttpHooker.globalInterceptors); this.networkInterceptors.addAll(OkHttpHooker.globalNetworkInterceptors); }
这是OkhttpClient中内部类Builder的构造函数,我们的目标是在方法末尾加上四行代码,这样一来,所有的OkHttpClient都会拥有共同的Intercepter/Dns/EventListener。我们再来看看OkHttpHooker的实现
public class OkHttpHooker { public static EventListener.Factory globalEventFactory = new EventListener.Factory() { public EventListener create(Call call) { return EventListener.NONE; } };; public static Dns globalDns = Dns.SYSTEM; public static List<Interceptor> globalInterceptors = new ArrayList<>(); public static List<Interceptor> globalNetworkInterceptors = new ArrayList<>(); public static void installEventListenerFactory(EventListener.Factory factory) { globalEventFactory = factory; } public static void installDns(Dns dns) { globalDns = dns; } public static void installInterceptor(Interceptor interceptor) { if(interceptor != null) globalInterceptors.add(interceptor); } public static void installNetworkInterceptors(Interceptor networkInterceptor) { if(networkInterceptor != null) globalNetworkInterceptors.add(networkInterceptor); } }
这样,只需要为OkHttpHooker预先install好几个全局的Intercepter/Dns/EventListener即可。
那么,如何来实现上面OkhttpClient内部Builder中插入四行代码呢?
首先,我们通过Hunter的框架,可以隐藏掉Transform和ASM绝大部分细节,我们只需把注意力放在写ClassVisitor以及MethodVisitor即可。我们一共需要做以下几步
1、新建一个自定义transform,添加到一个自定义gradle plugin中
2、继承HunterTransform实现自定义transform
3、实现自定义的ClassVisitor,并依情况实现自定义MethodVisitor
其中第一步文章讲解transform一部分有讲到,基本是一样简短的写法,我们从第二步讲起
继承HunterTransform,就可以让你的transform具备并发、增量的功能。
final class OkHttpHunterTransform extends HunterTransform { private Project project; private OkHttpHunterExtension okHttpHunterExtension; public OkHttpHunterTransform(Project project) { super(project); this.project = project; //依情况而定,看看你需不需要有插件扩展 project.getExtensions().create("okHttpHunterExt", OkHttpHunterExtension.class); //必须的一步,继承BaseWeaver,帮你隐藏ASM细节 this.bytecodeWeaver = new OkHttpWeaver(); } @Override public void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { okHttpHunterExtension = (OkHttpHunterExtension) project.getExtensions().getByName("okHttpHunterExt"); super.transform(context, inputs, referencedInputs, outputProvider, isIncremental); } // 用于控制修改字节码在哪些debug包还是release包下发挥作用,或者完全打开/关闭 @Override protected RunVariant getRunVariant() { return okHttpHunterExtension.runVariant; } } //BaseWeaver帮你隐藏了ASM的很多复杂逻辑 public final class OkHttpWeaver extends BaseWeaver { @Override protected ClassVisitor wrapClassWriter(ClassWriter classWriter) { return new OkHttpClassAdapter(classWriter); } } //插件扩展 public class OkHttpHunterExtension { public RunVariant runVariant = RunVariant.ALWAYS; @Override public String toString() { return "OkHttpHunterExtension{" + "runVariant=" + runVariant + '}'; } }
好了,Transform写起来就变得这么简单,接下来看自定义ClassVisitor,它在OkHttpWeaver返回。
我们新建一个ClassVisitor(自定义ClassVisitor是为了代理ClassWriter,前面讲过)
public final class OkHttpClassAdapter extends ClassVisitor{ private String className; OkHttpClassAdapter(final ClassVisitor cv) { super(Opcodes.ASM5, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.className = name; } @Override public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); if(className.equals("okhttp3/OkHttpClient$Builder")) { return mv == null ? null : new OkHttpMethodAdapter(className + File.separator + name, access, desc, mv); } else { return mv; } } }
我们寻找出okhttp3/OkHttpClient$Builder
这个类,其他类不管它,那么其他类只会被普通的复制,而okhttp3/OkHttpClient$Builder
将会有自定义的MethodVisitor来处理
我们来看看这个MethodVisitor的实现
public final class OkHttpMethodAdapter extends LocalVariablesSorter implements Opcodes { private boolean defaultOkhttpClientBuilderInitMethod = false; OkHttpMethodAdapter(String name, int access, String desc, MethodVisitor mv) { super(Opcodes.ASM5, access, desc, mv); if ("okhttp3/OkHttpClient$Builder/<init>".equals(name) && "()V".equals(desc)) { defaultOkhttpClientBuilderInitMethod = true; } } @Override public void visitInsn(int opcode) { if(defaultOkhttpClientBuilderInitMethod) { if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) { //EventListenFactory mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalEventFactory", "Lokhttp3/EventListener$Factory;"); mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "eventListenerFactory", "Lokhttp3/EventListener$Factory;"); //Dns mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalDns", "Lokhttp3/Dns;"); mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "dns", "Lokhttp3/Dns;"); //Interceptor mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "interceptors", "Ljava/util/List;"); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalInterceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); //NetworkInterceptor mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "networkInterceptors", "Ljava/util/List;"); mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalNetworkInterceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); } } super.visitInsn(opcode); } }
首先,我们先找出okhttp3/OkHttpClient$Builder
的构造函数,然后在这个构造函数的末尾,执行插入字节码的逻辑,我们可以发现,字节码的指令是符合逆波兰式的,都是操作数在前,操作符在后。
至此,我们只需要发布插件,然后apply到我们的项目中即可。
借助Hunter框架,我们很轻松就成功hack了Okhttp,我们就可以用全局统一的Intercepter/Dns/EventListener来监控我们APP的网络了。
讲到这里,就完整得介绍了如何使用Hunter框架开发一个字节码编译插件,对第三方依赖库为所欲为。如果对于代码还有疑惑,可以移步项目主页,参考完整代码,以及其他几个插件的实现。有时间再写文章介绍其他几个插件的具体实现。
总结
这篇文章写到这里差不多了,全文主要围绕Hunter展开介绍,分析了如何开发一个高效的修改字节码的编译插件,以及ASM字节码技术的一些相关工作流和开发套路。
也欢迎大家前往Hunter项目主页,欢迎使用Hunter框架开发插件,以及使用现有的几个插件,也欢迎提issue,欢迎star/fork。
现在加Android开发群;701740775,可免费领取一份最新Android高级架构技术体系大纲和视频资料,以及五年积累整理的所有面试资源笔记。加群请备注csdn领取xx资料
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
一起玩转Android项目中的字节码(上)
作为Android开发,日常写Java代码之余,是否想过,玩玩class文件?直接对class文件的字节码下手,我们可以做很多好玩的事情,比如: 对全局所有class插桩,做UI,内存,网络等等方面的性能监控 发现某个第三方依赖,用起来不爽,但是不想拿它的源码修改再重新编译,而想对它的class直接做点手脚 每次写打log时,想让TAG自动生成,让它默认就是当前类的名称,甚至你想让log里自动加上当前代码所在的行数,更方便定位日志位置 Java自带的动态代理太弱了,只能对接口类做动态代理,而我们想对任何类做动态代理 为了实现上面这些想法,可能我们最开始的第一反应,都是能否通过代码生成技术、APT,抑或反射、抑或动态代理来实现,但是想来想去,貌似这些方案都不能很好满足上面的需求,而且,有些问题不能从Java文件入手,而应该从class文件寻找突破。而从class文件入手,我们就不得不来近距离接触一下字节码! JVM平台上,修改、生成字节码无处不在,从ORM框架(如Hibernate, MyBatis)到Mock框架(如Mockio),再到Java Web中的常青树Spring框架,再到新...
- 下一篇
Activity 从启动到布局绘制的简单分析
这篇文章主要是配合源码简单的介绍一下,程序的加载过程,Activity 中布局的加载过程,能够大体的了解整个过程。不过过度的追究细节,因为里面任何一个细节可能都够你研究一段时间的!先了解掌握大体过程,再慢慢来! 文章最早发布于我的微信公众号 Android开发者家园 中,欢迎大家扫描下面二维码关注微信公众获取更多知识内容。 本文为sydMobile原创文章,可以随意转载,但请务必注明出处! 开始启动 我们都知道,Activity 是有生命周期的,onCreate()、onStart() 、onResume 等等那么这些方法是如何调用的呢?它不会平白无故的自己调用。其实当我们开启 APP 的时候会创建一个叫做 ActivityThread 的类,我们可以认为这个类是主类,就和 Java 程序中的启动类一样,ActivityThread 类有一个 main 函数,这个函数就是我们的程序入口! 相信看到这个,大家都会恍然大悟了,这就是我们学习 Java 的时候的 main 方法,认为是程序的入口。可以看到方法里面对一个 Looper 对象进行了初始化,Looper.prepareMainLo...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- 设置Eclipse缩进为4个空格,增强代码规范
- CentOS6,CentOS7官方镜像安装Oracle11G
- SpringBoot2整合Thymeleaf,官方推荐html解决方案
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- SpringBoot2全家桶,快速入门学习开发网站教程
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- CentOS7,CentOS8安装Elasticsearch6.8.6
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- MySQL8.0.19开启GTID主从同步CentOS8