Java实现远程Shell脚本调用指南
SSH(Secure Shell)是一种网络协议,用于在不安全的网络环境中为计算机之间提供安全的加密通信。它允许用户通过一个安全的通道访问远程计算机的命令行界面,进行文件传输,以及其他网络服务。SSH协议的工作原理通常包括以下几个阶段:版本协商:SSH客户端和服务器首先会交换协议版本信息,确保双方支持的版本是一致的。密钥交换:使用Diffie-Hellman密钥交换算法来生成一个共享的秘密值,这个
简介:本文详细讲解了Java如何调用远程Shell脚本,这是自动化运维任务中的常见需求。文章深入探讨了使用Java执行远程Shell命令的方法,重点介绍了SSH协议和Ganymed SSH-2库的使用。我们将通过Java代码示例来展示如何创建SSH连接、执行远程命令、处理命令输出以及如何灵活配置连接参数。这些内容对于希望在Java项目中实现远程服务器管理功能的开发者来说非常有价值。 
1. Java远程Shell脚本调用基础
在信息技术领域,尤其是云计算和自动化运维的背景下,能够远程执行Shell脚本的需求变得越来越普遍。 Java远程Shell脚本调用基础 这一章将为读者介绍如何在Java程序中实现远程Shell命令的执行,并且获取执行结果。本章的目标是为后续深入讨论SSH协议及其实现打下坚实的基础。
1.1 远程Shell调用的意义与场景
远程Shell脚本调用允许开发者从一台计算机上通过网络远程控制另一台计算机执行命令,这在现代IT环境中是不可或缺的。比如,开发者可能需要在服务器集群上批量更新软件,或者运维人员需要远程诊断问题。这种技术的应用场景广泛,包括但不限于服务器管理、自动化部署、监控和数据收集等。
1.2 Java中远程Shell脚本调用的实现
在Java中实现远程Shell脚本调用通常需要借助于SSH协议,它提供了一种安全的方式在不安全的网络中进行通信。Java标准库并没有直接支持SSH,但是有第三方库如JSch、Apache MINA SSHD等,可以实现该功能。在接下来的章节中,我们将具体探讨如何使用这些库以及它们的具体配置方法。
随着IT技术的不断发展,远程Shell脚本调用的场景和实现方式也在不断地演化。在下一章节中,我们将深入了解SSH协议,这是实现远程Shell脚本调用的核心技术。
2. SSH协议理解与应用
2.1 SSH协议概述
2.1.1 SSH协议的工作原理
SSH(Secure Shell)是一种网络协议,用于在不安全的网络环境中为计算机之间提供安全的加密通信。它允许用户通过一个安全的通道访问远程计算机的命令行界面,进行文件传输,以及其他网络服务。SSH协议的工作原理通常包括以下几个阶段:
- 版本协商 :SSH客户端和服务器首先会交换协议版本信息,确保双方支持的版本是一致的。
- 密钥交换 :使用Diffie-Hellman密钥交换算法来生成一个共享的秘密值,这个值将会用来加密后续的通信内容。
- 服务器认证 :客户端会验证服务器的身份,通常是通过匹配服务器的公钥和已知的主机密钥。
- 用户认证 :用户通过密码或者公钥的方式进行身份验证。如果使用公钥认证,客户端会发送公钥信息,服务器会使用对应的私钥进行匹配。
- 会话请求 :一旦用户认证成功,客户端就会向服务器发送会话请求,然后就可以开始使用加密的通道进行数据传输。
2.1.2 SSH与传统远程连接技术比较
与传统远程连接技术(如Telnet、FTP等)相比,SSH提供了一种更加安全的通信方式。Telnet和FTP在传输过程中不加密数据,这使得它们非常容易受到网络嗅探和中间人攻击。相比之下,SSH通过加密所有的数据传输,包括登录信息和命令输出,保证了数据的机密性和完整性。
2.2 SSH协议在Java中的应用
2.2.1 Java中实现SSH连接的方法
在Java中,实现SSH连接主要有两种方式:
- 使用Java自带的JSch库 :JSch是一个纯Java实现的SSH2客户端库,可以用来建立和管理SSH连接,并执行远程命令。
- 使用第三方SSH库 :除了JSch之外,还有其他一些第三方库可以用来实现SSH连接,如Apache MINA SSHD、Ganymed SSH-2等。
下面是一个使用JSch库创建SSH连接的基本示例:
import com.jcraft.jsch.*;
public class SSHDemo {
public static void main(String[] args) {
Session session = null;
Channel channel = null;
ChannelExec channelExec = null;
try {
JSch jsch = new JSch();
// 设置用户身份验证信息
UserInfo ui = new MyUserInfo();
session = jsch.getSession("username", "hostname", 22);
session.setPassword("password");
session.setUserInfo(ui);
// 连接服务器
session.connect();
// 打开一个通道
channel = session.openChannel("exec");
((ChannelExec) channel).setCommand("ls -l");
channel.setInputStream(null);
((ChannelExec) channel).setErrStream(System.err);
channel.connect();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 关闭连接
if (channel != null) channel.disconnect();
if (session != null) session.disconnect();
}
}
}
在这个示例中,我们首先创建了一个 JSch 对象,然后使用用户名、主机名和端口号建立了一个会话( Session )。设置好必要的用户信息后,我们通过调用 connect() 方法来连接到服务器。一旦连接成功,我们就可以打开一个通道( Channel )并执行命令。
2.2.2 Java主流SSH库的选择与比较
当选择Java中的SSH库时,有几个关键因素需要考虑:
- 性能 :一些库在处理大量并发连接时表现更好。
- 功能 :不同的库支持不同级别的SSH协议,包括SSH1和SSH2。
- 易用性 :有些库具有更加直观的API和更好的文档支持。
- 社区支持 :活跃的社区意味着更多的问题解决案例和定期的更新。
表2.1比较了几个流行的Java SSH库的特性:
| 特性\库 | JSch | Apache MINA SSHD | Ganymed SSH-2 |
|---|---|---|---|
| 支持的协议 | SSH2 | SSH2 | SSH2 |
| 开发状态 | 活跃 | 活跃 | 不再维护 |
| 许可证 | BSD | Apache License 2.0 | 不适用 |
| 用户认证方法 | 密码、公钥 | 密码、公钥 | 密码、公钥 |
在选择库的时候,如果项目需要长期维护,并且对性能有较高要求,同时考虑到社区支持,那么 JSch 可能是更好的选择。而如果项目需要支持SSH1或者是需要实现SSH服务器端功能,那么 Apache MINA SSHD 可能更加适合。 Ganymed SSH-2 虽然功能齐全,但因为不再维护,可能会存在安全风险,所以并不推荐用于新项目。
请注意,本节介绍的代码和表格内容仅为示例,实际应用时还需要根据项目需求和环境进行相应的调整和优化。
3. Ganymed SSH-2库的使用和配置
3.1 Ganymed SSH-2库简介
3.1.1 库的主要功能和特点
Ganymed SSH-2库是一个开源的Java实现,允许开发者在Java应用程序中建立SSH2连接。它提供了完整的客户端支持,能够执行远程命令、文件传输以及支持多种认证方法。Ganymed SSH-2库的主要特点包括:
- 支持SSH2协议 :它遵循SSH-2协议,这是目前广泛使用的安全远程连接协议。
- 多平台支持 :可以在任何支持Java的平台上运行。
- 丰富的认证方式 :支持密码认证、公钥认证等。
- 文件传输能力 :可以进行SFTP文件传输,这是SSH协议提供的安全文件传输方法。
- 开源免费 :作为一个开源项目,Ganymed SSH-2库可以免费使用,并且用户可以根据自己的需求进行扩展或修改。
3.1.2 库的适用场景和限制
Ganymed SSH-2库适用于需要在Java应用程序中远程执行命令或传输文件的场景。对于安全性要求较高的应用,它通过SSH2协议提供加密通道,确保传输数据的安全。
然而,Ganymed SSH-2库也有一些限制:
- 功能更新缓慢 :自2010年以来,该库没有维护更新,意味着不再支持最新的SSH安全特性。
- 文档和社区支持有限 :由于缺乏维护,相关的文档和社区支持相对较少。
- 缺少现代Java特性的支持 :不支持最新的Java版本特性,可能需要额外的工作以适配较新的Java环境。
3.2 Ganymed SSH-2库的安装与配置
3.2.1 库的环境搭建步骤
要在Java项目中使用Ganymed SSH-2库,首先需要下载库文件并将其添加到项目类路径中。以下是详细的环境搭建步骤:
- 下载Ganymed SSH-2库 :前往Ganymed SSH-2的官方网站或其他代码托管平台下载对应的jar文件。
- 配置Java环境 :将下载的jar文件添加到项目的构建路径中,例如在Maven项目中通过添加依赖来实现。
对于Maven项目,可以在 pom.xml 文件中添加以下依赖:
<dependency>
<groupId>chssh</groupId>
<artifactId>ganymed-ssh2-build210</artifactId>
<version>20150726.083605-1</version>
</dependency>
- 测试库是否正确加载 :运行一个简单的示例程序,验证库是否已经正确加载。
3.2.2 库的配置文件和参数解析
Ganymed SSH-2库在使用前需要进行一些基本配置,比如SSH连接的参数,主要包括服务器地址、端口、认证信息等。以下是一个简单的配置示例:
import ch.ethz.ssh2.*;
public class SSHConnection {
public static void main(String[] args) {
try {
// 创建SSH连接
Connection conn = new Connection("your.remote.host");
// 设置连接参数
conn.connect(new Properties());
conn.authenticateWithPassword("username", "password");
// 输出是否认证成功
if (conn.isAuthenticated()) {
System.out.println("Authentication was successful");
}
// 关闭连接
conn.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
在上面的代码中:
"your.remote.host"是远程服务器的主机名或者IP地址。"username"和"password"分别是登录远程服务器的用户名和密码。new Properties()这个对象可以用来设置一些特定的连接参数,例如端口号(默认是22)、超时时间等。conn.authenticateWithPassword方法用于密码认证,也可以使用conn.authenticateWithPublicKey方法使用公钥认证。
3.3 使用Ganymed SSH-2库执行远程命令
3.3.1 基础命令执行示例
Ganymed SSH-2库可以用来执行远程服务器上的Shell命令。以下是一个执行基础命令的示例:
import ch.ethz.ssh2.*;
public class SSHCommandExecution {
public static void main(String[] args) {
try {
Connection conn = new Connection("your.remote.host");
conn.connect(new Properties());
if (conn.authenticateWithPassword("username", "password")) {
Channel channel = conn.openChannel("exec");
String command = "ls -l"; // 这里填写你想要执行的命令
((ChannelExec)channel).setCommand(command);
channel.connect();
// 从命令执行结果流中读取数据
java.io.InputStream in = channel.getInputStream();
java.io.OutputStream out = channel.getOutputStream();
byte[] tmp = new byte[4096];
while (true) {
while (in.available() > 0) {
int i = in.read(tmp, 0, 1024);
if (i < 0) break;
System.out.print(new String(tmp, 0, i));
}
if (channel.isClosed()) {
System.out.println("exit-status: " + channel.getExitStatus());
break;
}
try {
Thread.sleep(100);
} catch (Exception ee) {}
}
channel.close();
}
conn.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
在这个示例中,我们创建了一个名为 ChannelExec 的通道,并使用 setCommand 方法设置了要执行的命令。通过 getInputStream 和 getOutputStream 方法读取命令执行的结果。
3.3.2 处理远程命令执行的输出
在执行远程命令后,我们通常需要处理命令的输出。这里,我们不仅打印命令的输出,还处理了命令的退出状态码。
// ...[之前的代码]
// 从命令执行结果流中读取数据
java.io.InputStream in = channel.getInputStream();
java.io.OutputStream out = channel.getOutputStream();
byte[] tmp = new byte[4096];
while (true) {
while (in.available() > 0) {
int i = in.read(tmp, 0, 1024);
if (i < 0) break;
System.out.print(new String(tmp, 0, i));
}
if (channel.isClosed()) {
System.out.println("exit-status: " + channel.getExitStatus());
break;
}
try {
Thread.sleep(100);
} catch (Exception ee) {}
}
channel.close();
// ...[之后的代码]
通过检查 in.available() 来确定是否还有数据可读,然后通过 in.read(tmp, 0, 1024) 读取数据。输出结果以及命令的退出状态码会被打印出来。
3.3.3 执行多个命令的序列化处理
在一些情况下,我们需要连续执行多个命令,并依赖前一个命令的输出。Ganymed SSH-2库本身没有内建命令序列化执行的功能,但可以通过组合使用 Channel 来实现。
// ...[之前的代码]
// 执行第二个命令依赖第一个命令的输出
((ChannelExec)channel).setCommand("第二个命令");
channel.connect();
// 从第二个命令执行结果流中读取数据,和第一个命令相同的方式
// 关闭第二个Channel
channel.close();
// ...[之后的代码]
在这个例子中,我们首先执行第一个命令并获取其输出,然后重新创建一个新的 ChannelExec 对象来执行第二个依赖第一个命令输出的命令。
3.3.4 命令执行中的异常处理
在使用Ganymed SSH-2库进行远程命令执行时,可能会遇到多种异常情况。常见的异常包括:
- SSH连接失败 :可能是因为网络问题、目标主机不可达、认证失败等。
- 命令执行错误 :执行的命令可能不存在或者执行时遇到错误。
异常处理通常可以通过try-catch块来实现:
try {
// ...[SSH连接和命令执行的代码]
} catch (IOException e) {
e.printStackTrace();
// 处理I/O异常,例如连接问题或命令执行错误
} catch (Exception e) {
e.printStackTrace();
// 处理其他类型的异常
}
通过捕获这些异常,我们可以记录错误信息、重试命令或者通知用户错误情况。
3.3.5 命令执行的安全性考虑
在执行远程命令时,安全性是一个需要特别关注的问题。使用Ganymed SSH-2库时,我们主要关注以下几点:
- 加密通信 :SSH协议默认使用加密连接,确保传输过程中的数据不会被窃取或篡改。
- 认证方式 :建议使用公钥认证方式,这比密码认证更为安全。
- 命令执行的权限控制 :远程执行的命令应该有适当的权限控制,避免执行敏感或危险的操作。
执行命令时应当始终关注命令的来源和内容,避免执行未经验证的代码或命令,以防止潜在的安全风险。
4. 远程服务器SSH连接建立与认证
4.1 SSH连接建立流程
4.1.1 连接建立的步骤和条件
建立SSH连接主要包含客户端和服务端两个部分。要成功建立一个SSH连接,客户端和服务端需要遵循以下步骤:
-
初始化阶段 :
- 客户端首先初始化一个TCP连接到服务器的22端口(默认端口)。
- 客户端和服务端进行版本号协商,确保双方使用的SSH协议版本兼容。 -
密钥交换过程 :
- 双方交换各自支持的加密算法,并选择一个共同的算法进行密钥交换。
- 通过密钥交换算法(如Diffie-Hellman)生成一个会话密钥,用于之后的数据传输加密。 -
服务器认证阶段 :
- 服务器通过自己的私钥对一个包含会话ID和其它信息的字符串进行签名。
- 客户端收到服务器的签名信息,并使用之前交换的服务器公钥进行验证,确保服务器的真实性。 -
用户认证阶段 :
- 客户端向服务器提供用户认证信息(如用户名和密码,或者公钥)。
- 服务器验证接收到的用户认证信息。如果用户认证成功,则允许连接;否则,终止连接或重试认证。
为了完成这个连接过程,需要满足以下条件:
- 网络连接 :客户端和服务器之间的网络必须畅通,TCP端口22(或自定义端口)必须开放。
- 服务端配置 :SSH服务必须在服务器上运行,且配置文件中的设置允许接受新的连接。
- 认证信息 :用户必须提供有效的用户名和密码,或相应的私钥信息。
- 密钥交换算法 :双方都需要支持至少一种共同的密钥交换算法。
- 加密算法 :双方需要支持共同的加密算法,以保证数据传输的安全。
4.1.2 连接过程中常见问题及解决策略
在连接过程中可能会遇到以下常见问题:
- 网络不可达 :确保服务器的IP地址和端口号是正确的,并且网络中的防火墙没有阻断相应的连接。
- 服务端拒绝连接 :检查服务器上的SSH配置文件,确认服务器是否配置为接受新连接。
- 认证失败 :检查提供的认证信息是否正确,包括用户名、密码或公钥/私钥。
- 密钥不匹配 :确保客户端使用的密钥交换算法和加密算法与服务器端的配置相匹配。
- 版本不兼容 :更新客户端或服务器上的SSH程序,以支持兼容的SSH版本。
解决这些问题通常需要检查和配置以下内容:
- 客户端配置 :检查
~/.ssh/config或使用ssh -v命令来获取详细日志信息。 - 服务器配置 :查看
/etc/ssh/sshd_config文件中的设置,例如是否启用了公钥认证。 - 日志文件 :查看服务器的SSH日志文件(如
/var/log/auth.log),分析认证失败的原因。
代码示例及解释:
ssh -vvv 用户名@服务器地址
该命令使用了SSH协议,进行三次详细的调试输出,有助于诊断连接问题。其中 -vvv 参数可以详细输出连接的每一步操作,便于开发者识别问题所在。
4.2 SSH认证机制详解
4.2.1 认证方式及各自特点
SSH提供了多种认证方式,主要包括:
- 密码认证 :
- 用户名和密码是最基本的认证方式。
-
特点:简单易用,但是密码可能会被泄露或者暴力破解。
-
公钥认证 :
- 使用一对公钥和私钥进行认证。
- 用户将公钥存放在服务器上,私钥保留在本地。
-
特点:安全级别高,即使私钥丢失,只要没有泄露,仍然可以撤销公钥并重新生成密钥对。
-
Host-based认证 :
- 基于已认证的主机进行认证。
-
特点:如果其他用户可以访问当前认证的机器,他们可能不需要再次认证就可以访问SSH服务器。
-
GSSAPI认证 :
- 使用Kerberos或其它GSSAPI机制进行认证。
- 特点:适合大型网络环境中,可以简化认证过程,但增加了系统配置复杂性。
4.2.2 认证过程的安全性分析
认证过程的安全性是至关重要的,特别是在远程连接的情况下。从安全性角度来分析:
- 双向认证 :SSH协议支持双向认证,这意味着客户端和服务端都可以验证对方的身份,从而有效防止中间人攻击(MITM)。
- 加密通信 :所有认证信息都是加密传输的,即使在不安全的网络中,认证信息也不会被轻易截获。
- 密钥存储 :私钥的存储需要足够安全,防止被未授权人员访问。通常建议使用硬件安全模块(HSM)或专门的密钥管理工具。
- 密钥更新 :定期更新密钥可以降低被破解的风险。对于私钥泄漏的情况,及时撤销密钥对是非常必要的。
- 多因素认证 :结合使用多种认证方式(例如密码和密钥)可以进一步提高安全性。
公钥认证的安全性尤其值得强调,因为它使用非对称加密原理,为每次连接生成唯一的会话密钥。私钥的保护至关重要,需要采取如下措施:
- 使用强密码对私钥进行加密。
- 设置合适的文件权限,避免未授权用户访问私钥文件。
- 定期更新密钥,尤其是发现安全漏洞时。
- 使用多因素认证,进一步增加安全性。
5. 执行远程Shell命令及获取输出
5.1 远程命令执行策略
5.1.1 执行命令的基本方法
在Java中,通过SSH库执行远程Shell命令的基本策略通常涉及以下步骤:
- 建立SSH连接 :首先需要通过Java SSH库建立到远程服务器的SSH连接。
- 创建会话 :连接建立后,需要创建一个会话(Session)来执行命令。
- 执行命令 :通过会话执行命令,通常是调用会话的
exec方法。 - 获取输出流 :命令执行后,可以通过会话对象获取输出流,来读取命令执行的结果。
- 命令输出的处理 :最后,解析输出流中的数据,根据实际需要进行处理。
下面是一个简单的示例代码,演示如何使用Ganymed SSH-2库来执行远程命令:
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
public class SSHCommandExecutor {
public static void executeRemoteCommand(String host, int port, String username, String password, String command) throws Exception {
JSch jsch = new JSch();
Session session = jsch.getSession(username, host, port);
session.setPassword(password);
session.setConfig("StrictHostKeyChecking", "no");
session.connect();
Channel channel = session.openChannel("exec");
((ChannelExec) channel).setCommand(command);
channel.setInputStream(null);
((ChannelExec) channel).setErrStream(System.err);
channel.connect();
InputStream in = channel.getInputStream();
byte[] tmp = new byte[1024];
while (true) {
while (in.available() > 0) {
int i = in.read(tmp, 0, 1024);
if (i < 0) {
break;
}
System.out.print(new String(tmp, 0, i));
}
if (channel.isClosed()) {
System.out.println("exit-status: " + channel.getExitStatus());
break;
}
try {
Thread.sleep(1000);
} catch (Exception ee) {}
}
channel.disconnect();
session.disconnect();
}
}
5.1.2 执行多个命令的序列化处理
如果需要按顺序执行多个命令,并且需要等待每个命令执行完成再执行下一个,可以对上述示例进行简单扩展,使其能够在一个会话中执行多个命令。关键点在于检查前一个命令的退出状态,确保其成功执行后再继续执行下一个命令。
这里是一个简化的例子,演示如何执行序列化命令:
// ...(省略之前的代码)
// 在一个会话中依次执行多个命令
String[] commands = {"ls -l", "cat /path/to/file.txt", "echo 'Hello, World!'"};
for (String cmd : commands) {
((ChannelExec) channel).setCommand(cmd);
channel.connect();
InputStream in = channel.getInputStream();
byte[] tmp = new byte[1024];
while (true) {
while (in.available() > 0) {
int i = in.read(tmp, 0, 1024);
if (i < 0) {
break;
}
System.out.print(new String(tmp, 0, i));
}
if (channel.isClosed()) {
System.out.println("exit-status: " + channel.getExitStatus());
break;
}
try {
Thread.sleep(1000);
} catch (Exception ee) {}
}
}
// ...(省略之前的代码)
5.2 远程命令输出的接收与解析
5.2.1 输出数据的捕获方法
远程命令的输出数据通常通过命令执行完毕后的输出流获取。在Java中,可以使用输入流(InputStream)来捕获这些数据。通过不断从输入流中读取数据,可以将输出数据收集到一个字符串或者字节数组中。
// ...(省略之前的代码)
// 捕获命令输出
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
while (true) {
while (in.available() > 0) {
int i = in.read(buffer);
if (i < 0) {
break;
}
output.write(buffer, 0, i);
}
if (channel.isClosed()) {
System.out.println("exit-status: " + channel.getExitStatus());
break;
}
try {
Thread.sleep(1000);
} catch (Exception ee) {}
}
String result = output.toString("UTF-8"); // 将捕获的数据转换为字符串
System.out.println(result); // 输出命令执行结果
output.close();
// ...(省略之前的代码)
5.2.2 输出数据的格式化处理
捕获到的命令输出数据可能是原始格式或者包含额外的控制字符,需要进行适当的格式化以方便后续的解析和使用。常见的格式化处理包括去除空白字符、按行分割、过滤特定字符等。
// ...(省略之前的代码)
// 格式化命令输出
String formattedResult = result.replaceAll("\\s+", " "); // 去除多余的空白字符
String[] lines = formattedResult.split("\\r?\\n"); // 按行分割字符串
// 处理每一行数据
for (String line : lines) {
// 逐行进行数据解析等操作
}
// ...(省略之前的代码)
在处理输出数据时,还可以根据实际需要对数据进行更复杂的解析,比如将文本形式的数据转换为Java对象、解析JSON数据等。这通常需要引入额外的解析库,如 org.json 或 Gson ,来完成更复杂的格式化和数据结构转换任务。
简介:本文详细讲解了Java如何调用远程Shell脚本,这是自动化运维任务中的常见需求。文章深入探讨了使用Java执行远程Shell命令的方法,重点介绍了SSH协议和Ganymed SSH-2库的使用。我们将通过Java代码示例来展示如何创建SSH连接、执行远程命令、处理命令输出以及如何灵活配置连接参数。这些内容对于希望在Java项目中实现远程服务器管理功能的开发者来说非常有价值。
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐

所有评论(0)