Apache Commons Net 3.13.0 常用 API 与网络客户端用法详解
逐族详解 Apache Commons Net 3.13.0 的 FTPClient、SMTPClient、TelnetClient、SubnetUtils 等客户端 API,全部示例与真实服务端到端实测并附完整回显
Apache Commons Net 3.13.0 常用 API 与网络客户端用法详解
目标:按客户端家族逐个过一遍常用 API,每个方法都用真实服务端跑通并留下完整回显。已在 Ubuntu 24.04.5 LTS(x86_64)+ OpenJDK 17.0.20.1 实测通过。
一、环境信息
- OS:Ubuntu 24.04.5 LTS
- 架构:x86_64
- JDK:OpenJDK 17.0.20.1
- 构建工具:Apache Maven 3.9.16
- 组件版本:Apache Commons Net 3.13.0(传递依赖 commons-io 2.21.0)
- Maven 坐标:
commons-net:commons-net:3.13.0 - 联动服务:vsftpd 3.0.5(127.0.0.1:21)、SMTP sink(127.0.0.1:2525)、netcat-openbsd 1.226(127.0.0.1:2323)
- 工作目录:
/root/commons-net-demo
所有演示类位于该工程的 src/main/java(默认包),编译产物在 target/classes,运行命令统一为:
java -Dfile.encoding=UTF-8 -cp target/classes:$HOME/.m2/repository/commons-net/commons-net/3.13.0/commons-net-3.13.0.jar:$HOME/.m2/repository/commons-io/commons-io/2.21.0/commons-io-2.21.0.jar <类名>
二、客户端家族总览
Commons Net 的协议客户端全部继承自 SocketClient(TCP 一族)或 DatagramSocketClient(UDP 一族),API 骨架一致:connect() → 协议命令 → logout()/disconnect()。
| 客户端 | 默认端口(实测) | 典型用途 | 本文实测范围 |
|---|---|---|---|
FTPClient | 21 | 文件传输、目录管理 | 登录/上传/下载/列表/建删目录/改名/追加/双模式 |
SMTPClient | 25 | 发信 | HELO 明文流、EHLO + AUTH LOGIN 认证流 |
TelnetClient | 23 | 远程字符终端 | 指定终端类型、双向交互 |
POP3Client | 110 | 收信(本文未部署服务,不做示例) | —— |
NTPUDPClient | 123(常量) | 校时 | —— |
TFTPClient | 69(常量) | 轻量文件传输 | —— |
SubnetUtils/SubnetUtils6 | —— | CIDR/掩码解析与 IP 判断 | IPv4 全边界 + IPv6 |
方法名与签名以 3.13.0 为准;超时、编码等参数口径见《配置参数详解》,本文聚焦"怎么用"。
三、FTPClient 用法详解
3.1 连接与登录
最小骨架三步:connect → login → logout/disconnect,每次命令后可用 getReplyCode()/getReplyString() 读服务端回复:
FTPClient ftp = new FTPClient();
ftp.connect("127.0.0.1", 21);
System.out.println("连接回显: " + ftp.getReplyString().trim());
if (!ftp.login("ftpuser", "q1w2e3r4")) {
throw new IllegalStateException("登录失败: " + ftp.getReplyString());
}
System.out.println("登录成功, 当前目录=" + ftp.printWorkingDirectory()
+ " 服务端类型=" + ftp.getSystemType());
实测回显:
连接回显: 220 (vsFTPd 3.0.5)
登录成功, 当前目录=/ 服务端类型=UNIX Type: L8
登录后工作目录是 /——vsftpd 的 chroot_local_user=YES 把用户锁在家目录 /home/ftpuser,客户端视角只见 chroot 后的虚拟根,属正常现象。
3.2 文件与目录操作全流程
一段程序把建目录、上传、列表(含元数据)、下载、追加、改名、删除全部走一遍:
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
public class FtpFullDemo {
public static void main(String[] args) throws Exception {
FTPClient ftp = new FTPClient();
ftp.connect("127.0.0.1", 21);
System.out.println("[1] 连接: " + ftp.getReplyString().trim());
ftp.login("ftpuser", "q1w2e3r4");
System.out.println("[2] 登录后目录: " + ftp.printWorkingDirectory());
ftp.enterLocalPassiveMode();
ftp.setFileType(FTP.BINARY_FILE_TYPE);
System.out.println("[3] PASV 协商回显: " + ftp.sendCommand("PASV"));
for (String line : ftp.getReplyStrings()) {
System.out.println(" " + line.trim());
}
System.out.println("[4] makeDirectory(data) = " + ftp.makeDirectory("data")
+ " 回复码=" + ftp.getReplyCode());
System.out.println("[5] makeDirectory(data/2026) = " + ftp.makeDirectory("data/2026"));
System.out.println("[6] changeWorkingDirectory(data/2026) = " + ftp.changeWorkingDirectory("data/2026")
+ " pwd=" + ftp.printWorkingDirectory());
byte[] payload = "Commons Net FTPClient 完整演示数据 v1".getBytes("UTF-8");
try (InputStream in = new ByteArrayInputStream(payload)) {
System.out.println("[7] storeFile(first.log) = " + ftp.storeFile("first.log", in)
+ " 回复码=" + ftp.getReplyCode());
}
try (InputStream in = new FileInputStream(new File("/root/commons-net-demo/hello-ftp.txt"))) {
System.out.println("[8] storeFile(hello-ftp.txt) = " + ftp.storeFile("hello-ftp.txt", in));
}
System.out.println("[9] listFiles 解析结果:");
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (FTPFile f : ftp.listFiles()) {
String type = f.isDirectory() ? "目录" : "文件";
String ts = f.getTimestamp() == null ? "无" : fmt.format(f.getTimestamp().getTime());
System.out.println(" " + type + " name=" + f.getName()
+ " size=" + f.getSize() + " mtime=" + ts);
}
try (OutputStream out = new FileOutputStream(new File("/root/commons-net-demo/first-download.log"))) {
System.out.println("[10] retrieveFile(first.log) = " + ftp.retrieveFile("first.log", out));
}
try (InputStream in = new ByteArrayInputStream(" 追加内容".getBytes("UTF-8"))) {
System.out.println("[11] appendFile(first.log) = " + ftp.appendFile("first.log", in));
}
System.out.println("[12] rename(first.log, first-v2.log) = " + ftp.rename("first.log", "first-v2.log"));
System.out.println("[13] deleteFile(first-v2.log) = " + ftp.deleteFile("first-v2.log"));
ftp.changeWorkingDirectory("/");
ftp.enterLocalActiveMode();
try (InputStream in = new ByteArrayInputStream("active-mode-check".getBytes("UTF-8"))) {
System.out.println("[14] 主动模式 storeFile(active.log) = " + ftp.storeFile("active.log", in)
+ " 回复码=" + ftp.getReplyCode());
}
System.out.println("[15] deleteFile(active.log) = " + ftp.deleteFile("active.log"));
ftp.logout();
ftp.disconnect();
System.out.println("[16] 完成, isConnected = " + ftp.isConnected());
}
}
实测回显(完整):
[1] 连接: 220 (vsFTPd 3.0.5)
[2] 登录后目录: /
[3] PASV 协商回显: 227
227 Entering Passive Mode (127,0,0,1,156,144).
[4] makeDirectory(data) = true 回复码=257
[5] makeDirectory(data/2026) = true
[6] changeWorkingDirectory(data/2026) = true pwd=/data/2026
[7] storeFile(first.log) = true 回复码=226
[8] storeFile(hello-ftp.txt) = true
[9] listFiles 解析结果:
文件 name=first.log size=43 mtime=2026-10-15 00:56:00
文件 name=hello-ftp.txt size=35 mtime=2026-10-15 00:56:00
[10] retrieveFile(first.log) = true
[11] appendFile(first.log) = true
[12] rename(first.log, first-v2.log) = true
[13] deleteFile(first-v2.log) = true
[14] 主动模式 storeFile(active.log) = true 回复码=226
[15] deleteFile(active.log) = true
[16] 完成, isConnected = false
验证结果:16 步全部成功。listFiles() 把 UNIX 风格列表解析成了类型、大小、修改时间齐全的 FTPFile 对象;isConnected=false 证明 logout()+disconnect() 正常收尾。
3.3 被动与主动模式的实测差异
回显 [3] 是手工发送 PASV 后抓到的服务端原文:227 Entering Passive Mode (127,0,0,1,156,144)——前四段是数据连接 IP,后两段拼出端口 156×256+144=40080,正落在 vsftpd 配置的 40000~40100 被动端口段内。回显 [14] 则证明切到主动模式后传输同样成功(客户端与服务端同机,PORT 回连无 NAT 阻碍)。
💡 技术拓展:两种模式的选择逻辑:
维度 主动模式(PORT) 被动模式(PASV/EPSV) 数据连接由谁发起 服务端连客户端 客户端连服务端 客户端要求 可被服务端直连 无 服务端要求 无 放行被动端口段 Commons Net API enterLocalActiveMode()enterLocalPassiveMode()本文演示服务双模式皆通;生产上客户端在内网/NAT 后一律被动模式,服务端记得放行被动端口段。
3.4 要点与坑
- 一次只能建一级目录:
makeDirectory("data/2026")在data尚不存在时被 vsftpd 拒绝(实测回复 550,方法返回 false),先建data再建子级即可,回显[4][5]即两步走的结果。 - 二进制模式必须显式设置:默认 ASCII 模式会按平台改写换行符,
setFileType(FTP.BINARY_FILE_TYPE)对图片、压缩包是必选项。 sendCommand("PASV")是探针用法:它会改变协议状态,本文只在演示连接上单独调用后立即断开;正常传输请用enterLocalPassiveMode(),由库自行处理 PASV 应答。- 上传后想拿流自己写:对应 API 是
storeFileStream()/retrieveFileStream(),用完必须调completePendingCommand(),否则下一个命令的回复码会错位。
四、SMTPClient 用法详解
4.1 HELO 明文发信(无认证)
SMTPClient 的完整会话:connect → login(主机名)(发 HELO)→ setSender → addRecipient → sendMessageData() 拿 Writer 写报文 → completePendingCommand() 等最终回复:
SMTPClient plain = new SMTPClient();
plain.connect("127.0.0.1", 2525);
System.out.println("[1] 连接: " + plain.getReplyString().trim());
plain.login("client.demo.local");
System.out.println("[2] HELO 回显: " + plain.getReplyString().trim());
plain.setSender("sender@demo.local");
plain.addRecipient("rcpt@demo.local");
try (Writer w = plain.sendMessageData()) {
w.write("From: sender@demo.local\r\n");
w.write("To: rcpt@demo.local\r\n");
w.write("Subject: plain helo flow\r\n");
w.write("\r\n");
w.write("body line 1\r\n");
}
System.out.println("[3] completePendingCommand = " + plain.completePendingCommand()
+ " 回复码=" + plain.getReplyCode());
plain.disconnect();
实测回显:
==== 流程一:HELO + 明文发信 ====
[1] 连接: 220 net-demo SMTP sink ready
[2] HELO 回显: 250 sink greets client.demo.local
[3] completePendingCommand = true 回复码=250
sink 侧看到的同一段对话:
C: HELO client.demo.local
C: MAIL FROM:<sender@demo.local>
C: RCPT TO:<rcpt@demo.local>
C: DATA
---- message body ----
M: b'From: sender@demo.local'
M: b'To: rcpt@demo.local'
M: b'Subject: plain helo flow'
M: b''
M: b'body line 1'
---- end of body ----
C: QUIT
connection closed
4.2 EHLO + AUTH LOGIN 认证发信
认证要用子类 AuthenticatingSMTPClient,先 ehlo() 看服务端宣告的扩展,再 auth():
AuthenticatingSMTPClient auth = new AuthenticatingSMTPClient();
auth.connect("127.0.0.1", 2525);
System.out.println("[4] 连接: " + auth.getReplyString().trim());
auth.ehlo("client.demo.local");
System.out.println("[5] EHLO 回显(多行): ");
for (String line : auth.getReplyStrings()) {
System.out.println(" " + line.trim());
}
boolean authOk = auth.auth(AuthenticatingSMTPClient.AUTH_METHOD.LOGIN, "smtpuser", "q1w2e3r4");
System.out.println("[6] AUTH LOGIN = " + authOk + " 回复码=" + auth.getReplyCode());
auth.setSender("smtpuser@demo.local");
auth.addRecipient("rcpt@demo.local");
try (Writer w = auth.sendMessageData()) {
w.write("From: smtpuser@demo.local\r\n");
w.write("To: rcpt@demo.local\r\n");
w.write("Subject: auth login flow\r\n");
w.write("\r\n");
w.write("authenticated body\r\n");
}
System.out.println("[7] completePendingCommand = " + auth.completePendingCommand());
auth.logout();
auth.disconnect();
实测回显:
==== 流程二:EHLO + AUTH LOGIN 认证发信 ====
[4] 连接: 220 net-demo SMTP sink ready
[5] EHLO 回显(多行):
250-sink greets client.demo.local
250-AUTH LOGIN PLAIN
250 8BITMIME
[6] AUTH LOGIN = true 回复码=235
[7] completePendingCommand = true
[8] 完成, isConnected = false
sink 侧还原了 AUTH LOGIN 的三次握手(Base64 由服务端解码打印):
C: EHLO client.demo.local
C: AUTH LOGIN
C: (base64 decoded) user=smtpuser
C: (base64 decoded) pass=q1w2e3r4
C: MAIL FROM:<smtpuser@demo.local>
C: RCPT TO:<rcpt@demo.local>
C: DATA
---- message body ----
M: b'From: smtpuser@demo.local'
M: b'To: rcpt@demo.local'
M: b'Subject: auth login flow'
M: b''
M: b'authenticated body'
---- end of body ----
C: QUIT
connection closed
💡 技术拓展:Commons Net 支持的 SMTP 认证方式(
AUTH_METHOD枚举实测可取值):
方式 交互次数 密码是否落网 建议 PLAIN 1 次 是(Base64) 配合 STARTTLS/TLS 使用 LOGIN 3 次问答 是(Base64) 兼容性最好,本文实测 CRAM-MD5 挑战应答 否(摘要) 不支持 TLS 的老内网
AuthenticatingSMTPClient还有starttls()可在 EHLO 后升级 TLS(配合SMTPSClient更常见),公网发信务必加密。
4.3 中文正文与编码
写通道编码在构造时决定:无参构造是 ISO-8859-1,中文变问号;new SMTPClient("UTF-8") 后字节完整。sink 侧对照(同一封"第二行:中文测试"):
M: b'????????'
M: b'\xe7\xac\xac\xe4\xba\x8c\xe8\xa1\x8c\xef\xbc\x9a\xe4\xb8\xad\xe6\x96\x87\xe6\xb5\x8b\xe8\xaf\x95'
第二行正是"第二行:中文测试"的 UTF-8 字节序列。实测还确认了 setCharset() 对 SMTP 写通道无效(编码字段构造后不可变),这是最容易踩错的一处;另外 sendMessageData() 返回的 Writer 会做点号转义(dot-stuffing),正文里出现单独一行 . 无需自行处理。
五、TelnetClient 用法详解
5.1 演示服务
netcat 监听 2323,-i 1 让应答脚本逐行输出,脚本 /root/telnet-script.txt 内容:
WELCOME TO NET-TELNET-DEMO
READY
ECHO: HELLO
BYE
nc -l 127.0.0.1 2323 -i 1 -q 3 < /root/telnet-script.txt
5.2 指定终端类型并交互
import org.apache.commons.net.telnet.TelnetClient;
import org.apache.commons.net.telnet.TelnetOption;
import java.io.InputStream;
import java.io.OutputStream;
public class TelnetFullDemo {
public static void main(String[] args) throws Exception {
TelnetClient telnet = new TelnetClient("vt220");
telnet.setConnectTimeout(10000);
telnet.connect("127.0.0.1", 2323);
System.out.println("[1] 已连接 127.0.0.1:2323, 终端类型=vt220, 连接超时=" + telnet.getConnectTimeout() + "ms");
InputStream in = telnet.getInputStream();
OutputStream out = telnet.getOutputStream();
System.out.println("[2] TERMINAL_TYPE(24) 本地协商状态=" + telnet.getLocalOptionState(TelnetOption.TERMINAL_TYPE));
String banner = readLine(in).trim();
System.out.println("[3] 服务端欢迎行: " + banner);
System.out.println("[4] 服务端就绪行: " + readLine(in).trim());
out.write("HELLO\r\n".getBytes());
out.flush();
System.out.println("[5] 发送 HELLO, 服务端回复: " + readLine(in).trim());
out.write("QUIT\r\n".getBytes());
out.flush();
System.out.println("[6] 发送 QUIT, 服务端回复: " + readLine(in).trim());
telnet.disconnect();
System.out.println("[7] 会话关闭, isConnected = " + telnet.isConnected());
}
static String readLine(InputStream in) throws Exception {
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = in.read()) != -1 && ch != '\n') {
sb.append((char) ch);
}
return sb.toString();
}
}
实测回显:
[1] 已连接 127.0.0.1:2323, 终端类型=vt220, 连接超时=10000ms
[2] TERMINAL_TYPE(24) 本地协商状态=false
[3] 服务端欢迎行: WELCOME TO NET-TELNET-DEMO
[4] 服务端就绪行: READY
[5] 发送 HELLO, 服务端回复: ECHO: HELLO
[6] 发送 QUIT, 服务端回复: BYE
[7] 会话关闭, isConnected = false
服务端侧收到的字节(nc 打印):
HELLO
QUIT
5.3 IAC 协商与 SocketClient 的区别
[2]的false说明对端(netcat)没有发起 TERMINAL_TYPE 协商,本地选项就保持未激活——对接真实终端服务器时,new TelnetClient("vt220")构造参数会让库在服务端询问时自动应答终端类型。TelnetClient的输入流内置 IAC(0xFF)解释器:协商字节不会混进业务数据,这也是它区别于裸SocketClient的核心价值。实测 netcat 收到的HELLO/QUIT与客户端写出的完全一致,普通 ASCII 零损耗。- 读阻塞模型是同步的:
getInputStream().read()阻塞到对端有数据或断开;需要事件式处理时用registerInputListener()注册监听器,或setReaderThread(false)关掉内置读线程自行调度。
六、SubnetUtils 与 SubnetUtils6 用法详解
6.1 基础用法
SubnetUtils 解析 CIDR 或"地址+掩码",getInfo() 拿 SubnetInfo 做判断与枚举(基础解析的完整示例与回显见《快速入门与集成示例》第二节,此处直接进入边界行为)。
6.2 边界行为实测
import org.apache.commons.net.util.SubnetUtils;
import org.apache.commons.net.util.SubnetUtils.SubnetInfo;
public class SubnetDeepDemo {
public static void main(String[] args) {
System.out.println("==== [1] /26 全量地址与相邻地址 ====");
SubnetUtils su = new SubnetUtils("192.168.56.0/26");
SubnetInfo info = su.getInfo();
String[] all = info.getAllAddresses();
System.out.println("countLong=" + info.getAddressCountLong()
+ " 数组长度=" + all.length
+ " 前3个=" + all[0] + "," + all[1] + "," + all[2]);
System.out.println("cidrSignature=" + info.getCidrSignature());
System.out.println("getNextAddress()=" + info.getNextAddress()
+ " getPreviousAddress()=" + info.getPreviousAddress());
System.out.println("asInteger(192.168.56.1)=" + info.asInteger("192.168.56.1")
+ " getAddress=" + info.getAddress());
System.out.println("==== [2] 地址+掩码两参构造 ====");
SubnetUtils mask = new SubnetUtils("192.168.56.130", "255.255.255.192");
SubnetInfo mi = mask.getInfo();
System.out.println("network=" + mi.getNetworkAddress() + " broadcast=" + mi.getBroadcastAddress()
+ " low=" + mi.getLowAddress() + " high=" + mi.getHighAddress()
+ " count=" + mi.getAddressCountLong());
System.out.println("==== [3] /32 单主机网段 ====");
try {
SubnetUtils s32 = new SubnetUtils("10.0.0.1/32");
SubnetInfo i32 = s32.getInfo();
System.out.println("/32 默认: low=" + i32.getLowAddress() + " high=" + i32.getHighAddress()
+ " count=" + i32.getAddressCountLong());
} catch (Exception e) {
System.out.println("/32 默认构造抛出: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
try {
SubnetUtils s32i = new SubnetUtils("10.0.0.1/32");
s32i.setInclusiveHostCount(true);
SubnetInfo i32i = s32i.getInfo();
System.out.println("/32 inclusive: low=" + i32i.getLowAddress() + " high=" + i32i.getHighAddress()
+ " count=" + i32i.getAddressCountLong());
} catch (Exception e) {
System.out.println("/32 inclusive 抛出: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
System.out.println("==== [4] 非法输入 ====");
try {
new SubnetUtils("300.1.2.0/24");
} catch (Exception e) {
System.out.println("300.1.2.0/24 -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
try {
new SubnetUtils("10.0.0.0/33");
} catch (Exception e) {
System.out.println("10.0.0.0/33 -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
实测回显(完整):
==== [1] /26 全量地址与相邻地址 ====
countLong=62 数组长度=62 前3个=192.168.56.1,192.168.56.2,192.168.56.3
cidrSignature=192.168.56.0/26
getNextAddress()=192.168.56.1 getPreviousAddress()=192.168.55.255
asInteger(192.168.56.1)=-1062717439 getAddress=192.168.56.0
==== [2] 地址+掩码两参构造 ====
network=192.168.56.128 broadcast=192.168.56.191 low=192.168.56.129 high=192.168.56.190 count=62
==== [3] /32 单主机网段 ====
/32 默认: low=0.0.0.0 high=0.0.0.0 count=0
/32 inclusive: low=10.0.0.1 high=10.0.0.1 count=1
==== [4] 非法输入 ====
300.1.2.0/24 -> IllegalArgumentException: Value [300] not in range [0,255]
10.0.0.0/33 -> IllegalArgumentException: Value [33] not in range [0,32]
逐条解读:
getNextAddress()/getPreviousAddress()以网段地址为基准移动一个 IP(192.168.56.0的下一个是.1,上一个落入上一段的.255)。- 两参构造会把主机位掩掉:
192.168.56.130/255.255.255.192自动归到 network192.168.56.128。 /32是最反直觉的点:默认口径下网络号即广播号,可枚举地址为空(low/high 都是0.0.0.0,count=0);setInclusiveHostCount(true)后才能得到10.0.0.1~10.0.0.1这一台主机。做"单 IP 是否在白名单网段"判断时记得这个口径。- 非法输入统一抛
IllegalArgumentException,报错信息带具体越界值,适合直接透给上层。
6.3 SubnetUtils6:IPv6 子网
3.13.0 新增的 SubnetUtils6(RELEASE-NOTES 条目 NET-741),API 与 IPv4 版同构,地址数用 BigInteger 表达:
import org.apache.commons.net.util.SubnetUtils6;
public class Subnet6Demo {
public static void main(String[] args) {
SubnetUtils6 su = new SubnetUtils6("2001:db8:1234:5678::/64");
SubnetUtils6.SubnetInfo info = su.getInfo();
System.out.println("network=" + info.getNetworkAddress()
+ " low=" + info.getLowAddress() + " high=" + info.getHighAddress());
System.out.println("addressCount(BigInteger)=" + info.getAddressCount());
System.out.println("isInRange(2001:db8:1234:5678::1) = " + info.isInRange("2001:db8:1234:5678::1"));
}
}
实测回显:
network=2001:db8:1234:5678:0:0:0:0 low=2001:db8:1234:5678:0:0:0:0 high=2001:db8:1234:5678:ffff:ffff:ffff:ffff
addressCount(BigInteger)=18446744073709551616
isInRange(2001:db8:1234:5678::1) = true
验证结果:/64 网段地址数 2 的 64 次方(18446744073709551616),::1 缩写形式正确落段。
七、参考链接
- JavaDoc FTPClient:https://commons.apache.org/proper/commons-net/javadocs/api-3.13.0/org/apache/commons/net/ftp/FTPClient.html
- JavaDoc SMTPClient / AuthenticatingSMTPClient:https://commons.apache.org/proper/commons-net/javadocs/api-3.13.0/org/apache/commons/net/smtp/SMTPClient.html
- JavaDoc TelnetClient:https://commons.apache.org/proper/commons-net/javadocs/api-3.13.0/org/apache/commons/net/telnet/TelnetClient.html
- JavaDoc SubnetUtils / SubnetUtils6:https://commons.apache.org/proper/commons-net/javadocs/api-3.13.0/org/apache/commons/net/util/SubnetUtils.html
- 官方示例源码:https://github.com/apache/commons-net/tree/master/src/main/java/org/apache/commons/net/examples
- 本系列:《Apache Commons Net 3.13.0 快速入门与集成示例》《Apache Commons Net 3.13.0 配置参数详解》