【AICon】探索RAG 技术在实际应用中遇到的挑战及应对策略!AICon精华内容已上线73%>>> 了解详情
写点什么

通过使用 Byte Buddy,便捷地创建 Java Agent

  • 2016-02-21
  • 本文字数:8967 字

    阅读完需:约 29 分钟

Java agent 是在另外一个 Java 应用(“目标”应用)启动之前要执行的 Java 程序,这样 agent 就有机会修改目标应用或者应用所运行的环境。在本文中,我们将会从基础内容开始,逐渐增强其功能,借助字节码操作工具 Byte Buddy,使其成为高级的 agent 实现。

在最基本的用例中,Java agent 会用来设置应用属性或者配置特定的环境状态,agent 能够作为可重用和可插入的组件。如下的样例描述了这样的一个 agent,它设置了一个系统属性,在实际的程序中就可以使用该属性了:

复制代码
public class Agent {
public static void premain(String arg) {
System.setProperty("my-property", “foo”);
}
}

如上面的代码所述,Java agent 的定义与其他的 Java 程序类似,只不过它使用premain方法替代 main 方法作为入口点。顾名思义,这个方法能够在目标应用的 main 方法之前执行。相对于其他的 Java 程序,编写 agent 并没有特定的规则。有一个很小的区别在于,Java agent 接受一个可选的参数,而不是包含零个或更多参数的数组。

如果要使用这个 agent,必须要将 agent 类和资源打包到 jar 中,并且在 jar 的 manifest 中要将Agent-Class属性设置为包含premain方法的 agent 类。(agent 必须要打包到 jar 文件中,它不能通过拆解的格式进行指定。)接下来,我们需要启动应用程序,并且在命令行中通过 javaagent 参数来引用 jar 文件的位置:

java -javaagent:myAgent.jar -jar myProgram.jar我们还可以在位置路径上设置可选的 agent 参数。在下面的命令中会启动一个 Java 程序并且添加给定的 agent,将值 myOptions 作为参数提供给premain方法:

java -javaagent:myAgent.jar=myOptions -jar myProgram.jar通过重复使用javaagent命令,能够添加多个 agent。

但是,Java agent 的功能并不局限于修改应用程序环境的状态,Java agent 能够访问 Java instrumentation API,这样的话,agent 就能修改目标应用程序的代码。Java 虚拟机中这个鲜为人知的特性提供了一个强大的工具,有助于实现面向切面的编程。

如果要对 Java 程序进行这种修改,我们需要在 agent 的premain方法上添加类型为Instrumentation的第二个参数。Instrumentation 参数可以用来执行一系列的任务,比如确定对象以字节为单位的精确大小以及通过注册ClassFileTransformers实际修改类的实现。ClassFileTransformers注册之后,当类加载器(class loader)加载类的时候都会调用它。当它被调用时,在类文件所代表的类加载之前,类文件 transformer 有机会改变或完全替换这个类文件。按照这种方式,在类使用之前,我们能够增强或修改类的行为,如下面的样例所示:

复制代码
public class Agent {
public static void premain(String argument, Instrumentation inst) {
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform({1}
ClassLoader loader,
String className,
Class<?> classBeingRedefined, // 如果类之前没有加载的话,值为 null
ProtectionDomain protectionDomain,
byte[] classFileBuffer) {
// 返回改变后的类文件。
}
});
}
}

通过使用Instrumentation实例注册上述的ClassFileTransformer之后,每个类加载的时候,都会调用这个 transformer。为了实现这一点,transformer 会接受一个二进制和类加载器的引用,分别代表了类文件以及试图加载类的类加载器。

Java agent 也可以在 Java 应用的运行期注册,如果是在这种场景下,instrumentation API 允许重新定义已加载的类,这个特性被称之为“HotSwap”。不过,重新定义类仅限于替换方法体。在重新定义类的时候,不能新增或移除类成员,并且类型和签名也不能进行修改。当类第一次加载的时候,并没有这种限制,如果是在这样的场景下,那classBeingRedefined会被设置为 null。

Java 字节码与类文件格式

类文件代表了 Java 类编译之后的状态。类文件中会包含字节码,这些字节码代表了 Java 源码中最初的程序指令。Java 字节码可以视为 Java 虚拟机的语言。实际上,JVM 并不会将 Java 视为编程语言,它只能处理字节码。因为它采用二进制的表现形式,所以相对于程序的源码,它占用的空间更少。除此之外,将程序以字节码的形式进行表现能够更容易地编译 Java 以外的其他语言,如 Scala 或 Clojure,从而让它们运行在 JVM 上。如果没有字节码作为中间语言的话,那么其他的程序在运行之前,可能还需要将其转换为 Java 源码。

但是,在代码处理的时候,这种抽象却带来了一定的成本。如果要将ClassFileTransformer应用到某个类上,那我们不能将该类按照 Java 源码的形式进行处理,甚至不能假设被转换的代码最初是由 Java 编写而成的。更糟糕的是,探查类成员或注解的反射 API 也是禁止使用的,这是因为类加载之前,我们无法访问这些 API,而在转换进程完成之前,是无法进行加载的。

所幸的是,Java 字节码相对来讲是一个比较简单的抽象形式,它包含了很少量的操作,稍微花点功夫我们就能大致将其掌握起来。Java 虚拟机执行程序的时候,会以基于栈的方式来处理值。字节码指令一般会告知虚拟机,需要从操作数栈(operand stack)上弹出值,执行一些操作,然后再将结果压到栈中。

让我们考虑一个简单的样例:将数字 1 和 2 进行相加操作。JVM 首先会将这两个数字压到栈中,这是通过 _iconst_1_ 和 _iconst_2_ 这两个字节指令实现的。_iconst_1_ 是个单字节的便捷运算符(operator),它会将数字 1 压到栈中。与之类似,_iconst_2_ 会将数字 2 压到栈中。然后,会执行 _iadd_ 指令,它会将栈中最新的两个值弹出,将它们求和计算的结果重新压到栈中。在类文件中,每个指令并不是以其易于记忆的名称进行存储的,而是以一个字节的形式进行存储,这个字节能够唯一地标记特定的指令,这也是 _bytecode_ 这个术语的来历。上文所述的字节码指令及其对操作数栈的影响,通过下面的图片进行了可视化。

对于人类用户来讲,会更喜欢源码而不是字节码,不过幸运的是 Java 社区创建了多个库,能够解析类文件并将紧凑的字节码暴露为具有名称的指令流。例如,流行的 ASM 库提供了一个简单的 visitor API,它能够将类文件剖析为成员和方法指令,其操作方式类似于阅读 XML 文件时的 SAX 解析器。如果使用 ASM 的话,那上述样例中的字节码可以按照如下的代码来进行实现(在这里,ASM 方式的指令是visitIns,能够提供修正的方法实现):

复制代码
MethodVisitor methodVisitor = ...
methodVisitor.visitIns(Opcodes.ICONST_1);
methodVisitor.visitIns(Opcodes.ICONST_2);
methodVisitor.visitIns(Opcodes.IADD);

需要注意的是,字节码规范只不过是一种比喻的说法(metaphor),因为 Java 虚拟机允许将程序转换为优化后的机器码(machine code),只要程序的输出能够保证是正确的即可。因为字节码的简洁性,所以在已有的类中取代和修改指令是很简单直接的。因此,使用 ASM 及其底层的 Java 字节码基础就足以实现类转换的 Java agent,这需要注册一个ClassFileTransformer,它会使用这个库来处理其参数。

克服字节码的不足

对于实际的应用来讲,解析原始的类文件依然意味着有很多的手动工作。Java 程序员通常感兴趣的是类型层级结构中的类。例如,某个 Java agent 可能需要修改所有实现给定接口的类。如果要确定某个类的超类,那只靠解析ClassFileTransformer所给定的类文件就不够了,类文件中只包含了直接超类和接口的名字。为了解析可能的超类型关联关系,程序员依然需要定位这些类型的类文件。

在项目中直接使用 ASM 的另外一个困难在于,团队中需要有开发人员学习 Java 字节码的基础知识。在实践中,这往往会导致很多的开发人员不敢再去修改字节码操作相关的代码。如果这样的话,实现 Java agent 很容易为项目的长期维护带来风险。

为了克服这些问题,我们最好使用较高层级的抽象来实现 Java agent,而不是直接操作 Java 字节码。Byte Buddy 是开源的、基于 Apache 2.0 许可证的库,它致力于解决字节码操作和 instrumentation API 的复杂性。Byte Buddy 所声称的目标是将显式的字节码操作隐藏在一个类型安全的领域特定语言背后。通过使用 Byte Buddy,任何熟悉 Java 编程语言的人都有望非常容易地进行字节码操作。

Byte Buddy 简介

Byte Buddy 的目的并不仅仅是为了生成 Java agent。它提供了一个 API 用于生成任意的 Java 类,基于这个生成类的 API,Byte Buddy 提供了额外的 API 来生成 Java agent。

作为 Byte Buddy 的简介,如下的样例展现了如何生成一个简单的类,这个类是 Object 的子类,并且重写了 toString 方法,用来返回“Hello World!”。与原始的 ASM 类似,“intercept”会告诉 Byte Buddy 为拦截到的指令提供方法实现:

复制代码
Class<?> dynamicType = new ByteBuddy()
.subclass(Object.class)
.method(ElementMatchers.named("toString"))
.intercept(FixedValue.value("Hello World!"))
.make()
.load(getClass().getClassLoader(),
ClassLoadingStrategy.Default.WRAPPER)
.getLoaded();

从上面的代码中,我们可以看到 Byte Buddy 要实现一个方法分为两步。首先,编程人员需要指定一个ElementMatcher,它负责识别一个或多个需要实现的方法。Byte Buddy 提供了功能丰富的预定义拦截器(interceptor),它们暴露在ElementMatchers类中。在上述的例子中,toString方法完全精确匹配了名称,但是,我们也可以匹配更为复杂的代码结构,如类型或注解。

当 Byte Buddy 生成类的时候,它会分析所生成类型的类层级结构。在上述的例子中,Byte Buddy 能够确定所生成的类要继承其超类 Object 的名为 toString 的方法,指定的匹配器会要求 Byte Buddy 重写该方法,这是通过随后的<a href="http://bytebuddy.net/javadoc/0.7.1/net/bytebuddy/implementation/Implementation.html">Implementation</a>实例实现的,在我们的样例中,这个实例也就是<a href="http://bytebuddy.net/javadoc/0.7.1/net/bytebuddy/implementation/FixedValue.html">FixedValue</a>

当创建子类的时候,Byte Buddy 始终会拦截(intercept)一个匹配的方法,在生成的类中重写该方法。但是,我们在本文稍后将会看到 Byte Buddy 还能够重新定义已有的类,而不必通过子类的方式来实现。在这种情况下,Byte Buddy 会将已有的代码替换为生成的代码,而将原有的代码复制到另外一个合成的(synthetic)方法中。

在我们上面的代码样例中,匹配的方法进行了重写,在实现里面,返回了固定的值“Hello World!”。intercept方法接受 Implementation 类型的参数,Byte Buddy 自带了多个预先定义的实现,如上文所使用的FixedValue类。但是,如果需要的话,可以使用前文所述的 ASM API 将某个方法实现为自定义的字节码,Byte Buddy 本身也是基于 ASM API 实现的。

定义完类的属性之后,就能通过 make 方法来进行生成。在样例应用中,因为用户没有指定类名,所以生成的类会给定一个任意的名称。最终,生成的类将会使用ClassLoadingStrategy来进行加载。通过使用上述的默认WRAPPER策略,类将会使用一个新的类加载器进行加载,这个类加载器会使用环境类加载器作为父加载器。

类加载之后,使用 Java 反射 API 就可以访问它了。如果没有指定其他构造器的话,Byte Buddy 将会生成类似于父类的构造器,因此生成的类可以使用默认的构造器。这样,我们就可以检验生成的类重写了toString方法,如下面的代码所示:

复制代码
assertThat(dynamicType.newInstance().toString(),
is("Hello World!"));

当然,这个生成的类并没有太大的用处。对于实际的应用来讲,大多数方法的返回值是在运行时计算的,这个计算过程要依赖于方法的参数和对象的状态。

通过委托实现 Instrumentation

要实现某个方法,有一种更为灵活的方式,那就是使用 Byte Buddy 的 MethodDelegation。通过使用方法委托,在生成重写的实现时,我们就有可能调用给定类和实例的其他方法。按照这种方式,我们可以使用如下的委托器(delegator)重新编写上述的样例:

复制代码
class ToStringInterceptor {
static String intercept() {
return “Hello World!”;
}
}

借助上面的 POJO 拦截器,我们就可以将之前的 FixedValue 实现替换为 MethodDelegation.to(ToStringInterceptor.class):

复制代码
Class<?> dynamicType = new ByteBuddy()
.subclass(Object.class)
.method(ElementMatchers.named("toString"))
.intercept(MethodDelegation.to(ToStringInterceptor.class))
.make()
.load(getClass().getClassLoader(),
ClassLoadingStrategy.Default.WRAPPER)
.getLoaded();

使用上述的委托器,Byte Buddy 会在 to 方法所给定的拦截目标中,确定 _ 最优的调用方法 _。就ToStringInterceptor.class来讲,选择过程只是非常简单地解析这个类型的唯一静态方法而已。在本例中,只会考虑一个静态方法,因为委托的目标中指定的是一个 _ 类 _。与之不同的是,我们还可以将其委托给某个类的 _ 实例 _,如果是这样的话,Byte Buddy 将会考虑所有的虚方法(virtual method)。如果类或实例上有多个这样的方法,那么 Byte Buddy 首先会排除掉所有与指定 instrumentation 不兼容的方法。在剩余的方法中,库将会选择最佳的匹配者,通常来讲这会是参数最多的方法。我们还可以显式地指定目标方法,这需要缩小合法方法的范围,将ElementMatcher传递到MethodDelegation中,就会进行方法的过滤。例如,通过添加如下的filter,Byte Buddy 只会将名为“intercept”的方法视为委托目标:

复制代码
MethodDelegation.to(ToStringInterceptor.class)
.filter(ElementMatchers.named(“intercept”))

执行上面的拦截之后,被拦截到的方法依然会打印出“Hello World!”,但是这次的结果是动态计算的,这样的话,我们就可以在拦截器方法上设置断点,所生成的类每次调用toString时,都会触发拦截器的方法。

当我们为拦截器方法设置参数时,就能释放出MethodDelegation的全部威力。这里的参数通常是带有注解的,用来要求 Byte Buddy 在调用拦截器方法时,注入某个特定的值。例如,通过使用@Origin注解,Byte Buddy 提供了添加 instrument 功能的方法的实例,将其作为 Java 反射 API 中类的实例:

复制代码
class ContextualToStringInterceptor {
static String intercept(@Origin Method m) {
returnHello World from ” + m.getName() + “!”;
}
}

当拦截toString方法时,对 instrument 方法的调用将会返回“Hello world from toString!”。

除了@Origin注解以外,Byte Buddy 提供了一组功能丰富的注解。例如,通过在类型为 Callable的参数上使用@Super注解,Byte Buddy 会创建并注入一个代理实例,它能够调用被 instrument 方法的原始代码。如果对于特定的用户场景,所提供的注解不能满足需求或者不太适合的话,我们甚至能够注册自定义的注解,让这些注解注入用户特定的值。

实现方法级别的安全性

可以看到,我们在运行时可以借助简单的 Java 代码,使用 MethodDelegation 来动态重写某个方法。这只是一个简单的样例,但是这项技术可以用到更加实际的应用之中。在本文剩余的内容中,我们将会开发一个样例,它会使用代码生成技术实现一个注解驱动的库,用来限制方法级别的安全性。在我们的第一个迭代中,这个库会通过生成子类的方式来限制安全性。然后,我们将会采取相同的方式来实现 Java agent,完成相同的功能。

样例库会使用如下的注解,允许用户指定某个方法需要考虑安全因素:

复制代码
@interface Secured {
String user();
}

例如,假设应用需要使用如下的Service类来执行敏感操作,并且只有用户被认证为管理员才能执行该方法。这是通过为执行这个操作的方法声明 Secured 注解来指定的:

复制代码
class Service {
@Secured(user = “ADMIN”)
void doSensitiveAction() {
// 运行敏感代码...
}
}

我们当然可以将安全检查直接编写到方法中。在实际中,硬编码横切关注点往往会导致复制 - 粘贴的逻辑,使其难以维护。另外,一旦应用需要涉及额外的需求时,如日志、收集调用指标或结果缓存,直接添加这样的代码扩展性不会很好。通过将这样的功能抽取到 agent 中,方法就能很纯粹地关注其业务逻辑,使得代码库能够更易于阅读、测试和维护。

为了让我们规划的库保持尽可能得简单,按照注解的协议声明,如果当前用户不具备注解的用户属性时,将会抛出IllegalStateException异常。通过使用 Byte Buddy,这种行为可以用一个简单的拦截器来实现,如下面样例中的SecurityInterceptor所示,它会通过其静态的 user 域,跟踪当前用户已经进行了登录:

复制代码
class SecurityInterceptor {
static String user = “ANONYMOUS”
static void intercept(@Origin Method method) {
if (!method.getAnnotation(Secured.class).user().equals(user)) {
throw new IllegalStateException(“Wrong user”);
}
}
}

通过上面的代码,我们可以看到,即便给定用户授予了访问权限,拦截器也没有调用原始的方法。为了解决这个问题,Byte Buddy 有很多预定义的方法可以实现功能的链接。借助MethodDelegation类的andThen方法,上述的安全检查可以放到原始方法的调用之前,如下面的代码所示。如果用户没有进行认证的话,安全检查将会抛出异常并阻止后续的执行,因此原始方法将不会执行。

将这些功能集合在一起,我们就能生成Service的一个子类,所有带有注解方法的都能恰当地进行安全保护。因为所生成的类是 Service 的子类,所以它能够替代所有类型为Service的变量,并不需要任何的类型转换,如果没有恰当认证的话,调用doSensitiveAction方法就会抛出异常:

复制代码
new ByteBuddy()
.subclass(Service.class)
.method(ElementMatchers.isAnnotatedBy(Secured.class))
.intercept(MethodDelegation.to(SecurityInterceptor.class)
.andThen(SuperMethodCall.INSTANCE)))
.make()
.load(getClass().getClassLoader(),
ClassLoadingStrategy.Default.WRAPPER)
.getLoaded()
.newInstance()
.doSensitiveAction();

不过坏消息是,因为实现 instrumentation 功能的子类是在运行时创建的,所以除了使用 Java 反射以外,没有其他办法创建这样的实例。因此,所有 instrumentation 类的实例都应该通过一个工厂来创建,这个工厂会封装创建 instrumentation 子类的复杂性。这样造成的结果就是,子类 instrumentation 通常会用于框架之中,这些框架本身就需要通过工厂来创建实例,例如,像依赖管理的框架 Spring 或对象 - 关系映射的框架 Hibernate,而对于其他类型的应用来讲,子类 instrumentation 实现起来通常过于复杂。

实现安全功能的 Java agent

通过使用 Java agent,上述安全框架的一个替代实现将会修改Service类的原始字节码,而不是重写它。这样做的话,我们就没有必要创建托管的实例了,只需简单地调用

new Service().doSensitiveAction()即可,如果对应的用户没有进行认证的话,就会抛出异常。为了支持这种方式,Byte Buddy 提供一种称之为 _rebase 某个类 _ 的理念。当 rebase 某个类的时候,不会创建子类,所采用的策略是实现 instrumentation 功能的代码将会合并到被 instrument 的类中,从而改变其行为。在添加 instrumentation 功能之后,在被 instrument 的类中,其所有方法的原始代码均可进行访问,因此像SuperMethodCall这样的 instrumentation,工作方式与创建子类是完全一样的。

创建子类与 rebase 的行为是非常类似的,所以两种操作的 API 执行方式是一致的,都会使用相同的DynamicType.Builder接口来描述某个类型。两种形式的 instrumentation 都可以通过ByteBuddy类来进行访问。为了使 Java agent 的定义更加便利,Byte Buddy 还提供了AgentBuilder类,它希望能够以一种简洁的方式应对一些通用的用户场景。为了定义 Java agent 实现方法级别的安全性,将如下的类定义为 agent 的入口点就足以完成该功能了:

复制代码
class SecurityAgent {
public static void premain(String arg, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.any())
.transform((builder, type) -> builder
.method(ElementMatchers.isAnnotatedBy(Secured.class)
.intercept(MethodDelegation.to(SecurityInterceptor.class)
.andThen(SuperMethodCall.INSTANCE))))
.installOn(inst);
}
}

如果将这个 agent 打包为 jar 文件并在命令行中进行指定,那么所有带有Secured注解的方法将会进行“转换”或重定义,从而实现安全保护。如果不激活这个 Java agent 的话,应用在运行时就不包含额外的安全检查。当然,这意味着如果对带有注解的代码进行单元测试的话,这些方法的调用并不需要特殊的搭建过程来模拟安全上下文。Java 运行时会忽略掉无法在 classpath 中找到的注解类型,因此在运行带有注解的方法时,我们甚至完全可以在应用中移除掉安全库。

另外一项优势在于,Java agent 能够很容易地进行叠加。如果在命令行中指定多个 Java agent 的话,每个 agent 都有机会对类进行修改,其顺序就是在命令行中所指定的顺序。例如,我们可以采取这种方式将安全、日志以及监控框架联合在一起,而不需要在这些应用间增添任何形式的集成层。因此,使用 Java agent 实现横切的关注点提供了一种更为模块化的代码编写方式,而不必针对某个管理实例的中心框架来集成所有的代码。

_Byte Buddy 的源码可以免费地在 GitHub _上获取到。入门手册可以在 http://bytebuddy.net上找到。Byte Buddy 当前的可用版本是 0.7.4,所有样例均是基于该版本的。因为其革新性以及对 Java 生态系统的贡献,该库曾经在 2015 年获得过 Oracle 的 Duke’s Choice 奖项。

关于作者

Rafael Winterhalter是一位软件咨询师,在挪威的奥斯陆工作。他是静态类型的支持者,对 JVM 有极大的热情,尤其关注于代码 instrumentation、并发和函数式编程。Rafael 日常会撰写关于软件开发的博客,经常出席相关的会议,并被认定为 JavaOne Rock Star。在工作以外的编码过程中,他为多个开源项目做出过贡献,经常会花精力在 Byte Buddy 上,这是一个为 Java 虚拟机简化运行时代码生成的库。因为他的贡献,Rafael 得到过 Duke’s Choice 奖项。

查看英文原文: Easily Create Java Agents with Byte Buddy

2016-02-21 17:3715517

评论 4 条评论

发布
用户头像
我凑,牛逼
2022-03-15 18:52
回复
用户头像
2021-01-20 14:31
回复
用户头像
牛逼
2021-01-04 23:40
回复
用户头像
吊的不行
2020-09-01 21:58
回复
没有更多了
发现更多内容

如何做代币分析:以 LINK 币为例

Footprint Analytics

区块链 加密货币 代币 LINK

技术人2023年终总结,大模型对小城市程序猿的深远影响|社区征文

百里丶落云

AI #大模型

5分钟攻略Spring-Retry框架实现经典重试场景

快乐非自愿限量之名

前端 spring-boot #框架

云计算:现代技术的基本要素

这我可不懂

云计算

投资引路人:认知体系决定成败

少油少糖八分饱

投资 认知 能力圈 反人性 人性

inBuilder&openEuler,基于毕昇 JDK,提升大规模Java应用的启动性能

inBuilder低代码平台

低代码 openEuler Java.

喜报!酷克数据携手中移在线入选2023大数据“星河”数据库优秀案例

酷克数据HashData

低代码可视化工具10分钟完成应用开发

互联网工科生

软件开发 低代码平台 可视化开发 JNPF

分享5个程序员必备的终端工具

伤感汤姆布利柏

前端 终端 低代码 JNPF

AI工程化与低代码:加速人工智能应用开发的新趋势

EquatorCoco

人工智能 AI 低代码 人工智能技术

质量免费吗?

BY林子

软件质量 质量内建 缺陷预防

探索Web前端技术的新趋势与发展

不在线第一只蜗牛

Web 前端技术 互联网+

空投 | Mint Blockchain 将于 2024 年 1 月 10 号启动 Mint Genesis NFT 空投活动

NFT Research

blockchain NFT\ 空投

SiteSucker for mac(网站下载工具) v5.1.13完美激活版

mac

网站下载工具 苹果mac Windows软件 SiteSucker

生成式 AI,从陌生到使用,仅需两门课

科技热闻

HttpClient5升级笔记--API篇

FunTester

使用代码生成工具快速开发应用-结合后端Web API提供接口和前端页面快速生成,实现通用的业务编码规则管理

快乐非自愿限量之名

Vue 前端 Web 后端 代码生成

PVP2 ProVideoPlayer2 for Mac(PVP2多屏幕演示投放软件) v2.1.6永久激活版

mac

苹果mac Windows软件 ProVideoPlayer2 多功能演示工具

探索前端构建可视化应用的思路

这我可不懂

前端开发 低代码 JNPF

汇聚数据库创新力量 打造千行万业数据基石,openGauss Summit 2023即将召开

彭飞

使用 Amazon CodeCatalyst 中的生成式 AI 助手 Amazon Q 提高开发人员的工作效率(预览版)

亚马逊云科技 (Amazon Web Services)

API re:Invent 生成式人工智能 Amazon Q Preview

生成式AI:未来的发展方向是什么?

不在线第一只蜗牛

人工智能 生成式人工智能 技术 优化体系

PostgreSQL 可观测性最佳实践

观测云

数据库 postgresql

海外云手机版-怎么从零开始进行TikTok营销?

Ogcloud

跨境电商 TikTok 外贸

还不知道什么是生成式 AI?两门课带你从了解到使用

科技热闻

称重驱动二次开发教程

EquatorCoco

技术 开发 系统 电子称重系统

低代码开发平台:数字化转型的助推器

高端章鱼哥

软件开发 低代码 数字化

每日一题:LeetCode-662. 二叉树最大宽度

半亩房顶

面试 算法 LeetCode 二叉树 BFS

“双十一、二” 业务高峰如何扛住?韵达快递选择 TDengine

TDengine

tdengine 时序数据库 韵达

宁波银行:在「金融科技」引擎上,沉浸式提效减负

LigaAI

研发管理 IDEA LigaAI 研发协作 提效

村卫生室、诊所云HIS系统源码 支持医保功能

源码星辰

通过使用Byte Buddy,便捷地创建Java Agent_Java_Rafael Winterhalter_InfoQ精选文章