Docker镜像及其相关技术的原理和本质

栏目: 编程工具 · 发布时间: 5年前

内容简介:R

R

5月15日(周三)晚20:30,Kubernetes Master Class在线培训第6期课程《 在Kubernetes中创建高可用应用 》即将开播,点击文末【阅读原文】即可免费预约注册!

作为云计算的当红明星, Docker 来势汹汹,成为了很多IT人员现时的必备技能。但是对于新手而言, 在理解 Docker命令时常常 存在一些问题,尤其是在 Docker 镜像 底层的工作原理和容器与容器镜像的关系上。

一般情况下只有真正理解了某门技术的原理才能真正掌握这一门技术,然后才能去深入地使用这门技术。在本文中,我们会由浅入深出地带大家了解下 Docker 镜像及其相关技术的原理和本质。

容器VS.容器镜像

在说Docker 镜像的原理知识之前,我们先看下docker 容器和docker 镜像的区别,因为这部分我们后面会涉及到。

简单说来,我们可以将Docker 镜像看成是Docker 容器的静态时,也可将Docker 容器看成是Docker镜像的运行时。

从Docker 的官方文档来看,Docker 容器的定义和 Docker 镜像的定义几乎是相同,Docker 容器和Docker 镜像的区别主要在于docker 容器多出了一个可写层。

Docker镜像及其相关技术的原理和本质

容器中的进程就运行在这个可写层,这个可写层有两个状态,即运行态和退出态。当我们docker run 运行容器后,docker 容器就进入了运行态,当我们停止正在运行中的容器时,docker 容器就进入了退出态。

我们将容器从运行态转为退出态时,期间发生的变更都会写入到容器的文件系统中(需要注意的是,此处不是写入到了docker 镜像中),这方面的变化下文中我们还会再细说。

Docker存储简介

简单说来Docker 镜像就是一组只读的目录,或者叫只读的 Docker 容器模板,镜像中含有一个Docker 容器运行所需要的文件系统,所以我们说Docker 镜像是启动一个Docker 容器的基础。

如果这样不是很好理解,我们可以通过一个图一起看下:

Docker镜像及其相关技术的原理和本质

从图中可以看出除了最上面的一层为读写层之外,下面的其他的层都是只读的镜像层,并且除了最下面的一层外,其他的层都有会有一个指针指向自己下面的一层镜像。

虽然统一文件系统(union file system)技术将不同的镜像层整合成一个统一的文件系统,为构成一个完整容器镜像的层提供了一个统一的视角,隐藏了多个层的复杂性,对用户来说只存在一个文件系统,但图中的这些层并不是不能看到的,如果需要查看的话可以进入运行Docker的机器上进行查看,从这些层中可以看到Docker 内部实现的一些细节,接下来我们一起看下。

一般刚接触Docker 不久的话可能会不太清楚Docker 的存储方式是怎样的,并且可能也不太清楚Docker 容器的镜像到底存储在什么路径下,比较迷茫。

有些同学想了解下Docker 的镜像数据存储在什么位置,然后谷歌了下几篇博文,说是在/var/lib/docker 下有个aufs目录,结果在自己机器上进到这个路径后发现没有aufs相关的目录,然后以为是版本的问题,其实不然。

Linux 服务器为例,其实Docker 的容器镜像和容器本身的数据都存放在服务器的 /var/lib/docker/ 这个路径下。不过不同的linux发行版存储方式上有差别,比如,在ubuntu发行版上存储方式为AUFS,CentOS发行版上的存储方式为device mapper。

/var/lib/docker 路径下的信息在不同的阶段会有变化,从笔者个人经验来看,了解这几个阶段中新增的数据以及容器与镜像的存储结构的变化非常有利于我们对Docker容器以及Docker镜像的理解。

在下文中,我们将一起看下Docker运行后、下载镜像后、运行容器后三个阶段中Docker 存储的变化。

环境信息:

系统发行版:CentOS7.2。

内核版本:3.10.0-327.36.1.el7.x86_64

Docker 版本:1.8

启动Docker后

在此我们假设大家已经安装好了Docker环境,具体安装的过程不再赘述。

# 启动Docker 服务

[root@influxdb ~]# systemctl start docker

# 查看/var/lib/docker路径下的文件结构

 [root@localhost docker]# tree .

├── containers

├── devicemapper

│   ├── devicemapper

│   │   ├── data

│   │   └── metadata

│   └── metadata

│       ├── base

│       ├── deviceset-metadata

│       └── transaction-metadata

├── graph

├── linkgraph.db

├── repositories-devicemapper

├── tmp

├── trust

└── volumes

8 directories, 7 files

必须 启动 Docker 服务 后查看, 如果 没有启动 Docker 进程 则路径 /var/li b /docker 不存在

前文中我们已经提到过,CentOS发行版中Docker 服务使用的存储方式为devicemapper,所以我们从前面tree命令的结果中可以看到出现了目录devicemapper。

有些同学可能会问什么是 devicemapper?

太阳底下无新鲜事,devicemapper 并不是伴随着Docker 才出现的,早在linux2.6版本的内核时其实就已经引入了devicemapper,且当时是作为一个很重要的技术出现的。

简单说来devicemapper 就是Docker 服务的一个存储驱动,或者叫Docker 服务的存储后端。Devicemapper 其实是一个基于内核的框架,这个框架中对linux上很多的功能进行了增强,比如对linux上高级卷管理功能的增强。

Devicemapper 存储驱动将我们的每个docker镜像和docker容器都存在在自己的虚拟设备中,devicemapper的这些设备相当于我们常见的一般的写时复制快照设备的超配版本。通过上面的介绍,有些同学可能以为devicemapper 存储驱动是工作在块级别的,但是devicempper 实际是工作在文件级别的,也就是说devicemapper 存储驱动和写时复制操作都是直接操作块,而不是对文件进行操作。

以上是我们关于Docker 服务的devicemapper 存储驱动的一个简单的介绍,Docker 官方文档中提供了很多的有关Docker 存储驱动的介绍,感兴趣的同学可以自行查阅。

进一步的查看的话,可以看到路径/var/lib/docker/devicemapper下面有两个目录,分别为devicemapper和data,其中目录devicemapper 下存在两个名为data和metadata的文件,两个文件从名称即可看出一个是镜像数据的存储池,一个为镜像相关的元数据。接下去我们会逐个看下这个路径下的文件。

进入上面提到的目录metadata下,可以看到这个目录中已经存在三个文件,分别为:base、deviceset-metadata和transction-metadata,这三个文分别用来存放上文中我们提到的元数据的id、大小和uuid等信息。

[root@localhost metadata]# pwd

/var/lib/docker/devicemapper/metadata

[root@localhost metadata]# ls

base  deviceset-metadata  transaction-metadata

除了上面提到的几个目录,上文中tree的结果中还有几个目录,分别为:containers、devicemapper、graph、linkgraph.db、repositories-devicemapper、tmp、trust和volumes。这几个文件对于docker 的镜像存储来说都很重要,我们以文件repositories-devicemapper为例,看下这个文件对于镜像存储所起到的作用。

[root@localhost docker]# pwd

/var/lib/docker

[root@localhost docker]# ls

containers  devicemapper  graph  linkgraph.db  repositories-devicemapper  tmp  trust  volumes

我们可以先看下repositories-devicemapper 这个文件中在当前的阶段中有什么:

root@localhost docker]# cat repositories-devicemapper

{"Repositories":{}}[root@localhost docker]#

从上面cat的结果中我们不难看出,文件repositories-devicemapper 中其实记录的就是docker 镜像的属性信息,比如镜像名称、镜像标签、镜像的ID等信息,如果镜像刚好没有标签的话默认会以lastet作为标签。

以上是对Docker 服务运行后pull镜像之前/var/lib/docker 路径下数据的一个简单的解读,相信大家通过上面的描述已经对docker镜像有了一些更深入的认识。下面我们看下在我们pull 自己的第一个docker镜像之后路径/var/lib/docker 之下的数据会发生怎样的变化。

Pull 镜像后:

在此我们以一个 nginx镜像 为例一起看下这个阶段的变化。

# pull 示例镜像

[root@localhost docker]# docker pull nginx

Using default tag: latest

latest: Pulling from library/nginx

22f3bf58cd09: Pull complete

ea2fc476f5f0: Pull complete

81d728438afe: Pull complete

303a6dec1190: Pull complete

d43816b44a22: Pull complete

dc02db50a25a: Pull complete

6f650a34b308: Pull complete

a634e96a9de9: Pull complete

72f3ebe1e4d7: Pull complete

c2c9e418b22c: Pull complete

Digest: sha256:a82bbaf63c445ee9b854d182254c62e34e6fa92f63d7b4fdf6cea7e76665e06e

Status: Downloaded newer image for nginx:latest

# 查看镜像是否已经在本地

[root@localhost docker]# docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE

nginx               latest              c2c9e418b22c        2 weeks ago         109.3 MB

[root@localhost docker]#

在此我们没有指定nginx镜像的tag,因此默认拉去了最新的版本。然后我们看下路径/var/lib/docker 下是否有变化:

[root@localhost docker]# tree .

.

├── containers

├── devicemapper

│   ├── devicemapper

│   │   ├── data

│   │   └── metadata

……

省略若干数据

……

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├── 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├── 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├── 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├── 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├── a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   ├──

……

省略若干数据

……

│   ├── ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc

│   │   ├── checksum

│   │   ├── json

│   │   ├── layersize

│   │   ├── tar-data.json.gz

│   │   └── v1Compatibility

│   └── _tmp

├── linkgraph.db

├── repositories-devicemapper

├── tmp

├── trust

└── volumes

30 directories, 67 files

[root@localhost docker]#

从结尾的目录数和文件数也可以看出,在我们拉取镜像后/var/lib/docker 下多出了很多的文件(拉取镜像之前,只有8个目录,7个文件),下面我们一步步的分析。

如果这个时候看下路径/var/lib/docker下的文件的话,可以很容易的看到发生变化的主要是下面这三个文件:/var/lib/docker/devicemapper/metadata、/var/lib/docker/devicemapper/mnt以及/var/lib/docker/graph。我们先看下metadata这个文件:

Docker镜像及其相关技术的原理和本质

从结果可以看出除了上一个阶段中已经有的base、deviceset-metadata等几个文件外,还多出了一些名字较长的文件,我们挨个看下这几个文件中的数据:

[root@localhost metadata]# cat base

{ "device_id":1 ,"size":107374182400,"transaction_id":1,"initialized":true} [root@localhost metadata]# cat 22f3bf58cd0949b57df2dc161e7026a8cc77699b6a8be7d0e3085e252a5439c3

{ "device_id":2 ,"size":107374182400,"transaction_id":2,"initialized":false}

[root@localhost metadata]# cat ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc

{ "device_id":3 ,"size":107374182400,"transaction_id":3,"initialized":false}

[root@localhost metadata]# cat 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9

{"device_id":4 ,"size":107374182400,"transaction_id":4,"initialized":false} [root@localhost metadata]# cat d43816b44a2280148da8d9b6ce2f357bff9b2e59ef386181f36a4a433a9aad6c

{ "device_id":6 ,"size":107374182400,"transaction_id":6,"initialized":false} [root@localhost metadata]# cat 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2

{ "device_id":5 ,"size":107374182400,"transaction_id":5,"initialized":false} [root@localhost metadata]# cat a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50

{ "device_id":9 ,"size":107374182400,"transaction_id":9,"initialized":false} [root@localhost metadata]# cat dc02db50a25a87ca227492197721e97d19f1822701fe3ec73533a0811a6393a7

{ "device_id":7 ,"size":107374182400,"transaction_id":7,"initialized":false} [root@localhost metadata]# cat 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a

{ "device_id":8 ,"size":107374182400,"transaction_id":8,"initialized":false} [root@localhost metadata]# cat 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39

{ "device_id":10 ,"size":107374182400,"transaction_id":10,"initialized":false} [root@localhost metadata]# cat c2c9e418b22ca5a0b02ef0c2bd02c34ad792d1fc271e5580fdb3252979ccc092

{ "device_id":11 ,"size":107374182400,"transaction_id":11,"initialized":false}

从上面的结果可以看出上面的几个文件中的device_id数值是按照顺序排列下来的,换句话说就是除了上一个阶段中已经存在的base文件,上面结果中其他的几个文件都是nginx镜像的中间镜像层,也就是我们经常执行的命令docker images –a 的结果中看到的构成当前镜像的各个镜像层。

接下来我们再看一个变化较大的文件/var/lib/docker/graph。

 [root@localhost graph]# tree .

.

├── 22f3bf58cd0949b57df2dc161e7026a8cc77699b6a8be7d0e3085e252a5439c3

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── c2c9e418b22ca5a0b02ef0c2bd02c34ad792d1fc271e5580fdb3252979ccc092

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── d43816b44a2280148da8d9b6ce2f357bff9b2e59ef386181f36a4a433a9aad6c

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── dc02db50a25a87ca227492197721e97d19f1822701fe3ec73533a0811a6393a7

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

├── ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc

│   ├── checksum

│   ├── json

│   ├── layersize

│   ├── tar-data.json.gz

│   └── v1Compatibility

└── _tmp

11 directories, 50 files

从上面的结果中可以很明显的看到我们的镜像nginx及其每一个nginx镜像的中间层对应的目录下都包含如下几个文件:checksum、json、layerrize、tar-data.json.gz和v1Compatibility。我们任意找一个文件看下这几个文件中存放了什么数据。

(1) Checksum

Docker镜像及其相关技术的原理和本质

其实从文件名称即可看出每个镜像层中的checksum文件存放的是当前镜像层的md5值,用于核对当前镜像层的数据是否完整。

(2) json

从cat 的结果中可以看出json文件中存放的数据较多,比如Hostname、Domainname、User、Image等信息,而且很多参数和我们经常接触的Dockerfile中的参数相似。相比较前面的checksum文件这个文件中的内容相对较复杂,在此我们也解释下。

此处的json文件中一般主要用于存放镜像中涉及的动态信息,但需要注意的是此处的json文件并不仅仅被用于存储docker镜像的动态信息(很多同学可能会认为此处的json文件只是被用来描述Docker容器的动态信息的),我们在使用Dockerfile 构建镜像时,Dockerfile 构建过程中涉及到的所有操作基本都被记录到这个json文件中。

说到这儿,有些读者可能会问这个json是在什么阶段被使用到的,好问题。通过下面这个图我想大家应该就能看明白了:

Docker镜像及其相关技术的原理和本质

从图中我们可以看出每个镜像层的json文件其实是由Docker 守护进程进行解析的。Docker 守护进程通过json文件可以解析出运行容器需要的各种数据,比如环境变量、workdir以及容器启动时需要执行的ENTRYPOINT或者CMD命令等。Docker 守护进程从json文件中获取到这些数据后,接下来就开始进行容器进程的初始化。

(3) layersize

从文件名称即可看出,这个文件中存放的为当前镜像层的占用空间大小:

(4) repositories-devicemapper

上一阶段中我们解释过这个文件中记录的为当前镜像层的属性信息,比如镜像名称信息、镜像标签信息、镜像的ID信息等:

Docker镜像及其相关技术的原理和本质

以上是对 pull 镜像之后运行容器之前镜像存储信息的简单介绍,相信大家在看下之后对 docker容器 镜像已经有了更加深入的认识。下面我们看下本文中我们要说的最后一个阶段,即运行容器后 docker 的 存储又发生了哪些变化。

运行容器后:

我们运行下前面从dockerhub pull的镜像nginx:latest:

[root@localhost metadata]# docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE

nginx               latest              c2c9e418b22c        2 weeks ago         109.3 MB

[root@localhost metadata]#

[root@localhost metadata]#

[root@localhost metadata]#

[root@localhost metadata]# docker run --name nginx -d nginx:latest

814ec80839669e235c94978ed3d07eab0e2b2bebd7d7a64fd6488cddca51be41

[root@localhost metadata]# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES

814ec8083966        nginx:latest        "nginx -g 'daemon off"   3 seconds ago       Up 2 seconds        80/tcp              nginx

按照惯例,然后我们看下/var/lib/docker路径下的文件结构:

Docker镜像及其相关技术的原理和本质

和上一阶段不同,这个阶段发生变化的文件主要是:/var/lib/docker/devicemapper/metadata、/var/lib/docker/devicemapper/mnt以及/var/lib/docker/container,下面我们逐个看下。

(1) metadata

我们看下metadata这个目录下的文件:

Docker镜像及其相关技术的原理和本质

从图中的结果可以看出,相比上一个阶段,当前阶段中metadata目录下多出了两个文件,即以51be4e和51b44e-init结尾的两个文件。

我们都知道docker 借助容器镜像运行起容器之后,会在当前镜像的最顶层添加一个特殊的层,和其他的层相比这个层不但有可读的权限还有可写的权限。说到这,相信多出的两个文件的功能就不难理解了。

(2) mnt

在查看mnt下的数据之前,我们先看下这个目录下的文件结构:

对比上面说过的metadata目录,发现这两个目录下的文件是一样的,相比前一个阶段的话也是新增了两个文件,即以51be4e和51b44e-init结尾的两个文件。

(3) container

我们先看下当前目录下的文件结构:

Docker镜像及其相关技术的原理和本质

Container目录为容器本身的目录,此目录中存放了诸如容器的配置文件等文件。如果我们删掉这个目录 ( docker  进程 hang死导致docker rm、docker kill杀不掉 容器时常用此种方式处理 )的 话正在运行的容器就会被删掉,我们看下这几个文件都存放了什么数据。

(1) xxx.json.log、config.json

从文件名称即可看出,这两个文件存放的为当前容器的配置信息及其数据:

Docker镜像及其相关技术的原理和本质

(2) hosts

hosts配置信息,在此不再赘述。

(3) hostname

容器host名称,可以cat查看后再进入容器查看hostname,核对下看是否是一样的。

(4) resolv.conf

dns配置信息。

结 语

前面分析了那么多涉及到docker 存储的文件,在查阅各个文件或者目录作用时可能不是很方便,在此我们给大家总结了一下各个文件的作用(每个文件都是在/var/lib/docker路径下):

(1) devicemapper/devicemapper/data: 存储存储池相关的数据

(2) devicemapper/devicemapper/metdata: 存储元数据

(3) devicemapper/metadata/: 存储device_id、layersize等信息

(4) devicemapper/mnt: 存储挂载相关的信息

(5) container/: 存储容器本身的信息

(6) graph/: 存储各个镜像层的详细信息

(7) repositores-devicemapper: 存储镜像的一些基本信息

(8) tmp: 存储docker的临时目录

(9) trust: 存储docker的信任目录

(10)  volumes: 存储docker的卷目录

推荐阅读

原生Kubernetes监控功能详解-Part1

如何设计一个优秀的分布式系统?重要因素、 工具 、策略都在这里

3类6种,主流容器操作系统全比较

About Rancher Labs

Rancher Labs由硅谷云计算泰斗、CloudStack之父梁胜创建,致力于打造创新的开源软件,帮助企业在生产环境中运行容器与Kubernetes。旗舰产品Rancher是一个开源的企业级Kubernetes平台,是业界首个且唯一可以管理所有云上、所有发行版、所有Kubernetes集群的平台。解决了生产环境中企业用户可能面临的基础设施不同的困境,改善Kubernetes原生UI易用性不佳以及学习曲线陡峭的问题,是企业落地Kubernetes的不二之选。

Rancher在全球拥有超过一亿的下载量,超过20000家企业客户。全球知名企业如中国人寿、华为、中国平安、民生银行、兴业银行、上汽集团、海尔、米其林、天合光能、丰田、本田、霍尼韦尔、金风科技、普华永道、海南航空、厦门航空、恒大人寿、中国太平、巴黎银行、美国银行、HSCIS恒生指数、中国水利、暴雪、CCTV等均是Rancher的付费客户。

Docker镜像及其相关技术的原理和本质

第六期 Kubernetes Master Class 传送门

↓↓↓


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

形式感+:网页视觉设计创意拓展与快速表现

形式感+:网页视觉设计创意拓展与快速表现

晋小彦 / 清华大学出版社 / 2014-1-1 / 59.00元

网页设计师从早年的综合性工作中分化出来,形成了相对独立的专业岗位,网页设计也不再是单纯的软件应用,它衍生出了许多独立的研究方向,当网站策划、交互体验都逐渐独立之后,形式感的突破和表现成为网页视觉设计的一项重要工作。随着时代的发展,网页设计更接近于一门艺术。网络带宽和硬件的发展为网页提供了使用更大图片、动画甚至视频的权利,而这些也为视觉设计师提供了更多表现的空间。另外多终端用户屏幕(主要是各种移动设......一起来看看 《形式感+:网页视觉设计创意拓展与快速表现》 这本书的介绍吧!

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试