Linux Namespace 入门系列:Namespace API
Linux Namespace
是 Linux 提供的一种内核级别环境隔离的方法。用官方的话来说,Linux Namespace 将全局系统资源封装在一个抽象中,从而使 namespace 内的进程认为自己具有独立的资源实例。这项技术本来没有掀起多大的波澜,是容器技术的崛起让他重新引起了大家的注意。
Linux Namespace 有如下 6 个种类:
分类 | 系统调用参数 | 相关内核版本 |
---|---|---|
Mount namespaces | CLONE_NEWNS | Linux 2.4.19 |
UTS namespaces | CLONE_NEWUTS | Linux 2.6.19 |
IPC namespaces | CLONE_NEWIPC | Linux 2.6.19 |
PID namespaces | CLONE_NEWPID | Linux 2.6.24 |
Network namespaces | CLONE_NEWNET | 始于Linux 2.6.24 完成于 Linux 2.6.29 |
User namespaces | CLONE_NEWUSER | 始于 Linux 2.6.23 完成于 Linux 3.8 |
namespace 的 API 由三个系统调用和一系列 /proc
文件组成,本文将会详细介绍这些系统调用和 /proc
文件。为了指定要操作的 namespace 类型,需要在系统调用的 flag 中通过常量 CLONE_NEW*
指定(包括 CLONE_NEWIPC
,CLONE_NEWNS
, CLONE_NEWNET
,CLONE_NEWPID
,CLONE_NEWUSER
和 `CLONE_NEWUTS),可以指定多个常量,通过 |(位或)操作来实现。
简单描述一下三个系统调用的功能:
- clone() : 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述系统调用参数达到隔离的目的。
- unshare() : 使某进程脱离某个 namespace。
- setns() : 把某进程加入到某个 namespace。
具体的实现原理请往下看。
1. clone()
clone()
的原型如下:
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
- child_func : 传入子进程运行的程序主函数。
- child_stack : 传入子进程使用的栈空间。
- flags : 表示使用哪些
CLONE_*
标志位。 - args : 用于传入用户参数。
clone()
与 fork()
类似,都相当于把当前进程复制了一份,但 clone()
可以更细粒度地控制与子进程共享的资源(其实就是通过 flags 来控制),包括虚拟内存、打开的文件描述符和信号量等等。一旦指定了标志位 CLONE_NEW*
,相对应类型的 namespace 就会被创建,新创建的进程也会成为该 namespace 中的一员。
clone() 的原型并不是最底层的系统调用,而是封装过的,真正的系统调用内核实现函数为 do_fork()
,形式如下:
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
其中 clone_flags
可以赋值为上面提到的标志。
下面来看一个例子:
/* demo_uts_namespaces.c
Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later
Demonstrate the operation of UTS namespaces.
*/
#define _GNU_SOURCE
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)
static int /* Start function for cloned child */
childFunc(void *arg)
{
struct utsname uts;
/* 在新的 UTS namespace 中修改主机名 */
if (sethostname(arg, strlen(arg)) == -1)
errExit("sethostname");
/* 获取并显示主机名 */
if (uname(&uts) == -1)
errExit("uname");
printf("uts.nodename in child: %s\n", uts.nodename);
/* Keep the namespace open for a while, by sleeping.
This allows some experimentation--for example, another
process might join the namespace. */
sleep(100);
return 0; /* Terminates child */
}
/* 定义一个给 clone 用的栈,栈大小1M */
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
int
main(int argc, char *argv[])
{
pid_t child_pid;
struct utsname uts;
if (argc < 2) {
fprintf(stderr, "Usage: %s <child-hostname>\n", argv[0]);
exit(EXIT_FAILURE);
}
/* 调用 clone 函数创建一个新的 UTS namespace,其中传出一个函数,还有一个栈空间(为什么传尾指针,因为栈是反着的);
新的进程将在用户定义的函数 childFunc() 中执行 */
child_pid = clone(childFunc,
child_stack + STACK_SIZE, /* 因为栈是反着的,
所以传尾指针 */
CLONE_NEWUTS | SIGCHLD, argv[1]);
if (child_pid == -1)
errExit("clone");
printf("PID of child created by clone() is %ld\n", (long) child_pid);
/* Parent falls through to here */
sleep(1); /* 给子进程预留一定的时间来改变主机名 */
/* 显示当前 UTS namespace 中的主机名,和
子进程所在的 UTS namespace 中的主机名不同 */
if (uname(&uts) == -1)
errExit("uname");
printf("uts.nodename in parent: %s\n", uts.nodename);
if (waitpid(child_pid, NULL, 0) == -1) /* 等待子进程结束 */
errExit("waitpid");
printf("child has terminated\n");
exit(EXIT_SUCCESS);
}
该程序通过标志位 CLONE_NEWUTS
调用 clone()
函数创建一个 UTS namespace。UTS namespace 隔离了两个系统标识符 — 主机名和 NIS 域名 —它们分别通过 sethostname()
和 setdomainname()
这两个系统调用来设置,并通过系统调用 uname()
来获取。
下面将对程序中的一些关键部分进行解读(为了简单起见,我们将省略其中的错误检查)。
程序运行时后面需要跟上一个命令行参数,它将会创建一个在新的 UTS namespace 中执行的子进程,该子进程会在新的 UTS namespace 中将主机名改为命令行参数中提供的值。
主程序的第一个关键部分是通过系统调用 clone()
来创建子进程:
child_pid = clone(childFunc,
child_stack + STACK_SIZE, /* Points to start of
downwardly growing stack */
CLONE_NEWUTS | SIGCHLD, argv[1]);
printf("PID of child created by clone() is %ld\n", (long) child_pid);
子进程将会在用户定义的函数 childFunc()
中开始执行,该函数将会接收 clone()
最后的参数(argv[1])作为自己的参数,并且标志位包含了 CLONE_NEWUTS
,所以子进程会在新创建的 UTS namespace 中执行。
接下来主进程睡眠一段时间,让子进程能够有时间更改其 UTS namespace 中的主机名。然后调用 uname()
来检索当前 UTS namespace 中的主机名,并显示该主机名:
sleep(1); /* Give child time to change its hostname */
uname(&uts);
printf("uts.nodename in parent: %s\n", uts.nodename);
与此同时,由 clone()
创建的子进程执行的函数 childFunc()
首先将主机名改为命令行参数中提供的值,然后检索并显示修改后的主机名:
sethostname(arg, strlen(arg);
uname(&uts);
printf("uts.nodename in child: %s\n", uts.nodename);
子进程退出之前也睡眠了一段时间,这样可以防止新的 UTS namespace 不会被关闭,让我们能够有机会进行后续的实验。
执行程序,观察父进程和子进程是否处于不同的 UTS namespace 中:
$ su # 需要特权才能创建 UTS namespace
Password:
# uname -n
antero
# ./demo_uts_namespaces bizarro
PID of child created by clone() is 27514
uts.nodename in child: bizarro
uts.nodename in parent: antero
除了 User namespace 之外,创建其他的 namespace 都需要特权,更确切地说,是需要相应的 Linux Capabilities
,即 CAP_SYS_ADMIN
。这样就可以避免设置了 SUID(Set User ID on execution)的程序因为主机名不同而做出一些愚蠢的行为。如果对 Linux Capabilities 不是很熟悉,可以参考我之前的文章:Linux Capabilities 入门教程:概念篇。
2. proc 文件
每个进程都有一个 /proc/PID/ns
目录,其下面的文件依次表示每个 namespace, 例如 user 就表示 user namespace。从 3.8 版本的内核开始,该目录下的每个文件都是一个特殊的符号链接,链接指向 $namespace:[$namespace-inode-number]
,前半部份为 namespace 的名称,后半部份的数字表示这个 namespace 的句柄号。句柄号用来对进程所关联的 namespace 执行某些操作。
$ ls -l /proc/$$/ns # $$ 表示当前所在的 shell 的 PID
total 0
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 ipc -> ipc:[4026531839]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 mnt -> mnt:[4026531840]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 net -> net:[4026531956]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 pid -> pid:[4026531836]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 user -> user:[4026531837]
lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 uts -> uts:[4026531838]
这些符号链接的用途之一是用来确认两个不同的进程是否处于同一 namespace 中。如果两个进程指向的 namespace inode number 相同,就说明他们在同一个 namespace 下,否则就在不同的 namespace 下。这些符号链接指向的文件比较特殊,不能直接访问,事实上指向的文件存放在被称为 nsfs
的文件系统中,该文件系统用户不可见,可以使用系统调用 stat() 在返回的结构体的 st_ino
字段中获取 inode number。在 shell 终端中可以用命令(实际上就是调用了 stat())看到指向文件的 inode 信息:
$ stat -L /proc/$$/ns/net
File: /proc/3232/ns/net
Size: 0 Blocks: 0 IO Block: 4096 regular empty file
Device: 4h/4d Inode: 4026531956 Links: 1
Access: (0444/-r--r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2020-01-17 15:45:23.783304900 +0800
Modify: 2020-01-17 15:45:23.783304900 +0800
Change: 2020-01-17 15:45:23.783304900 +0800
Birth: -
除了上述用途之外,这些符号链接还有其他的用途,如果我们打开了其中一个文件,那么只要与该文件相关联的文件描述符处于打开状态,即使该 namespace 中的所有进程都终止了,该 namespace 依然不会被删除。通过 bind mount 将符号链接挂载到系统的其他位置,也可以获得相同的效果:
$ touch ~/uts
$ mount --bind /proc/27514/ns/uts ~/uts
3. setns()
加入一个已经存在的 namespace 可以通过系统调用 setns()
来完成。它的原型如下:
int setns(int fd, int nstype);
更确切的说法是:setns()
将调用的进程与特定类型 namespace 的一个实例分离,并将该进程与该类型 namespace 的另一个实例重新关联。
fd
表示要加入的 namespace 的文件描述符,可以通过打开其中一个符号链接来获取,也可以通过打开 bind mount 到其中一个链接的文件来获取。nstype
让调用者可以去检查 fd 指向的 namespace 类型,值可以设置为前文提到的常量CLONE_NEW*
,填0
表示不检查。如果调用者已经明确知道自己要加入了 namespace 类型,或者不关心 namespace 类型,就可以使用该参数来自动校验。
结合 setns()
和 execve()
可以实现一个简单但非常有用的功能:将某个进程加入某个特定的 namespace,然后在该 namespace 中执行命令。直接来看例子:
/* ns_exec.c
Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later
Join a namespace and execute a command in the namespace
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
/* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)
int
main(int argc, char *argv[])
{
int fd;
if (argc < 3) {
fprintf(stderr, "%s /proc/PID/ns/FILE cmd [arg...]\n", argv[0]);
exit(EXIT_FAILURE);
}
fd = open(argv[1], O_RDONLY); /* 获取想要加入的 namespace 的文件描述符 */
if (fd == -1)
errExit("open");
if (setns(fd, 0) == -1) /* 加入该 namespace */
errExit("setns");
execvp(argv[2], &argv[2]); /* 在加入的 namespace 中执行相应的命令 */
errExit("execvp");
}
该程序运行需要两个或两个以上的命令行参数,第一个参数表示特定的 namespace 符号链接的路径(或者 bind mount 到这些符号链接的文件路径);第二个参数表示要在该符号链接相对应的 namespace 中执行的程序名称,以及执行这个程序所需的命令行参数。关键步骤如下:
fd = open(argv[1], O_RDONLY); /* 获取想要加入的 namespace 的文件描述符 */
setns(fd, 0); /* 加入该 namespace */
execvp(argv[2], &argv[2]); /* 在加入的 namespace 中执行相应的命令 */
还记得我们之前已经通过 bind mount 将 demo_uts_namespaces
创建的 UTS namespace 挂载到 ~/uts
中了吗?可以将本例中的程序与之结合,让新进程可以在该 UTS namespace 中执行 shell:
$ ./ns_exec ~/uts /bin/bash # ~/uts 被 bind mount 到了 /proc/27514/ns/uts
My PID is: 28788
验证新的 shell 是否与 demo_uts_namespaces
创建的子进程处于同一个 UTS namespace:
$ hostname
bizarro
$ readlink /proc/27514/ns/uts
uts:[4026532338]
$ readlink /proc/$$/ns/uts # $$ 表示当前 shell 的 PID
uts:[4026532338]
在早期的内核版本中,不能使用 setns()
来加入 mount namespace、PID namespace 和 user namespace,从 3.8 版本的内核开始,setns()
支持加入所有的 namespace。
util-linux 包里提供了nsenter
命令,其提供了一种方式将新创建的进程运行在指定的 namespace 里面,它的实现很简单,就是通过命令行(-t 参数)指定要进入的 namespace 的符号链接,然后利用 setns()
将当前的进程放到指定的 namespace 里面,再调用 clone()
运行指定的执行文件。我们可以用 strace
来看看它的运行情况:
# strace nsenter -t 27242 -i -m -n -p -u /bin/bash
execve("/usr/bin/nsenter", ["nsenter", "-t", "27242", "-i", "-m", "-n", "-p", "-u", "/bin/bash"], [/* 21 vars */]) = 0
…………
…………
pen("/proc/27242/ns/ipc", O_RDONLY) = 3
open("/proc/27242/ns/uts", O_RDONLY) = 4
open("/proc/27242/ns/net", O_RDONLY) = 5
open("/proc/27242/ns/pid", O_RDONLY) = 6
open("/proc/27242/ns/mnt", O_RDONLY) = 7
setns(3, CLONE_NEWIPC) = 0
close(3) = 0
setns(4, CLONE_NEWUTS) = 0
close(4) = 0
setns(5, CLONE_NEWNET) = 0
close(5) = 0
setns(6, CLONE_NEWPID) = 0
close(6) = 0
setns(7, CLONE_NEWNS) = 0
close(7) = 0
clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f4deb1faad0) = 4968
4. unshare()
最后一个要介绍的系统调用是 unshare()
,它的原型如下:
int unshare(int flags);
unshare()
与 clone()
类似,但它运行在原先的进程上,不需要创建一个新进程,即:先通过指定的 flags 参数 CLONE_NEW*
创建一个新的 namespace,然后将调用者加入该 namespace。最后实现的效果其实就是将调用者从当前的 namespace 分离,然后加入一个新的 namespace。
Linux 中自带的 unshare
命令,就是通过 unshare() 系统调用实现的,使用方法如下:
$ unshare [options] program [arguments]
options
指定要创建的 namespace 类型。
unshare 命令的主要实现如下:
/* 通过提供的命令行参数初始化 'flags' */
unshare(flags);
/* Now execute 'program' with 'arguments'; 'optind' is the index
of the next command-line argument after options */
execvp(argv[optind], &argv[optind]);
unshare 命令的完整实现如下:
/* unshare.c
Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later
A simple implementation of the unshare(1) command: unshare
namespaces and execute a command.
*/
#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
/* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)
static void
usage(char *pname)
{
fprintf(stderr, "Usage: %s [options] program [arg...]\n", pname);
fprintf(stderr, "Options can be:\n");
fprintf(stderr, " -i unshare IPC namespace\n");
fprintf(stderr, " -m unshare mount namespace\n");
fprintf(stderr, " -n unshare network namespace\n");
fprintf(stderr, " -p unshare PID namespace\n");
fprintf(stderr, " -u unshare UTS namespace\n");
fprintf(stderr, " -U unshare user namespace\n");
exit(EXIT_FAILURE);
}
int
main(int argc, char *argv[])
{
int flags, opt;
flags = 0;
while ((opt = getopt(argc, argv, "imnpuU")) != -1) {
switch (opt) {
case 'i': flags |= CLONE_NEWIPC; break;
case 'm': flags |= CLONE_NEWNS; break;
case 'n': flags |= CLONE_NEWNET; break;
case 'p': flags |= CLONE_NEWPID; break;
case 'u': flags |= CLONE_NEWUTS; break;
case 'U': flags |= CLONE_NEWUSER; break;
default: usage(argv[0]);
}
}
if (optind >= argc)
usage(argv[0]);
if (unshare(flags) == -1)
errExit("unshare");
execvp(argv[optind], &argv[optind]);
errExit("execvp");
}
下面我们执行 unshare.c
程序在一个新的 mount namespace 中执行 shell:
$ echo $$ # 显示当前 shell 的 PID
8490
$ cat /proc/8490/mounts | grep mq # 显示当前 namespace 中的某个挂载点
mqueue /dev/mqueue mqueue rw,seclabel,relatime 0 0
$ readlink /proc/8490/ns/mnt # 显示当前 namespace 的 ID
mnt:[4026531840]
$ ./unshare -m /bin/bash # 在新创建的 mount namespace 中执行新的 shell
$ readlink /proc/$$/ns/mnt # 显示新 namespace 的 ID
mnt:[4026532325]
对比两个 readlink
命令的输出,可以知道两个shell 处于不同的 mount namespace 中。改变新的 namespace 中的某个挂载点,然后观察两个 namespace 的挂载点是否有变化:
$ umount /dev/mqueue # 移除新 namespace 中的挂载点
$ cat /proc/$$/mounts | grep mq # 检查是否生效
$ cat /proc/8490/mounts | grep mq # 查看原来的 namespace 中的挂载点是否依然存在?
mqueue /dev/mqueue mqueue rw,seclabel,relatime 0 0
可以看出,新的 namespace 中的挂载点 /dev/mqueue
已经消失了,但在原来的 namespace 中依然存在。
5. 总结
本文仔细研究了 namespace API 的每个组成部分,并将它们结合起来一起使用。后续的文章将会继续深入研究每个单独的 namespace,尤其是 PID namespace 和 user namespace。
参考链接
微信公众号
扫一扫下面的二维码关注微信公众号,在公众号中回复◉加群◉即可加入我们的云原生交流群,和孙宏亮、张馆长、阳明等大佬一起探讨云原生技术
Linux Namespace 入门系列:Namespace API的更多相关文章
- Web API 入门系列 - RESTful API 设计指南
参考:https://developer.github.com/v3/ https://github.com/bolasblack/http-api-guide HTTP 协议 目前使用HTTP1. ...
- Linux ns 6. Network Namespace 详解
文章目录 1. 简介 1.1 Docker Network 桥接模式配置 2. 代码解析 2.1 copy_net_ns() 2.2 pernet_list 2.2.1 loopback_net_op ...
- linux入门系列18--Web服务之Apache服务1
前面系列文章讲解了Linux下通过文件传输.文件共享.邮件系统来分享和获取资源,本文讲解网络资源获取和共享的另外一种形式,通过Apache服务程序来提供Web服务. 本文先讲解目前主流的Web服务程序 ...
- Hadoop MapReduce编程 API入门系列之压缩和计数器(三十)
不多说,直接上代码. Hadoop MapReduce编程 API入门系列之小文件合并(二十九) 生成的结果,作为输入源. 代码 package zhouls.bigdata.myMapReduce. ...
- 爬虫入门系列(三):用 requests 构建知乎 API
爬虫入门系列目录: 爬虫入门系列(一):快速理解HTTP协议 爬虫入门系列(二):优雅的HTTP库requests 爬虫入门系列(三):用 requests 构建知乎 API 在爬虫系列文章 优雅的H ...
- HBase编程 API入门系列之create(管理端而言)(8)
大家,若是看过我前期的这篇博客的话,则 HBase编程 API入门系列之put(客户端而言)(1) 就知道,在这篇博文里,我是在HBase Shell里创建HBase表的. 这里,我带领大家,学习更高 ...
- HBase编程 API入门系列之delete(客户端而言)(3)
心得,写在前面的话,也许,中间会要多次执行,连接超时,多试试就好了. 前面的基础,如下 HBase编程 API入门系列之put(客户端而言)(1) HBase编程 API入门系列之get(客户端而言) ...
- HBase编程 API入门系列之get(客户端而言)(2)
心得,写在前面的话,也许,中间会要多次执行,连接超时,多试试就好了. 前面是基础,如下 HBase编程 API入门系列之put(客户端而言)(1) package zhouls.bigdata.Hba ...
- HBase编程 API入门系列之HTable pool(6)
HTable是一个比较重的对此,比如加载配置文件,连接ZK,查询meta表等等,高并发的时候影响系统的性能,因此引入了“池”的概念. 引入“HBase里的连接池”的目的是: 为了更高的,提高程序的并发 ...
随机推荐
- linux查看端口号占用命令-netstat
题记 经常会发现,很多时候我们在运行一些带有端口的程序时,程序经常会报端口被占用的问题,比如Tomcat 8080,端口起不来. 查看端口号 netstat 如果发现某个端口被占用后,可以用命令查看, ...
- Socket.io 入门 - Renyi的博客
Socket.io Vue 中使用 NPM 安装 npm install vue-socket.io --save npm install --save socket.io-client 引用 详情 ...
- 测试用例设计经典面试题之电梯、杯子、笔、桌子、洗衣机、椅子、ATM等
测试用例设计经典面试题之电梯.杯子.笔.桌子.洗衣机.椅子.ATM等 1.测试项目:电梯 需求测试:查看电梯使用说明书.安全说明书等 界面测试:查看电梯外观 功能测试:测试电梯能否实现正常的上升和下降 ...
- 美团新零售招聘-高级测试开发(20k-50k/月)
内推邮箱:liuxinguang@meituan.com 地点:北京 职位级别:p2-2以上级别 15.5薪
- Java对接微信登录
今天我们来对接微信开放平台的网站应用登录 首先上文档链接:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login ...
- 快速上手百度大脑EasyDL专业版·物体检测模型(附代码)
作者:才能我浪费991. 简介:1.1. 什么是EasyDL专业版EasyDL专业版是EasyDL在2019年10月下旬全新推出的针对AI初学者或者AI专业工程师的企业用户及开发者推出的A ...
- JS 增删改查操作XML
效果图: <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <titl ...
- web页面上展示图片时,图片不显示,报错:ERR_CONTENT_LENGTH_MISMATCH
问题描述 前端页面加载css,和js文件的时候,经常出现ERR_CONTENT_LENGTH_MISMATCH的报错情况. 查找问题 在单独打开hearder中css,js的网络地址是能打开的 ...
- 移动webApp必备技能一、WebApp 里Meta标签大全,webappmeta标签大全
1.先说说mate标签里的viewport: viewport即可视区域,对于桌面浏览器而言,viewport指的就是除去所有工具栏.状态栏.滚动条等等之后用于看网页的区域.对于传统WEB页面来说,9 ...
- 浅谈Spring框架
一.Spring简介 Spring 是一个开源框架,是为了解决企业应用程序开发复杂性而创建的.框架的主要优势之一就是其分层架构, 分层架构允许您选择使用哪一个组件,同时为 J2EE 应用程序开发提供集 ...