AST反混淆之路——babel基本知识及常用转换操作
本文章是学习AST反混淆的笔记,包括AST介绍、babel介绍(重点!!!)、以及部分AST反混淆实验代码
参照Babel插件开发助手(官方):https://blog.csdn.net/weixin_33826609/article/details/93164633#toc-visitors
介绍
AST
AST,抽象语法树(abstract syntax code,AST)是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,这所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。抽象语法树并不依赖于源语言的语法,也就是说语法分析阶段所采用的上下文无文文法,因为在写文法时,经常会对文法进行等价的转换(消除左递归,回溯,二义性等),这样会给文法分析引入一些多余的成分,对后续阶段造成不利影响,甚至会使合个阶段变得混乱。因些,很多编译器经常要独立地构造语法分析树,为前端,后端建立一个清晰的接口。
Babel
Babel 是一个通用的多功能的 JavaScript 编译器。此外它还拥有众多模块可用于不同形式的静态分析。
静态分析是在不需要执行代码的前提下对代码进行分析的处理过程 (执行代码的同时进行代码分析即是动态分析)。 静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。
Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。
关于AST和Babel的一些详情(比如Node节点的定义、各种节点的特殊属性等),请参照:https://github.com/yacan8/blog/blob/master/posts/JavaScript%E6%8A%BD%E8%B1%A1%E8%AF%AD%E6%B3%95%E6%A0%91AST.md。感谢大佬!
基本知识
Babel的处理步骤
Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。
解析
步骤接收代码并输出 AST。 这个步骤分为两个阶段:**词法分析(Lexical Analysis) **和 语法分析(Syntactic Analysis)。.
词法分析阶段把字符串形式的代码转换为 令牌(tokens) 流。.
你可以把令牌看作是一个扁平的语法片段数组
语法分析阶段会把一个令牌流转换成 AST 的形式。 这个阶段会使用令牌中的信息把它们转换成一个 AST 的表述结构,这样更易于后续的操作。
转换
步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。
在转换步骤中,需要进行递归的树形遍历(深度遍历)。
生成
代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。.代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。
PS:
可以通过:https://astexplorer.net/网站将待转换的js代码转换为AST抽象语法树。
Visitors 访问者
当我们谈及“进入”一个节点,实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个访问者模式(visitor)的概念。
访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 比如:
const Visitor = {Identifier() {console.log("Called!");}
};
// Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。.
// 也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}
以上为一个简单的访问者,用于遍历时,每遇到一个Identifier的时候都会调用Identifier()方法。
此种方法默认为在进入节点时进行操作,也可以在退出节点时进行操作:
const Visitor = {Identifier: {enter() {console.log("Entered!");},exit() {console.log("Exited!");}}
};
如果对不同节点有同样的操作,可以用‘|’将方法名分隔开:
const visitor = {"Idenfifier |MemberExpression"(path){}
}
特别的,还可以使用别名(https://github.com/babel/babel/tree/master/packages/babel-types/src/definitions)去定义:
//Function is an alias for FunctionDeclaration, FunctionExpression,
//ArrowFunctionExpression, ObjectMethod and ClassMethod.const Visitor = {Function(path) {}
}
Paths 路径
AST 通常会有许多节点,那么节点直接如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Paths(路径)来简化这件事情。.
Path 是表示两个节点之间连接的对象。
在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。
Paths in Visitors(存在于访问者中的路径)
当你有一个 Identifier()
成员方法的访问者时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(即路径)而非节点本身。
// 对表达式:a+b+c
const visitor = {Identifier(path){console.log("now: " + path.node.name);}
}// 使用path.traverse(visitor)进行转换
path.traverse(visitor);//以下是输出结果:
now: a
now: b
now: c
State(状态)
状态是抽象语法树AST转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。
const parser = require("@babel/parser"); // 解析,js转AST
const traverse = require("@babel/traverse").default; // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default; // 生成const fs = require('fs'); // 文件读写target_js = "function square(n) { return n * n;}"// 尝试将该代码中的n都变为x
const visitor = {FunctionDeclaration(path){const param = path.node.params[0]if (param.name === 'n'){paramName = param.name;param.name = 'x'}},Identifier(path){if (path.node.name === paramName){path.node.name = "x"}}
}let ast = parser.parse(target_js);traverse(ast, visitor);let {code} = generator(ast);// 输出:
function square(x) {return x * x;
}
以上代码可以做到将n变为x,但如果js代码变为:
function square(n){return n * n;
}function add(n, m){return n + m
}
输出结果就变成了:
function square(x) {return x * x;
}function add(x, m) {return x + m;
}
但我们本意只想变换square方法中的n。于是可以用递归的方式:
const parser = require("@babel/parser"); // 解析,js转AST
const traverse = require("@babel/traverse").default; // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default; // 生成const fs = require('fs'); // 文件读写var target_js = "function square(n){\n" +" return n * n;\n" +"}\n" +"\n" +"function add(n, m){\n" +" return n + m\n" +"}"// 尝试将该代码中的n都变为x
const updateParamName = {"Identifier"(path){if (path.node.name === this.paramName){path.node.name = "x"}}
}const visitor = {"FunctionDeclaration"(path){if (path.node.params.length > 1){return;}const param = path.node.params[0]if (param.name === 'n'){paramName = param.name;param.name = 'x'path.traverse(updateParamName, { paramName });}}
}let ast = parser.parse(target_js);traverse(ast, visitor);let {code} = generator(ast);console.log(code)//输出结果
function square(x) {return x * x;
}function add(n, m) {return n + m;
}
此处为特殊例子,为了演示如何从访问者中消除全局状态。
Scopes 作用域
JavaScript支持词法作用域,在树状嵌套结构中代码块创建出新的作用域。
在JavaScript中,每创建一个引用,不管是通过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)还是标签(label)等,都属于当前作用域。
更深的内部作用域代码可以使用外层作用域中的引用。
内层作用域也可以创建和外层作用域同名的引用。
当写转换时,必须小心作用域,必须要确保在改变代码的各个部分时不会破坏已经存在的代码。
在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突,或者仅仅想找出使用一个变量的所有引用,只想在给定的作用域中找出这些饮用。
作用域可以表示为:
{path: path,block: path.node,parentBlock: path.parent,parent: parentScope,bindings: [...]
}
创建一个新的作用域,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内手机所有的引用("绑定")。
一旦引用收集完毕,就可以在作用域上使用各种方法。
Bindings 绑定
所有引用属于特定的作用域,引用和作用域的这种关系被称为:绑定(binding)
单个绑定可以表示为:
Text for Translation
{identifier: node,scope: scope,path: path,kind: 'var',referenced: true,references: 3,referencePaths: [path, path, path],constant: false,constantViolations: [path]
}
有以上信息就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等),查找所属的作用域,或者拷贝标识符,甚至知道是不是常量,如果不是,那么哪里修改了它。
在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩。
API
Babel实际上是一组模块的集合。接下来是一些主要的模块,会解释他们是做什么的,以及如何使用。
babylon
Babylon是Babel的解析器。最初是从Acorn项目fork出来的。Acorn非常快,易于使用,并且针对非标准特性(以及未来的标准特性)设计了一个基于插件的架构。
babel-traverse
Babel Traverse模块维护了整棵树的状态,并且负责替换、移除和添加节点。
babel-types
Babel Types模块是一个用于AST节点的Lodash式工具库(JavaScript函数工具库,提供了基于函数式编程风格的众多工具函数),包含了构造、验证以及变化AST节点的方法。该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。
Definitions 定义
Babel Types模块拥有每一个单一类型节点的定义,包括节点包含那些属性,什么是合法值,如何构建节点、遍历节点,以及节点的别名等信息。
单一节点类型的定义形式如下:
defineType("BinaryExpression", {builder: ["operator", "left", "right"],fields: {operator: {validate: assertValueType("string")},left:{validate: assertNodeType("Expression")},right:{validate: assertNodeType("Expression")}},visitor: ["left", "right"],aliases: ["Binary", "Expression"]
});
Builders 构建器
上边的定义中有builder字段,这个字段的出现是因为每个节点类型都有构造器方法builder,使用方法:
type.binaryExpression("*", type.identifier("a"), type.identifiier("b"));
可以创建的AST:
{type: "BinaryExpression",operator: "*",left: {type: "Identifier",name: "a"},right: {type: "Identifier",name: "b"}
}
转为js代码后:
a * b
构造器还会验证自身创建的节点,并在错误使用的情况下抛出描述性错误。于是有了验证器。
Validators 验证器
BinaryExpression的定义还包含了节点的字段fields信息,以及如何验证这些字段。
fields: {operator: {validate: assertValueType("string")},left: {validate: assertNodeType("Expression")},right: {validate: assertNodeType("Expression")}}
可以创建两种验证方法。第一种是isX。
type.isBinaryExpression(maybeBinaryExpressionNode)
这个测试用来确保节点是一个二进制表达式,另外你也可以传入第二个参数来确保节点包含特定的属性和属性值。
type.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
还有一些断言式版本,会抛出异常而非true或false。
type.assertBinaryExpressiion(maybeBinaryExpressionNode);
type.assertBiinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }
Converters 变换器
babel-generator
是Babel的代码生成器,读取AST并将其转换为代码和源码映射。
const code = '......'const ast = babylon.parse(code);
generate(ast, {}, code);
// 结果
// {
// code: "...",
// map: "..."
// }// 也可以传递选项
generate(ast, {retainLines: false,compact: "auto",concise: false,quotes: "double",
}, code);
babel-template
是另一个虽然小但非常有用的模块,能使你编写字符串形式切带有占位符的代码来代替手动编码,尤其生成大规模AST时。在计算机科学中,这种能力被称为准引用(quasiquotes)。
import template from "babel-template";
import generate from "babel-generator";
import * as type from "babel-types";const buildRequire = template(' var IMPORT_NAME = require(SOURCE); ');const ast = buildRequire({IMPORT_NAME: type.identifier("myModule"),SOURCE: type.stringLiteral("my-module")
};console.log(generate(ast).code);// 结果:
var myModule = require("my-module");
编写第一个Babel插件
// 源代码:
target_js = 'foo === bar'// AST:
//{
// type: "BinaryExpression",
// operator: "===",
// left: {
// type: "Identifier",
// name: "foo"
// },
// right: {
// type: "Identifier",
// name: "bar"
// }
//}// 目标是将 === 运算符左右变量名替换
export default function({ types: t}) {return {visitor: {BinaryExpression(path){if (path.node.operator !== "==="){return;}path.node.left = t.identifier("sanshi");path.node.rigth = t.identifier("stone");}}}
}// 运行结果:
sanshi === stone
转换操作
访问
获取子节点的Path
为了得到一个AST节点的属性值,我们一般先访问到该节点,然后利用path.node.property方法即可。
// BinaryExpression AST node 的属性: 'left', 'right', 'operator'
BinaryExpression(path){path.node.left;path.node.right;path.node.operator;
}
访问该属性内部的path,使用path对象的get方法,传递该属性的字符串形式作为参数:
BinaryExpression(path){path.get('left');
}Program(path){path.get('body.0');
}
检查节点的类型
如果想检查节点的类型,最好的方式是:
BinaryExpression(path){if (t.isIdentifier(path.node.left)){// ...}
}
或者可以对节点的属性做浅层检查:
BinaryExpression(path){if (t.isIdentifier(path.node.left, { name: "n" })) {// ...}
}
功能上等价于:
BinaryExpression(path){if (path.node.left != null && path.node.left.type === "Identiifiier" &&path.node.left.name === "n"){// ...}
}
检查路径(Path)类型
BinaryExpression(path) {if (path.get('left').isIdentifier({ name: "n" })){// ...}
}// 等价于BinaryExpression(path) {if (t.isIdentifier(path.node.left, { name: "n" })) {// ...}
}
检查标识符(Identifier)是否被引用
Identifier(path) {if (path.isReferencedIdentifier()) {// ...}
}// 或者Identifier(path) {if (t.isReferenced(path.node, path.parent)) {// ...}
}
找到特定的父路径
有时需要从一个路径向上遍历语法树,直到满足相应的条件。
// 对于每一个父路径调用callback并将其NodePath当作参数,当callback返回真值时,则将其NodePath返回。
path.findParent((path) => path.isObjcetExpression());// 如果也需要遍历当前节点:
path.find((path) => path.isObjectExpression());// 查找最接近的父函数或程序:
path.getFunctionParent();// 向上遍历语法树,知道找到在列表中的父节点路径
path.getStatementParent();
获取同级路径
如果一个路径是在一个Function/Program中的列表里面,他就有同级节点。
- 使用path.inList来判断路径是否有同级节点
- 使用path.getSibling(index)来获取同级路径
- 使用path.key获取路径所在容器的索引
- 使用path.container获取路径的容器(包含所有同级节点的数组)
- 使用path.listKey获取容器的key
target_js = "var a = 1; // pathA, path.key = 0 var b = 2; // pathB, path.key = 1 var c = 3; // pathC, path.key = 2"export default function({ types: t }) {return {visitor: {VariableDeclaration(path) {// if the current path is pathApath.inList // truepath.listKey // "body"paht.key // 0path.getSibling(0) // pathApath.getSibling(path.key + 1) //pathBpath.container // [pathA, pathB, pathC]}}};
}
停止遍历
如果插件需要在某种情况下不运行,最简单的做法是尽早返回:
BinaryExpression(path){if (path.node.operator !== '**') return;
}
如果在顶级路径中进行子遍历,则可以使用2个提供的API方法:
path.skip() 会跳过当前路径之后的子节点遍历;path.stop() 完全停止遍历。
处理
替换一个节点
BinaryExpression(path) {path.replaceWith(t.binaryExpression("**", path.node.left, t.numberLiteral(2))// 将当前BinaryExpression替换为:binaryExpression节点,该节点值为"**", left为path.node.left, right为t.numberLiteral(2),即2。);
}
用多个节点替换单个节点
ReturnStatement(path) {path.replaceWithMultiple([t.expressionStatement(t.stringLiteral("Is this the real life?")),t.expressionStatement(t.stringLiteral("Is this just fantasy?")),t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in you head)")),]);
}
PS: 注意,当多个节点替换一个表达式时,他们必须是声明。因为Babel在更换节点时广泛使用启发式算法,这意味着您可以做一些疯狂的转换,否则将会非常冗长。
用字符串源码替换节点
FunctionDeclaration(path){path.replaceWithSourceString(function add(a, b) {return a + b;});
}// 结果:
- function square(n) {
- return n * n
+ function add(a, b) {
+ return a + b
PS:不建议使用这个API,除非正在处理动态的源码字符串,否则在访问者外部解析代码更有效率
插入兄弟节点
FunctionDeclaration(path) {path.insertBefore(t.expressionStatement(t.stringLiteral("Because I'm easy come, easy go.")));path.insertAfter(t.expressionStatement(t.stringLiteral("A little high, little low.")));
}// 结果
+ "Because I'm easy come, easy go.";function square(n) {return n * n;}
+ "A little high, little low.";
PS:这里同样应该使用声明或者一个声明数组。这个使用了在用多个节点替换一个节点中提到的相同的启发式算法。
插入到容器(container)中
如果要在AST节点属性中插入一个类似body那样的数组,其方法与insertBefore/insertAfter类似,但必须指定listKey。
ClassMethod(path) {path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
}// 结果
class A{
+ "before"var a = 'middle';
+ "after"
}
删除一个节点
FunctionDeclaration(path) {path.remove();
}//结果
- function square(n) {
- return n * n;
- }
替换父节点
只需要用parentPath: path.parentPath.replaceWith即可:
BinaryExpression(path) {path.parentPath.replaceWith(t.expressionStatement(t.stringLiteral("Anyway the wind blows, doesn't really matter to me, to me.")));
}// 结果
function square(n) {
- return n * n
+ "Anyway the wind blows, doesn't really matter to me, to me.";
}
删除父节点
BinaryExpression(path) {path.parentPath.remove();
}// 结果
function square(n) {
- return n * n
}
基于Scope(作用域)的操作。
检查本地变量是否被绑定
FunctionDeclaration(path) {if (path.scope.hasBinding("n")) {// ...}
}// 以上操作将遍历范围树,并检查特定的绑定FunctionDeclaration(path) {if (path.scope.hasOwnBinding("n")) {// ...}
}// 这步是检查一个作用域是否有自己的绑定。
创建一个UID
生成一个标识符,不会与任何本地定义的变量冲突:
FunctionDeclaration(path){path.scope.generateUidIdentifier("uid");// Node { type: "Identifier", name: "_uid" }path.scpoe.generateUidIdentifier("uid");// Node { type: "Identifier", name: "_uid2" }
}
提升变量声明至父级作用域
FunctionDeclaration(path) {const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);path.remove();path.scope.parent.push({ id, init: path.node });
}// 结果
- function square(n) {
+ var _square = function square(n) {return n * n
- }
+ }
重命名绑定及其引用
FunctionDeclaration(path) {path.scope.rename("n", "x");
}// 结果
- function square(n) {
- return n * n;
+ function square(x) {
+ return x * x;
}// 或者将绑定重命名为生成的唯一标识符
FunctionDeclaration(path) {path.scope.rename("n");
}// 结果
- function square(n) {
- return n * n;
+ function square(_n) {
+ return _n * _n;
}
插件选项
如果想自定义Babel插件的行为,可以指定的插件特定选项:
{plugins: [["my-plugin", {"option1": true,"option2": false}]]
}
这些选项会通过state对象传递给插件访问者:
export default function({ types: t}) {return {visitor: {FunctionDeclaration(path, state){console.log(state.opts);//输出:// { option1: true, option2: false}}}}
}
这些选项特定于插件,不能访问其他插件中的选项
插件的准备和收尾工作
插件可以具有在插件之前或之后运行的函数。可以用于设置或清理/分析:
export default function({ types: t }) {return {pre(state) {this.cache = new Map();},visitor: {StringLiteral(path) {this.cache.set(path.node.value, 1);}},post(state) {console.log(this.cache);}}
}
在插件中启用其他语法
插件可以启用babylon plugins,以便用户不需要安装/启用他们。这可以防止解析错误,而不会继承语法插件。
export default function({ types: t }) {return {inherits: require("babel-plugin-syntax-jsx")};
}
AST反混淆之路——babel基本知识及常用转换操作相关推荐
- AST反混淆实战-高级难度
Ast实战:反混淆解析高级难度ob混淆网站 https://obfuscator.io/ 一.混淆demo生成 二.混淆demo说明 步骤相同-不在冗余 详情参考:AST反混淆实战-中等难度 http ...
- AST反混淆实战-经典ob混淆
Ast实战:反混淆解析经典ob混淆 一.混淆demo获取 ob混淆源码 来自猿人学14题 https://match.yuanrenxue.com/api/match/14/m demo.js //为 ...
- AST反混淆实战-中等难度
Ast实战:反混淆解析中等难度ob混淆网站 https://obfuscator.io/ 一.混淆demo生成 二.混淆demo生成 三.混淆demo整理 demo.js //TODO 这里对混淆de ...
- AST反混淆实战:猿人学爬虫比赛第二题详细题解
缘起 应星友要求,写下此文,哎,有钱能使鬼推磨. 实战地址: http://match.yuanrenxue.com/match/2 抓包分析 由于谷歌浏览器某些请求不会显示,建议使用火狐浏览器来抓包 ...
- AST反混淆插件|如何还原Array对象里的元素
关注它,不迷路. 本文章中所有内容仅供学习交流,不可用于任何商业用途和非法用途,否则后果自负,如有侵权,请联系作者立即删除! 1. 需求分析 曾经在某大型网站的参数核心加密代码中全都是类似下面的代码: ...
- java基础知识---IO常用基础操作(二)
九. 缓冲流 9.1 概述 缓冲流,也叫高效流,是对4个基本的FileXxx 流的增强,所以也是4个流,按照数据类型分类: 字节缓冲流:BufferedInputStream,BufferedOutp ...
- java基础知识---IO常用基础操作(一)
一. File类 1.1 概述 java.io.File 类是文件和目录路径名的抽象表示,主要用于文件和目录的创建.查找和删除等操作. 1.2 构造方法 public File(String path ...
- 公基计算机快捷键,专业知识—计算机常用快捷键操作大全
Ctrl+V 粘贴 Ctrl+W 关闭当前窗口,IE7 中关闭当前选项卡(tab) Ctrl+X 剪切 Ctrl+Y 重复上一次操作 Ctrl+Z 撤销上一次操作 Ctrl+数字 IE7 中切换至与数 ...
- JS逆向、破解、反混淆、反浏览器指纹——JS补环境框架
JS逆向的主要思路一般有这几种 1,利用AST反混淆,因为用的就是AST混淆的,所以理论上应该都能用AST再返回去.但是实际操作好像不容易. 2,跟值,一步一步找到加密方法和密钥.现在很多混淆方法,把 ...
最新文章
- How to open robotium-solo-1.4.0 javadoc.jar to get the information of robotium.
- 在 Spring中 创建 JavaBean
- 关于Configuration.ConfigurationManager
- 【bzoj1212】[HNOI2004]L语言 AC自动机
- proxifier代理失败原因_上海财务代理
- linux下面安装maven
- 【Pytorch神经网络实战案例】20 基于Cora数据集实现图卷积神经网络论文分类
- c# asp.net RangeValidator(范围验证)控件(11)
- 光耦驱动单向可控硅_单向可控硅最筒单电路图大全
- 蚂蚁金服服务器系统,蚂蚁金服轻量级监控分析系统 SOFALookout 服务端开源
- 华为杯数学建模竞赛百分百获奖经验分享(获奖 == 四分经验,三分运气,三分实力)
- 中原证券同花顺个股期权全真模拟交易客户端
- 局域网联机_红警如何局域网联机?详细联机教程,方法特别简单
- 开源的api文档管理系统
- office word doc中无法输入英文双引号
- 看代码,学strings包
- 更好的 java 重试框架 sisyphus 的 3 种使用方式
- 基于BP神经网络的英文字母识别
- 深夜街头被偷拍的扎心瞬间:成年人的体面,都是易碎品
- css grid布局中的minmax()函数的使用
热门文章
- 帝都机器人便利店_飘了?帝都这几家网红便利店,竟藏着“米其林”早餐!
- python c++情侣网名含义,Python class 与c++ 之类的区别
- JavaBeans简介
- 揭秘阿里云IoT安全平台Link Security如何实现物联网产品全生命周期管理
- ARM内核 和 linux内核
- 当程序员一天天老去.哪些人晚景凄凉
- QRowTable表格控件(二)-红涨绿跌
- Android 事件传递机制总结
- SMSLIB+RXTX 短信猫开发模块
- 小赵老师课堂开课了 !天道酬勤,相信自己学到就是赚到,一起来学习吧--- java面向对象程序设计基础的知识!!!!