上周对象突然心血来潮说想养个小宠物,我问想养啥她又说随便,你看着办!!!
这我真的比较难办啊!但是咱们程序员能有个对象就不错了,还不赶紧宠着,我只能照办咯!
我去到了一家宠物店,半天也没有找到合适的目标。正在我犹豫彷徨之时,看到了老板门口鱼缸里面的金鱼游来游去还挺顺眼!
于是我问老板:
我:老板,金鱼多少钱? 老板:加鱼缸一起 100 块钱不讲价! 我:这...便宜一点咯! 老板:小伙子看你骨骼惊奇,定是个养鱼的奇才,2 块钱卖给你吧!但是鱼缸可不能给你! 我:那,你帮我打包一条吧,帮我拿个袋子装着就好了!
于是我兴高采烈的拎着小金鱼就回家了,找了个大罐子养着!对象看到我买的小金鱼后露出了幸福的笑容~
第二天早上对象把我从睡梦中摇醒:“嘤嘤嘤,人家的小鱼动不了了,你赔~~~ ”。
于是我很愤怒的跑去宠物店找老板索赔:
我:你们家卖的鱼有问题,回去就不行了! 老板:不可能,昨天在我们这都活蹦乱跳的! 我:就是你们家的鱼有问题! 老板:肯定是你自己买的鱼缸有问题! 我:手持两把锟斤拷,口中疾呼烫烫烫。 老板:脚踏千朵屯屯屯,笑看万物锘锘锘?
这一幕,似曾相识!像极了我们在开发中的场景:
测试:xx,你的代码在生产环境上运行有问题。 我:不可能,我本地都运行得好好的。 测试:你自己上生产环境上看。 我:我不看,我的代码在本地没问题,肯定是运维的锅,你去找运维!
或许很多开发人员都有过上面的经历,程序在本地运行都很正常,一上到生产环境就崩了。
这是因为程序跟小金鱼一样也会“水土不服”!而导致程序水土不服的原因一般就是环境和配置的差异!
加上现在互联网高并发、大流量的访问,一个应用往往需要部署到集群的多台机器上,并且集群扩容缩容的需求也比较频繁。
如果按照传统的方式部署,那每一台服务器上都需要装各种软件...然后进行各种配置...我仿佛看到了“工作 996,生病 ICU”在向运维工程师招手!
那有没有一种方案不仅能屏蔽环境的差异,并且还能快速部署呢?
既然“水土不服”那我把程序及整个“水土”都打包迁移,就看你服不服。而 Docker 就是这样的一种让你服技术!
昂,上面的对象是我自己 New 的(* ̄︶ ̄)..
Hello Docker
Docker 是什么呢?百度百科是这样跟我说的:
Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的 Linux 或 Windows 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。
这段话已经很概括的描述了 Docker 是什么,Docker 能干嘛,Docker 的基本特性!
相信刚开始接触 Docker 的你跟我也一样,看了几遍都很懵逼!没关系,看完全文再回头看这段话,或许就有不一样的体会了!
我们先看看 Docker 官方给出的“定妆照”:
如果非要我用一句话描述这张图片,还在上幼儿园的我会说:“一条可爱的鲸鱼背着多个集装箱,畅游在大海里 ”!
而现在我会说:“Docker 是一个运行在操作系统上的软件,这个软件上面可以运行多个相互隔离的容器”!
不同的表述,同一个意思!这条可爱的鲸鱼就是咱们的 Docker,而大海就是我们的操作系统,多个集装箱就是在 Docker 上运行的容器!什么是容器咱们后面会说~
假如你想漂洋过海来看我,你可以选择自己造一条船,这样你就得自己备足很多干粮,还得准备很多其他的必需品才能出发!
但是现在有一条鲸鱼游过来对你说,我这里有很多集装箱,里面有你所需要的一切,你选一个适合你的进来就可以了,我会带你乘风破浪的!
看到这里,你是否对 Docker 有个初步的印象了呢?至少知道了:
Docker 是什么?
为什么需要 Docker?
与传统虚拟机对比
前面我们说过 Docker 可以实现虚拟化,那 Docker 与我们平时用的虚拟机有什么区别和联系呢?
在那些年我们还买不起云服务器的时候,如果我们想学 Linux 那就得先安装一个创建虚拟机的软件。
然后在软件上面创建虚拟机,再分配内存、分配磁盘、安装 Linux 操作系统等等一系列的操作,最后等个分把钟让虚拟机运行起来~
为什么传统虚拟机启动会那么慢呢?因为传统虚拟机技术是虚拟出一套硬件后,在其上面运行一个完整的操作系统,然后在该系统上面再运行所需要的应用程序,并且虚拟机的资源需要提前分配,一旦分配这些资源将全部被占用。
但是 Docker 容器内的应用程序是直接运行于宿主的内核,容器没有自己的内核,更加不会对硬件进行虚拟。
因此 Docker 容器比传统的虚拟机更为轻便!但是 Docker 容器技术也是参考虚拟机一步一步的迭代优化过来的!
我们来看看官方给出的 Docker 容器和传统虚拟机的对比图:
图中也能看出来,Docker 就是一个运行在操作系统上的软件!
以后如果想在 Windows 上面学习 Linux,只需要在本地安装一个 Windows 版本的 Docker,然后看完本文的剩下的部分,就能轻轻松松的玩转 Linux 啦!
不过在 Windows 上安装 Docker 也需要先安装一个虚拟机~
基本组成要素
前面对 Docker 的基本概念有了个大致印象,但是到目前为止,可能你对 Docker 的认识还比较空泛,那下面部分我们就从 Docker 的基本组成要素来更深入的走进 Docker!Docker 是一个 Client-Server 的结构!
先看看官网给出的架构图:
这张图里面概括了 Docker 的所有的元素!我们就逐一分析 Docker 客户端、Docker 服务、仓库、镜像、容器等概念!
Docker 客户端
最左边是 Docker 的客户端,类似我们操作 MySQL 的工具 Navcat,只不过我们这里的是没有图形化界面的命令终端。
Docker 客户端是用户与 Docker 服务交互的窗口!我们能看到图中就是各种操作的命令!
Docker 服务
中间的是 Docker 后台运行的服务,一个称为 Docker Daemon 的守护进程。可以理解为我们 MySQL 的服务,我们的操作命令都是在这部分进行处理!
Docker Deamon 监听着客户端的请求,并且管理着 Docker 的镜像、容器、网络、磁盘(图中只列出了镜像与容器)等对象。
同样,Docker 的客户端与服务可以运行在同一机器上,也可以用某台机器上的客户端远程连接另一台机器上的 Docker 服务,这跟我们的 MySQL 一样的呢。
仓库
右边部分是注册仓库,在远古时代做开发的都知道,我们以前需要一个第三方包的时候需要去网上下载对应的 Jar 包,很麻烦不说,还容易下的包是不稳定的版本。
有了 Maven 之后,我们只要在 Maven 配置文件中引入对应的依赖,就可以直接从远程仓库中下载对应版本的 Jar 包了。
Docker 中的仓库与 Maven 的仓库是一个概念,可以远程下载常用的镜像,也可以 Push 包到远程仓库(如图中的 Redis、Nginx 等镜像),同一个镜像又可以有多个版本,在 Docker 中称为 Tag!
镜像&容器
前面我们有多次提到镜像和容器,这是 Docker 里面很核心的两个概念。那镜像和容器分别是什么呢?镜像和容器的关系是什么呢?
①镜像
官方给出的定义是:Docker 镜像是一个只读模板,可以用来创建 Docker 容器。
镜像是一种轻量级的、可执行的独立软件包,用来打包软件运行环境和基于运行环境开发的软件。
它包含运行某个软件所需要的所有的内容,包括代码、运行时、库、环境变量、配置文件等。
我们开发的 Web 应用需要 JDK 环境、需要 Tomcat 容器、需要 Linux 操作系统,那我们可以把我们所需要的一切都进行打包成一个整体(包括自己开发的 Web 应用+JDK+Tomcat+CentOS/Ubuntu+各种配置文件)。
打包后的镜像在某台机器上能运行,那它就能够在任何装有 Docker 的机器上运行。
任何镜像的创建会基于其他的父镜像,也就是说镜像是一层套一层,比如一个 Tomcat 镜像,需要运行在 CentOS/Ubuntu 上。
那我们的 Tomcat 镜像就会基于 CentOS/Ubuntu 镜像创建(在后面的操作部分我们可以通过命令查看)。
这样的结构就类似于我们吃的洋葱,如果你愿意一层一层一层地剥开我的心~
②容器
官方给出的定义是:Docker 的容器是用镜像创建的运行实例,Docker 可以利用容器独立运行一个或一组应用。
我们可以使用客户端或者 API 控制容器的启动、开始、停止、删除。每个容器之间是相互隔离的。
上一步我们构建的镜像只是一个静态的文件,这个文件需要运行就需要变为容器,我们可以把容器看做是一个简易版的 Linux 系统和运行在其中的应用程序!
就是前面看到的鲸鱼背上的一个一个的集装箱,每个集装箱都是独立的!
③镜像与容器关系
上面的概念很抽象,可以理解为容器就是镜像的一个实例,相信大家都写过类似下面的代码:
public void Dog extends Animal{ ...... } ...... Dog dog = new Dog()
我们在代码中定义了一个 Dog 类,这个类就相当于一个镜像,可以根据这个类 New 出很多的实例,New 出来的实例就相当于一个个的容器。镜像是静态的文件,而容器就是有生命的个体!
Dog 类可以继承父类 Animal,如果不显式的指定继承关系,Dog 类就默认继承 Object 类。
同样上面也说到过 Docker 中的镜像也有继承关系,一个镜像可以继承其他的镜像创建,添加新的功能!
看到这里的你是不是对 Docker 有了更多的了解了呢?我们再回头看看百度百科对 Docker 的描述,可能你又会有更深的印象:
Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的 Linux 或 Windows 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。
容器数据卷
上面说到容器是一个简易版的 Linux 系统和运行在其中的应用程序,那我们的应用程序产生的数据(比如操作日志、异常日志、数据)也是在容器内的系统中存放的,默认不会做持久化,我们可以进入到容器中查看。
但是万一有一天,Docker 这条鲸鱼不满人类的压迫,反抗了...老子打烂你的集装箱!
随着容器的关闭,容器内的数据也会丢失,重新开启的容器不会加载原来的数据(简单说就是容器重新启动又是另外一个实例了)。
那对容器内的数据需要持久化到宿主机上就很有必要了,这就需要了解我们的容器数据卷~
容器数据卷的设计目的就是做数据的持久化和容器间的数据共享,数据卷完全独立于容器的生命周期,也就是说就算容器关闭或者删除,数据也不会丢失。
简单点说就将宿主机的目录挂载到容器,应用在容器内的数据可以同步到宿主机磁盘上,这样容器内产生的数据就可以持久化了。关于容器卷的命令,我们后面会有操作实例!
命令操作
上面说了那么多,下面就到了咱们的实操环节啦!这一节的内容会通过一些常用的命令让大家更进一步的了解 Docker。注意!这里只是一些常用的命令来加深理解,而不是命令大全!
如果没有安装 Docker 的小伙伴可以自己按照官网的文档进行安装,本文不会讲到这部分的内容!所以我假设你在自己的服务器上已经装好了 Docker!
帮助命令
①docker version:查看 Docker 客户端和服务的版本。
②docker info:查看 Docker 的基本信息,如有多少容器、多少镜像、Docker 根目录等等。
③docker --help:查看 Docker 的帮助信息,这个命令可以查看所有 Docker 支持的命令~
这几个命令非常简单,有过一点 Linux 基础的小伙伴应该很容易理解!
镜像命令
①docker images:查看本地主机上所有的镜像。注意是本地主机的!这里能看到镜像的名称、版本、id、大小等基本信息,注意这里的 imageID 是镜像的唯一标识!
还可以通过 docker images tomcat 指定某个具体的镜像查看对应信息。
这里还要注意的是 CentOS 的镜像才 200MB 的大小,比我们物理机器上装的 CentOS 要小得多的多,这是因为 CentOS 的镜像只保留了 Linux 核心部分,这也是为什么 Docker 虚拟化技术比虚拟机运行效率更高的原因!
那为什么 Tomcat 的镜像这么大呢?那是因为我们之前说过我们的镜像就像一个洋葱一样,是一层套一层的!
Tomcat 的运行需要基于 CentOS、JDK 等等镜像,Tomcat 在上层所以体积比较大啦!
②docker rmi:删除本地的镜像,如下图所示,可以加上 -f 参数进行强制删除。
这里的 rmi 命令跟 Linux 中的删除命令就很像啦,只是这里加了一个 i 代表 image!
③docker search:根据镜像名称搜索远程仓库中的镜像!
④docker pull:搜索到某个镜像之后就可以从远程拉取镜像啦,有点类似咱们 Git 中的 Pull 命令,当然对应的还有个 dockerpush 的命令。
如上图,如果我们没有指定 Tag,默认就会拉取 Latest 版本,也可以通过 docker pull tomcat:1.7 的方式拉取指定版本!
注意这里在拉取镜像的时候打印出来的信息有很多,这也是前面说到的镜像是一层套一层,拉取一个镜像也是一层一层的拉取!
容器命令
通过镜像命令我们就能获取镜像、删除镜像等操作啦!镜像有了下面自然就需要通过镜像创建对应的实例啦,也就是我们的容器。下面我们以 Tomcat 为例:
①docker run [OPTIONS] IMAGE [COMMAND] [ARG...]:可以基于某个镜像运行一个容器,如果本地有指定的镜像则使用本地镜像,如果没有则从远程拉取对应的镜像然后启动!
由于这个命令非常重要,所以下面列出几个比较重要的参数:
-d:启动容器,并且后台运行(Docker 容器后台运行,就必须要有一个前台进程,容器运行的命令如果不是一直挂起的命令,容器启动后就会自动退出)。
-i:以交互模式运行容器,通常与 -t 同时使用。
-t:为容器重新分配一个伪输入终端,通常与 -i 同时使用(容器启动后进入到容器内部的命令窗口)。
-P:随机端口映射,容器内部端口随机映射到主机的高端口。
-p:指定端口映射,格式为:主机(宿主)端口:容器端口。
-v:建立宿主机与容器目录的同步。
--name="myTomcat":为容器指定一个名称(如果不指定,则有个随机的名字)。
上面我通过命令启动了一个 Tomcat 的容器,由于使用了 -t 的参数,所以容器启动后就进入到了容器的内部的命令窗口,打印了很多 Tomcat 启动的日志。
并且使用 -p 参数指定了端口映射,也就是容器内 Tomcat 运行的端口是 8080,并且映射到了宿主机上的 8888 端口,这样我们在外部就可以通过服务器的 ip+8888 端口访问到我们容器内部 Tomcat 部署的服务了。
前面我们提到过容器内的数据会随着容器的关闭而丢失。那我们就需要有容器数据卷的技术能将容器内的数据持久化到宿主机。这里需要用到 -v 参数!
我们看下面的截图:
这里第一个要注意的是我们用的 -d 参数,启动后没有进入到容器内部,还是在宿主机。(可以对比一下与上面 -it 参数的区别)
第二个要注意的是 -v /宿主机:/容器内目录实现了宿主机与容器内指定目录的数据同步!
容器启动后就可以使用 Linux 的 ll 命令查看宿主机上已经同步到了容器内的文件。
第三个要注意的是这里的同步是双向的,也就是说在宿主机上对文件的修改也会同步到容器内部!
多个不同的容器映射到宿主机的同一个目录,就可以实现不同容器间的数据共享啦!
②进入到容器后可以通过 exit 命令退出容器,也可以通过 Ctrl+P+Q 快捷键退出容器,这两种方式的不同之处是 exit 会退出并且关闭容器,而 Ctrl+P+Q 快捷键只是单纯的退出,容器还在运行,并且还能再次进入!
③docker ps:我们可以通过该命令查看正在运行的容器的信息,这里能看到容器的唯一 id,启动时间等等...
这里跟 Linux 的 ps 命令类似,所以也可以把容器理解为一个运行在 Docker 上的进程!
docker ps -a 可以查看运行中与停止的所有容器:
④docker attach [OPTIONS] CONTAINER:上面说过通过 Ctrl+P+Q 快捷键退出容器后容器还在后台运行,那如果想再次进入容器怎么办呢?我们就可以通过 attach 命令+容器的 id 再次进入容器!
⑤docker exec [OPTIONS] CONTAINER:这个命令与 attach 一样都可以再次进入后台运行的容器,但是该命令可以不进入容器而在运行的容器中执行命令!比 attach 更加强大!
⑥docker stop、docker kill、docker restart:这三个命令分别用来停止容器、强制停止容器和重启容器,就跟我们在 Linux 上停止、强制停止和重启某个进程一样的啦,这里就不做演示了!
⑦docker rm:使用这个命令就可以删除某个容器,这里跟删除镜像的区别是这里少了一个 i 啦!
需要注意的是通过 Stop 和 Kill 停止的容器还存在于 Docker 中,而使用 rm 命令操作后的容器将不再存在!
⑧docker inspect:查看容器的详情(也能查看镜像详情)。
Dockerfile
前面我们对 Docker 以及相关概念、常用命令有了基本的了解,我们也知道了可以从远程 Pull 一个镜像,那远程的镜像是怎么来的呢?如果我们想自己创建一个镜像又该怎么做呢?
对,Dockerfile!Dockerfile 是一个包含用户能够构建镜像的所有命令的文本文档,它有自己的语法以及命令,Docker 能够从 Dockerfile 中读取指令自动的构建镜像!
我们要想编写自己的 Dockerfiler 并构建镜像,那对 Dockerfile 的语法和命令的了解就是必须的,了解规则才好办事嘛!
FROM
FROM <image> [AS<name>] FROM <image>[:<tag>][AS <name>] FROM<image>[@<digest>] [AS <name>]
指定基础镜像,当前镜像是基于哪个镜像创建的,有点类似 Java 中的类继承。FROM 指令必是 Dockerfile 文件中的首条命令。
MAINTAINER
MAINTAINER <name>
镜像维护者的信息,该命令已经被标记为不推荐使用了。
LABEL
LABEL<key>=<value><key>=<value><key>=<value> ...
给镜像添加元数据,可以用 LABEL 命令替换 MAINTAINER 命令。指定一些作者、邮箱等信息。
ENV
ENV <key><value> ENV <key>=<value> ...
设置环境变量,设置的变量可供后面指令使用。跟 Java 中定义变量差不多的意思!
WORKDIR
WORKDIR /path/to/workdir
设置工作目录,在该指令后的 RUN、CMD、ENTRYPOINT, COPY、ADD 指令都会在该目录执行。如果该目录不存在,则会创建!
RUN
RUN <command> RUN ["executable","param1", "param2"]
RUN 会在当前镜像的最上面创建一个新层,并且能执行任何的命令,然后对执行的结果进行提交。提交后的结果镜像在 Dockerfile 的后续步骤中可以使用。
ADD
ADD[--chown=<user>:<group>] <src>... <dest> ADD[--chown=<user>:<group>] ["<src>",..."<dest>"]
从宿主机拷贝文件或者文件夹到镜像,也可以复制一个网络文件!如果拷贝的文件是一个压缩包,会自动解压缩!
COPY
COPY[--chown=<user>:<group>] <src>... <dest> COPY[--chown=<user>:<group>] ["<src>",..."<dest>"]
从宿主机拷贝文件或者文件夹到镜像,不能复制网络文件也不会自动解压缩!
VOLUME
VOLUME ["/data"]
VOLUME 用于创建挂载点,一般配合 run 命令的 -v 参数使用。
EXPOSE
EXPOSE <port>[<port>/<protocol>...]
指定容器运行时对外暴露的端口,但是该指定实际上不会发布该端口,它的功能是镜像构建者和容器运行者之间的记录文件。
回到容器命令中的 run 命令部分,run 命令有 -p 和 -P 两个参数。
如果是 -P 就是随机端口映射,容器内会随机映射到 EXPOSE 指定的端口;如果是 -p 就是指定端口映射,告诉运维人员容器内需要映射的端口号。
CMD
CMD["executable","param1","param2"] CMD["param1","param2"] CMD command param1 param2
指定容器启动时默认运行的命令,在一个 Dockerfile 文件中,如果有多个 CMD 命令,只有一个最后一个会生效!
同样是可以执行命令,可能你会觉得跟上面的 RUN 指令很相似,RUN 指令是在构建镜像时候执行的,而 CMD 指令是在每次容器运行的时候执行的!docker run 命令会覆盖 CMD 的命令!
ENTRYPOINT
ENTRYPOINT["executable", "param1", "param2"] ENTRYPOINT command param1 param2
这个指令与 CMD 指令类似,都是指定启动容器时要运行的命令,如果指定了 ENTRYPOINT,则 CMD 指定的命令不会执行!
在一个 Dockerfile 文件中,如果有多个 ENTRYPOINT 命令,也只有一个最后一个会生效!不同的是通过 docker run command 命令会覆盖 CMD 的命令!
执行的命令不会覆盖 ENTRYPOINT,docker run 命令中指定的任何参数都会被当做参数传递给 ENTRYPOINT!
RUN、CMD、ENTRYPOINT 区别:
RUN 指令是在镜像构建时运行,而后两个是在容器启动时执行!
CMD 指令设置的命令是容器启动时默认运行的命令,如果 docker run 没有指定任何的命令,并且 Dockerfile 中没有指定 ENTRYPOINT,那容器启动的时候就会执行 CMD 指定的命令!有点类似代码中的缺省参数!
如果设置了 ENTRYPOINT 指令,则优先使用!并且可以通过 dockerrun 给该指令设置的命令传参!
CMD 有点类似代码中的缺省参数。
USER
USER <user>[:<group>] USER <UID>[:<GID>]
用于指定运行镜像所使用的用户。
ARG
ARG <name>[=<defaultvalue>]
指定在镜像构建时可传递的变量,定义的变量可以通过 dockerbuild --build-arg = 的方式在构建时设置。
ONBUILD
ONBUILD [INSTRUCTION]
当所构建的镜像被当做其他镜像的基础镜像时,ONBUILD 指定的命令会被触发!
STOPSIGNAL
STOPSIGNAL signal
设置当容器停止时所要发送的系统调用信号!
HEALTHCHECK
HEALTHCHECK [OPTIONS] CMD command (在容器内运行运行命令检测容器的运行情况)
HEALTHCHECK NONE (禁止从父镜像继承检查)
该指令可以告诉 Docker 怎么去检测一个容器的运行状况!
SHELL
SHELL ["executable","parameters"]
用于设置执行命令所使用的默认的 Shell 类型!该指令在 Windows 操作系统下比较有用,因为 Windows 下通常会有 CMD 和 Powershell 两种 Shell,甚至还有 SH。
构建
Dockerfile 执行顺序是从上到下,顺序执行!每条指令都会创建一个新的镜像层,并对镜像进行提交。
编写好 Dockerfile 文件后,就需要使用 dockerbuild 命令对镜像进行构建了。
docker build 的格式:
docker build [OPTIONS] PATH | URL | -
-f:指定要使用的 Dockerfile 路径,如果不指定,则在当前工作目录寻找 Dockerfile 文件!
-t:镜像的名字及标签,通常 name:tag 或者 name 格式;可以在一次构建中为一个镜像设置多个标签。
例如我们可以 docker build -t myApp:1.0.1 . 这样来构建自己的镜像,注意后面的 . , 用于指定镜像构建过程中的上下文环境的目录。
如果大家想了解那些官方镜像的 Dockerfile 文件都是怎么样写的,可以上 https://hub.docker.com/ 进行搜索。
以 Tomcat 镜像为例:
能看到 Tomcat 镜像的父镜像是 OpenJDK 镜像,我们再搜索 OpenJDK 的 Dockerfile 文件:
OpenJDK 镜像的父镜像又是 OracleLinux 镜像,我们再搜索 OracleLinux 的 Dockerfile 文件:
OpenJDK 镜像的父镜像是 Scratch,这是根镜像,所有的镜像都会依赖该镜像,就像我们代码中所有的对象的父类都是 Object!
所以能看到 Tomcat 镜像就是这样一层一层的构建出来的,这也是为什么前面通过 docker images 查看到的 Tomcat 镜像为什么会有四百多兆的原因啦!
看到这里的你,是否对 Docker 是什么?为什么需要 Docker?Docker 镜像、Docker 容器的概念是什么?Docker 中常用的命令有哪些?Dockerfile 有哪些指令?怎么去构建自己的镜像?这些问题都能明白了呢?
(文章来源:51CTO)