20230711 10. 安全
安全
当 Java 技术刚刚问世时,令人激动的并不是因为它是一种设计完美的编程语言,而是因为它能够安全地运行通过因特网传播的各种 applet 。这就意味着, Java 与其他的语言和系统有所不同,在那些语言和系统中安全是在事后才想到要去实现的,或者是对破坏的一种应对措施,而对 Java 来说,安全机制是一个不可分割的组成部分
Java 安全架构包括以下三个部分:
- 语言设计特性(对数组的边界进行检查,无不受检查的类型转换,无指针算法等)
- 访问控制机制,用于控制代码能够执行的操作(比如文件访问,网络访问等)
- 代码签名,利用该特性,代码的作者就能够用标准的加密算法来认证 Java 代码。这样,该代码的使用者就能够准确地知道谁创建了该代码,以及代码被标识后是否被修改过
第一部分很成功。但是其他两个部分并不那么成功。安全管理器很复杂,而且要受攻击的面很广,在 Java 17 中已经废弃了。代码签名架构也随着 applet 和 Java Web start 这两种用于安全传输客户端应用程序的机制的消亡而遭弃用。
类加载器
Java 编译器会将 源指令 转换为虚拟机上的代码。虚拟机代码 存储在以 .class
为扩展名的类文件中,每个类文件都包含某个类或者接口的定义和实现代码。这些类文件必须由一个程序进行解释,该程序能够将虚拟机的指令集翻译成目标机器的机器语言。
类加载过程
虚拟机只加载程序执行时所需要的类文件
假设程序从 MyProgram.class
开始运行,下面是虚拟机执行的步骤:
- 虚拟机有一个用于加载类文件的机制,例如,从磁盘上读取文件或者请求 Web 上的文件;它使用该机制来加载 MyProgram 类文件中的内容
- 如果 MyProgram 类拥有类型为另一个类的域,或者是拥有超类,那么这些类文件也会被加载 (加载某个类所依赖的所有类的过程称为类的 解析 )
- 接着,虚拟机执行时
MyProgram
中的main
方法(它是静态的,无需创建类的实例) - 如果
main
方法或者main
调用的方法要用到更多的类,那么接下来就会加载这些类
类加载机制并非只使用单个的类加载器。每个 Java 程序至少拥有三个类加载器:
- 引导类加载器
- 平台类加载器
- 系统类加载器(有时也称为应用类加载器)
引导类加载器负责加载包含在下列模块以及大量的 JDK 内部模块中的平台类:
- java.base
- java.datatransfer
- java.desktop
- java.instrument
- java.logging
- java.management
- java.management.rmi
- java.naming
- java.prefs
- java.rmi
- java.security.sasl
- java.xml
引导类加载器没有对应的 ClassLoader
对象
ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader); // null
在 Java 9 之前,Java 平台类位于 rt.jar 中。如今,Java 平合是模块化的,每个平合模块都包含一个 JMOD 文件。平台类加载器会加载引导类加载器没有加载的 Java 平台中的所有类。
系统类加载器会从模块路径和类路径中加载应用类。
注释:在 Java 9之前,“扩展类加载器”会加载 jre/lib/ext
目录中的“标准扩展”。而“授权标准覆盖”机制提供了一种方式,可以用更新的版本覆盖某些平台类(包括
CORBA 和 XML 的实现)。这两种机制都被移除了。
类加载器的层次结构
类加载器有一种父/子关系。除了引导类加载器外,每个类加载器都有一个父类加载器。根据规定,类加载器会为它的父类加载器提供一个机会,以便加载任何给定的类,并且只有在其父类加载器加载失败时,它才会加载该给定类。例如,当要求系统类加载器加载一个系统类(比如, java.lang.StringBuilder
)时,它首先要求平台类加载器进行加载,该平台类加载器则首先要求引导类加载器进行加载。引导类加载器会找到并加载这个类,而无须其他两个类加载器做更多的搜索
某些程序具有插件架构,其中代码的某些部分是作为可选的插件打包的。如果插件被打包为 JAR 文件,那就可以直接用 URLClassLoader
类的实例去加载这些类
URL url = new URL("file:///path/to/plugin.jar");
URLClassLoader pluginLoader = new URLClassLoader(new URL[]{url});
Class<?> cl = pluginLoader.loadClass("mypackage.MyClass");
因为在 URLClassloader
构造器中没有指定父类加载器,因此 pluginLoader
的父亲就是系统类加载器
在 Java 9 之前,系统类加载器是 URLClassloader
类的实例。有些程序员会使用强制转型来访问其 getURLs 方法,或者通过反射机制调用受保护的 addURLs 方法将 JAR 文件添加到类路径中。现在无法这样操作了。
大多数时候,你不必操心类加载的层次结构。通常,类是由于其他的类需要它而被加载的,而这个过程对你是透明的
偶尔,你也会需要干涉和指定类加载器。考虑下面的例子:
- 你的应用的代码包含一个助手方法,它要调用
Class.forName(classNameString)
- 这个方法是从一个插件类中被调用的
classNameString
指定的正是一个包含在这个插件的 JAR 中的类
插件的作者会很合理地期望这个类应该被加载。但是,助手方法的类是由系统类加载器加载的,这正是 Class.forName
所使用的类加载器。而对于它来说,在插件 JAR 中的类是不可视的,这种现象称为 类加载器倒置
要解决这个问题,助手方法需要使用恰当的类加载器,它可以要求类加载器作为其一个参数传递给它。或者,它可以要求将恰当的类加载器设置成为当前线程的上下文类加载器,这种策略在许多框架中都得到了应用(例如 JAXP 和 JNDI 框架)
每个线程都有一个对类加载器的引用,称为上下文类加载器。主线程的上下文类加载器是系统类加载器。当新线程创建时,它的上下文类加载器会被设置成为创建该线程的上下文类加载器。因此,如果你不做任何特殊的操作,那么所有线程就都会将它们的上下文类加载器设置为系统类加载器
通过下面的调用将线程的上下文类加载器设置成为任何类加载器:
Thread t = Thread.currentThread();
t.setContextClassLoader(loader);
然后助手方法可以获取这个上下文类加载器:
Thread t = Thread.currentThread();
ClassLoader loader = t.getContextClassLoader();
Class cl = loader.loadClass(className);
当上下文类加载器设置为插件类加载器时,问题依旧存在。应用设计者必须作出决策:通常,当调用由不同的类加载器加载的插件类的方法时,进行上下文类加载器的设置是一种好的思路;或者,让助手方法的调用者设置上下文类加载器
提示:如果你编写了一个按名字来加载类的方法,那么让调用者在传递显式的类加载器和使用上下文类加载器之间进行选择就是一种好的做法。不要直接使用该方法所属的类的类加载器
将类加载器作为命名空间
在同一个虚拟机中,可以有两个类,它们的类名和包名都是相同的。类是由它的全名和类加载器来确定的。这项技术在加载来自多处的代码时很有用。例如,应用服务器会为每一个应用使用单独的类加载器,这使虚拟机可以区分来自不同应用的类,而无论它们是怎么命名的
编写你自己的类加载器
我们可以编写自己的用于特殊目的的类加载器,这使得我们可以在向虚拟机传递字节码之前执行定制的检查
如果要编写自己的类加载器,只需要继承 ClassLoader
类,然后覆盖下面这个方法
protected Class<?> findClass(String name)
ClassLoader
超类的 loadClass
方法用于将类的加载操作委托给其父类加载器去进行,只有当该类尚未加载并且父类加载器也无法加载该类时,才调用 findClass
方法。
如果要实现该方法,必须做到以下几点:
- 为来自本地文件系统或者其他来源的类加载其字节码
- 调用
ClassLoader
超类的defineClass
方法,向虚拟机提供字节码
/**
* This class loader loads encrypted class files.
*/
class CryptoClassLoader extends ClassLoader {
private int key;
/**
* Constructs a crypto class loader.
*
* @param k the decryption key
*/
public CryptoClassLoader(int k) {
key = k;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] classBytes = null;
classBytes = loadClassBytes(name);
Class<?> cl = defineClass(name, classBytes, 0, classBytes.length);
if (cl == null) {
throw new ClassNotFoundException(name);
}
return cl;
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
/**
* Loads and decrypt the class file bytes.
*
* @param name the class name
* @return an array with the class file bytes
*/
private byte[] loadClassBytes(String name) throws IOException {
String cname = name.replace('.', '/') + ".caesar";
byte[] bytes = Files.readAllBytes(Paths.get(cname));
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (bytes[i] - key);
}
return bytes;
}
}
java.lang.Class<T> 方法名称 |
方法声明 | 描述 |
---|---|---|
getClassLoader |
public ClassLoader getClassLoader() |
获取加载该类的类加载器 |
java.lang.ClassLoader 方法名称 |
方法声明 | 描述 |
---|---|---|
getParent |
public final ClassLoader getParent() |
返回父类加载器,如果父类加载器是引导类加载器,则返回 null |
getSystemClassLoader |
public static ClassLoader getSystemClassLoader() |
获取系统类加载器,即用于加载第一个应用类的类加载器 |
findClass |
protected Class<?> findClass(String name) throws ClassNotFoundException |
类加载器应该覆盖该方法,以查找类的字节码,并通过调用 defineClass 方法将字节码传给虚拟机。在类的名字中,使用 . 作为包名分隔符,并且不使用 .class 后缀 |
defineClass |
protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError |
将一个新的类添加到虚拟机中,其字节码在给定的数据范围中 |
java.net.URLClassLoader 方法名称 |
方法声明 | 描述 |
---|---|---|
构造器 | public URLClassLoader(URL[] urls) public URLClassLoader(URL[] urls, ClassLoader parent) |
构建一个类加载器,它可以从给定的 URL 处加载类。如果 URL 以 / 结尾,那么它表示的一个目录,否则,它表示的是一个 JAR 文件 |
java.lang.Thread 方法名称 |
方法声明 | 描述 |
---|---|---|
getContextClassLoader |
public ClassLoader getContextClassLoader() |
获取类加载器,该线程的创建者将其指定为执行该线程时最适合使用的类加载器 |
setContextClassLoader |
public void setContextClassLoader(ClassLoader cl) |
为该线程中的代码设置一个类加载器,以获取要加载的类。如果在启动一个线程时没有显式地设置上下文类加载器,则使用父线程的上下文类加载器 |
字节码校验
当类加载器将新加载的 Java 平台类的字节码传递给虚拟机时,这些字节码首先要接受 校验器( verifier )的校验。校验器负责检查那些指令无法执行的明显有破坏性的操作。除了系统类外,所有的类都要被校验
下面是校验器执行的一些检查:
- 变量要在使用之前进行初始化
- 方法调用与对象引用类型之间要匹配
- 访问私有数据和方法的规则没有被违反
- 对本地变量的访问都落在运行时栈内
- 运行时栈没有溢出
如果以上这些检查中任何一条没有通过, 那么该类就被认为遭到了破坏,并且不予加载
如果校验器接受了一个程序,那么该程序就确实是安全的。然而,也有许多程序尽管是安全的,但却被校验器拒绝了 (在强制用哑元值来初始化一个变量时,你就会碰到这个问题,因为编译器无法了解这个变量是否可以被正确地初始化)
这种严格的校验是出于安全上的考虑,有一些偶然性的错误
为什么要有一个专门的校验器来检查这些特性呢?实际上,用 Java 语言编译器生成的类文件总是可以通过校验。然而,类文件中使用的字节码格式是有很好的文档记录的,对于具有汇编程序设计经验并且拥有十六进制编辑器的人来说,要手工地创建一个对 Java 虚拟机来说,由合法的但是不安全的指令构成的类文件,是一件非常容易的事情。再次提醒你,要记住,校验器总是在防范被故意篡改的类文件,而不仅仅只是检查编译器产生的类文件
用户认证
Java API 提供了一个名为 Java 认证和授权服务(JAAS) 的框架,它提供了对平台提供的和客户定制的认证机制的访问管理
JAAS 框架
Java 认证和授权服务(JAAS, Java Authentication and Authorization Service )包含两部分:“认证” ( Authentication
)部分主要负责确定程序使用者的身份,而 “授权” ( Authorization
)将各个用户映射到相应的权限。
授权部分和已经被弃用的安全管理器紧密相关,不需要再理会
JAAS 是一个可插拔的 API ,可以将 Java 应用程序与实现认证的特定技术分离开来。除此之外, JAAS 还支持 UNIX 登录、 Windows 登录、 Kerberos 认证和基于证书的认证
一旦用户通过认证,就可以为其附加一组权限
System.setSecurityManager(new SecurityManager());
try {
LoginContext context = new LoginContext("Login1");
context.login();
System.out.println("Authentication successful.");
Subject subject = context.getSubject();
System.out.println("subject=" + subject);
// ...
context.logout();
} catch (LoginException e) {
e.printStackTrace();
}
这里, subject
是指已经被认证的个体
主体(subject) 拥有多个特征(principal)
JAAS 登录模块
略
数字签名
java.security
包中包含了许多用于确保数据和电子签名完整性的算法实现。
消息摘要
消息摘要( message digest )是数据块的数字指纹。例如,所谓的 SHA1(安全散列算法制)可将任何数据块,无论其数据有多长,都压缩为 160 位( 20 字节)的序列。与真实的指纹一样,人们希望任何两条消息都不会有相同的 SHA1 指纹。当然,这是不可能的,因为只存在 SHA1 指纹,所以肯定会有某些消息具有相同的指纹。但是重复的可能性极小。
消息摘要具有两个基本属性:
- 如果数据的 1 位或者几位改变了,那么消息摘要也将改变
- 拥有给定消息的伪造者不能创建与原消息具有相同摘要的假消息
人们已经设计出大量的算法,用于计算这些消息摘要,其中最著名的两种算法是 SHA1 和 MD5 。SHA1 是由美国国家标准和技术学会开发的加密散列算法, MD5 是由麻省理工学院的 Ronald Rivest 发明的算法。这两种算法都使用了独特巧妙的方法对消息中的各个位进行
扰乱。但是,人们在这两种算法中发现了某些微妙的规律性,因此美国国家标准和技术学会建议切换到更强的加密算法上,Java 支持 SHA-2 和 SHA-3 算法集
MessageDigest
类是用于创建封装了指纹算法的对象的 “工厂”,它的静态方法 getInstance
返回继承了 MessageDigest
类的某个类的对象。这意味着 MessageDigest
类能够承担下面的双重职责:
- 作为一个工厂类
- 作为所有消息摘要算法的超类
// 获取一个能够计算 SHA 指纹的对象的方法
// 如果要获取计算 MD5 的对象,请使用字符串 "MD5" 作为 getInstance 的参数
MessageDigest alg = MessageDigest.getInstance("SHA-1");
// 在获取 MessageDigest 对象之后,可以通过反复调用 update 方法,将信息中的所有字节提供给该对象
InputStream in = ... ;
int ch;
while ((ch = in.read()) != -1) {
alg.update((byte) ch);
}
如果这些字节存放在一个数组中,那就可以一次完成整个数组的更新:
byte[] bytes = ... ;
alg.update (bytes);
当完成上述操作后,调用 digest
方法。该方法按照指纹算法的要求补齐输入,并且进行相应的计算,然后以字节数组的形式返回消息摘要
byte[] hash = alg.digest();
程序计算了一个消息摘要,既可以用 SHA ,也可以使用 MD5 来计算:
// 在线加密网站:http://www.metools.info/code/c26.html
String path = "D:\\Develop\\workspace\\study\\study-corejava\\src\\main\\java\\v2ch09\\hash\\input.txt";
String algname = "MD5"; // MD5 、 SHA-1
MessageDigest alg = MessageDigest.getInstance(algname);
byte[] input = Files.readAllBytes(Paths.get(path));
byte[] hash = alg.digest(input);
String d = "";
for (int i = 0; i < hash.length; i++) {
int v = hash[i] & 0xFF;
if (v < 16) {
d += "0";
}
d += Integer.toString(v, 16).toUpperCase() + " "; // 这里加了空格
}
System.out.println(d);
java.security.MessageDigest 方法名称 |
方法声明 | 描述 |
---|---|---|
getInstance |
public static MessageDigest getInstance(String algorithm) throws NoSuchAlgorithmException |
返回实现指定算法的 MessageDigest 对象。如果没有提供该算法,则抛出 NoSuchAlgorithmException 异常 |
update |
public void update(byte input) public void update(byte[] input) public void update(byte[] input, int offset, int len) |
使用指定的字节来更新摘要 |
digest |
public byte[] digest() |
完成散列计算,返回计算所得的摘要,并复位算法对象 |
reset |
public void reset() |
重置摘要 |
消息签名
公共密钥加密技术是基于 公共密钥 和 私有密钥 这两个基本概念的。它的设计思想是你可以将公共密钥告诉世界上的任何人,但是,只有自己才持有私有密钥,重要的是你要保护你的私有密钥,不将它泄漏给其他任何人
数字签名算法( DSA )
在现实中,几乎不可能用一个密钥去推算出另一个密钥
大多数密码学者认为,拥有 2000 位或者更多位“模数”的密钥目前是完全安全的,可以抵御任何攻击。DSA 被认为具有类似的安全性
假设 Alice 想要给 Bob 发送一个消息, Bob 想知道该消息是否来自 Alice ,而不是冒名顶替者。 Alice 写好了消息,并且用她的私有密钥对该消息摘要 签名 。Bob 得到了她的公共密钥的拷贝,然后 Bob 用公共密钥对该签名进行 校验 。如果通过了校验,则 Bob 可以确认以下两个事实:
- 原始消息没有被篡改过
- 该消息是由 Alice 签名的,她是私有密钥的持有者,该私有密钥就是与 Bob 用于校验的公共密钥相匹配的密钥
校验签名
JDK 配有一个 keytool
程序,该程序是一个命令行工具,用于生成和管理一组证书
警告:绝对不要将你并不完全信任的证书导入到密钥库中 一旦证书添加到密钥库中,使用密钥库的任何程序都会认为这些证书可以用来对签名进行校验
jarsigner
工具负责对 JAR 文件进行签名和校验
认证问题
假设你从一个声称代表某著名软件公司的陌生人那里获得了一个消息,他要求你运行消息附带的程序。这个陌生人甚至将他的公共密钥的拷贝发送给你,以便让你校验他是否是该消息的作者。你检查后会发现该签名是有效的,这就证明该消息是用匹配的私有密钥签名的,并且没有遭到破坏
此时你要小心:你仍然不清楚谁写的这条消息。任何人都可以生成一对公共密钥和私有密钥,再用私有密钥对消息进行签名,然后把签名好的消息和公共密钥发送给你。这种确定发送者身份的问题称为 “认证问题”
解决这个认证问题的通常做法是比较简单的。假设陌生人和你有一个你们俩都值得信赖的 共同熟人 。假设陌生人亲自约见了该熟人,将包含公共密钥的磁盘交给了他。后来,你的熟人与你见面,向你担保他与该陌生人见了面,并且该陌生人确实在那家著名的软件公司工作,然后将磁盘交给你。这样一来,你的熟人就证明了陌生人身份的真实性。
事实上,你的熟人并不需要与你见面 取而代之的是,他可以将他的私有签名应用于陌生人的公共密钥文件之上即可。当你拿到公共密钥文件之后,就可以检验你的熟人的签名是否真实,由于你信任他,因此你确信他在添加他的签名之前,确实核实了陌生人的身份
然而,你们之间可能没有共同的熟人。有些信任模型假设你们之间总是存在一个 “信任链” —— 即一个共同熟人的链路 —— 这样你就可以信任该链中的每个成员。当然,实际情况并不总是这样。你可能信任你的熟人 Alice ,而且你知道 Alice 信任 Bob ,但是你不了解 Bob ,因此你没有把握究竟是不是该信任他。其他的信任模型则假设有一个我们大家都信任的慈善大佬,在扮演这个角色的公司中,最有名的是 VeriSign 公司
然而,对于实际被认证的对象,你应该抱有一个符合实际的期望:在认证公共密钥时,VeriSign 公司的 CEO 也不会亲自去会见每个人或者公司代表。直接在 Web 页面上填一份表格,并支付少量的费用,就可以获得一个“第一类( class 1 )” ID ,包含在证书中的密钥将被发送到指定的邮件地址。因此,你有理由相信该电子邮件是真实的,但是密钥申请人也可能填入任意名字和机构。还有其他对身份信息的检验更加严格的 ID 类别。例如,如果是“第三类( class 3 )” ID , VeriSign 将要求密钥申请人必须进行身份公证,公证机构将要核实企业申请者的财务信用资质。其他认证机构将采用不同的认证程序。因此,当你收到一条经过认证的消息时,重要的是你应该明白它实际上认证了什么
证书签名
证书授权( CA )
假设 Alice 想要给同事 Cindy 发送一条经过签名的消息,但是 Cindy 并不希望因为要校验许多签名指纹而受到困扰。因此,假设有一个 Cindy 信任的实体来校验这些签名。在这个例子中, Cindy 信任 ACME 软件公司的信息资源部
这个部门负责证书授权( CA )的运作。ACME 的每个人在其密钥库中都有 CA 的公共密钥,这是由一个专门负责详细核查密钥指纹的系统管理员安装的。CA 对 ACME 雇员的密钥进行签名,当他们在安装彼此的密钥时,密钥库将隐含地信任这些密钥,因为它们是由可信任的密钥签名的
密钥库要进行校验,以确定该密钥是由密钥库中已有的受信任的根密钥签过名的。Cindy 就不必对证书的指纹进行校验了
一旦 Cindy 添加了根证书和经常给她发送文档的人的证书后,她就再也不用担心密钥库了。
证书请求
略
代码签名
略
加密
到现在为止,我们已经介绍了一种在 Java 安全 API 中实现的重要密码技术, 即通过数字签名进行的认证。安全性的第二个重要方面是加密,当信息通过认证之后,该信息本身是直白可见的。数字签名只不过负责检验信息有没有被篡改过。相比之下,信息被加密后,是不可见的,只能用匹配的密钥进行解密
对称密码
Java 密码扩展 包含了一个 Cipher
类,该类是所有加密算法的超类。通过调用下面的 getInstance
方法可以获得一个密码对象:
Cipher cipher = Cipher.getInstance(algorithName);
Cipher cipher = Cipher.getInstance(algorithName, providerName);
JDK 中是由名为 “ SunJCE ” 的提供商提供密码的,如果没有指定其他提供商,则会默认为该提供商。如果要使用特定的算法,而对该算法 Oracle 公司没有提供支持,那么也可以指定其他的提供商
算法名称是一个字符串,比如 “ AES ” 或者 “ DES / CBC / PKCS5Padding ”
DES ,即数据加密标准,是一个密钥长度为 56 位的古老的分组密码。DES 加密算法在现在看来已经是过时了,因为可以用穷举法将它破译,更好的选择是采用它的后续版本,即 高级加密标准(AES)
密钥生成
为了加密,我们需要生成密钥。每个密码都有不同的用于密钥的格式,我们需要确保密钥的生成是随机的。这需要遵循下面的步骤:
- 为加密算法获取
KeyGenerator
- 用随机源来初始化密钥发生器。如果密码块的长度是可变的,还需要指定期望的密码块长度
- 调用
generateKey
方法
如何生成 AES 密钥:
KeyGenerator keygen = KeyGenerator.getInstance("AES");
SecureRandom random = new SecureRandom();
keygen.init(random);
Key key = keygen.generateKey();
或者,可以从一组固定的原生数据(也许是由口令或者随机击键产生的)中生成一个密钥,这时可以使用如下的 SecretKeyFactory
byte[] keyData = ...; // 16 bytes far AES
SecretKey key = new SecretKeySpec(keyData, "AES");
如果要生成密钥,必须使用“真正的随机”数。在 Random
类中的常规的随机数发生器,是根据当前的日期和时间来产生随机数的,因此它不够随机。SecureRandom
类产生的随机数,远比由 Random
类产生的那些数字安全得多
密码流
CipherInputStream/CipherOutputStream
公共密钥密码
对称密码的致命缺点在于密码的分发。如果 Alice 给 Bob 发送了一个加密的方法,那么 Bob 需要使用与 Alice 相同的密钥。如果 Alice 修改了密钥,那么她必须在给 Bob 发送消息的同时,通过安全信道发送新的密钥。
公共密钥密码技术解决了这个问题。
最常见的公共密钥算法是 RSA 算法
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)
2020-01-13 20200113 SpringBoot整合MyBatis