一杯茶的时间,上手 Docker

2020-04-20 10:04:04 +08:00
 huan1043269994

我们研发开源了一款基于 Git 进行技术实战教程写作的工具,我们图雀社区的所有教程都是用这款工具写作而成,欢迎 Star

如果你想快速了解如何使用,欢迎阅读我们的 教程文档哦

努力工作,然后进入梦乡,“工作”和“做梦”之间好像没有任何关联;编写代码,然后部署应用,这两者似乎也是天各一边。然而果真如此吗?这篇文章将通过《盗梦空间》的方式打开 Docker,让你实现从“做梦”到“筑梦”的实质性转变。在原先的“做梦”阶段(手动配置和部署),一切都充满了随机性和不可控性,你有时甚至都无法回忆起具体做的每一步;而在“筑梦”阶段(借助 Docker ),你将通过自动化、高度可重复且可追踪的方式轻松实现任何配置和部署任务。希望读完这篇文章的你,也能成为一个优秀的“筑梦师”!

准备工作

写在前面的话

很多朋友跟我们反馈说,“一杯茶”纯粹就是忽悠人,写那么长,怎么可能在一杯茶的时间内看完?实际上,“饮茶”的方式因人而异,不同的读者自有不同的节奏。你完全可以选择一目十行、甚至只浏览一下插图,几分钟的时间便能看完;也可以选择跟着我们一步一步动手实践,甚至在有些地方停下来思考一番,虽然需要花更多的时间,但是我们相信这份投入的时间一定是值得的。

其次,我们想确认你是否是这篇文章的受众:

  1. 如果你已经是每天操纵数以千计容器的 DevOps 大佬,那么很抱歉打扰了,这篇文章对你来说可能过于简单;
  2. 如果你已经比较熟悉 Docker 了,想要更多的实战操作经验,这篇文章能够较好地帮助你复习和巩固关键的知识点;
  3. 如果你只听说过 Docker,但是基本上不会用,那么这篇文章就是为你准备的!只不过友情提醒:Docker 上手略有难度,想要真正掌握需要投入足够的时间,认真读完这篇文章一定能让你有相当大的进步

最后,每个小节的结构都是实战演练 + 回忆与升华。回忆与升华部分是笔者花了不少时间对优质资源进行搜集和整合而成,并结合了自身使用容器的经验,相信能够进一步加深你的理解,如果你赶时间的话,也可以略过哦。

PS:这篇文章并没有像常规的 Docker 教程一样上来就郑重其事地讲 Docker 的背景、概念、优势(很有可能你已经听到耳朵生茧了 hhh ),而是完全通过实践的方式直观地理解 Docker 。在最后,我们还是会贴出经典的 Docker 架构图,结合之前的操作体验,相信你会有了然于胸的感觉。

前提条件

在正式阅读这篇文章之前,我们希望你已经具备以下条件:

我们将实现什么

现在假定你手头已经有了一个 React 编写的“梦想清单”项目,如下面这个动图所示:

我们将在这篇文章中教你一步步用 Docker 将这个应用容器化,用 Nginx 服务器提供构建好的静态页面。

你将学会

这篇文章不会涉及 ...

当然咯,这篇文章作为一篇入门性质的教程,以下进阶内容不会涉及:

以上进阶知识我们会马上推出相关教程,敬请期待。

安装 Docker

我们推荐各个平台用以下方式安装 Docker (经过我们反复测试哦)。

Windows

菜鸟教程中详细介绍了 Win7/8 以及 Win10 的不同推荐安装方法。注意 Win10 建议开启 Hyper-V 虚拟化技术。

macOS

可通过点击官方下载链接下载并安装 DMG 文件(如果速度慢的话可以把链接复制进迅雷哦)。安装完毕之后,点击 Docker 应用图标即可打开。

Linux

对于各大 Linux 发行版( Ubuntu 、CentOS 等等),我们推荐用官方脚本进行安装,方便快捷:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

然后推荐将 docker 的权限移交给非 root 用户,这样使用 docker 就不需要每次都 sudo 了:

sudo usermod -aG docker $USER

注销用户或者重启之后就会生效。然后通过 systemd 服务配置 Docker 开机启动:

sudo systemctl enable docker

配置镜像仓库

默认的镜像仓库 Docker Hub 在国外,国内拉取速度比较感人。建议参考这篇文章配置镜像加速。

镜像与容器:筑梦师的图纸和梦境

镜像( Image )和容器( Container )是 Docker 中最为基础也是最为关键的两个概念,前者就是筑梦师的图纸,根据这张图纸的内容,就能够生成完全可预测的梦境(也就是后者)。

提示

如果你觉得这个比喻难以理解,那么可以通过面向对象编程中“类”( class )和“实例”( instance )这两个概念进行类比,“类”就相当于“镜像”,“实例”就相当于“容器”。

小试牛刀:梦开始的地方

在略微接触了镜像与容器这两个基础概念之后,我们打算暂停理论的讲解,而先来一波小实验让你快速感受一下。

实验一:Hello World!

按照历史惯例,我们运行一下来自 Docker 的 Hello World,命令如下:

docker run hello-world

输出如下:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:fb158b7ad66f4d58aa66c4455858230cd2eab4cdf29b13e5c3628a6bfc2e9f05
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
...

不就打印了一个字符串然后退出吗,有这么神奇?其实 Docker 为我们默默做了以下事情:

  1. 检查本地是否有指定的 hello-world:latest 镜像(latest 是镜像标签,后面会细讲),如果没有,执行第 2 步,否则直接执行第 3 步
  2. 本地没有指定镜像( Unable to find xxx locally ),从 Docker Hub 下载到本地
  3. 根据本地的 hello-world:latest 镜像创建一个新的容器并运行其中的程序
  4. 运行完毕后,容器退出,控制权返回给用户

实验二:运行一个 Nginx 服务器

感觉太简单?我们来尝试一个高级一点的:运行一个 Nginx 服务器。运行以下命令

docker run -p 8080:80 nginx

运行之后,你会发现一直卡住,也没有任何输出,但放心你的电脑并没有死机。让我们打开浏览器访问 localhost:8080

这时候熟悉 Nginx 的朋友可能就坐不住了:就一个简简单单的 docker run 命令,就搞定了 Nginx 服务器的安装和部署??没错,你可以继续访问一些不存在的路由,比如 localhost:8080/what,同样会提示 404 。这时候我们再看 Docker 容器的输出,就有内容(服务器日志)了:

总结一下刚才 Docker 做的事情:

  1. 检查本地是否有指定的 nginx:latest 镜像(关于 latest 标签,后面会细讲),如果没有,执行第 2 步,否则直接执行第 3 步
  2. 本地没有指定镜像( Unable to find xxx locally ),从 Docker Hub 下载到本地
  3. 根据本地的 nginx:latest 镜像创建一个新的容器,并通过 -p--publish)参数建立本机的 8080 端口与容器的 80 端口之间的映射,然后运行其中的程序
  4. Nginx 服务器程序保持运行,容器也不会退出

提示

端口映射规则的格式为 <本机端口>:<容器端口>。Nginx 容器默认开放了 80 端口,我们通过设置 8080:80 的端口映射规则,就可以在本机(容器之外)通过访问 localhost:8080 访问,甚至可以在同一局域网内通过内网 IP 访问,这篇文章的最后会演示哦。

实验三:后台运行 Nginx

看上去很酷,不过像 Nginx 服务器这样的进程我们更希望把它抛到后台一直运行。按 Ctrl + C 退出当前的容器,然后再次运行以下命令:

docker run -p 8080:80 --name my-nginx -d nginx

注意到与之前不同的是,我们:

警告

容器的名称必须是唯一的,如果已经存在同一名称的容器(即使已经不再运行)就会创建失败。如果遇到这种情况,可以删除之前不需要的容器(后面会讲解怎么删除)。

Docker 会输出一串长长的 64 位容器 ID,然后把终端的控制权返回给了我们。我们试着访问 localhost:8080,还能看到那一串熟悉的 Welcome to nginx!,说明服务器真的在后台运行起来了。

那我们怎么管理这个服务器呢?就像熟悉的 UNIX ps 命令一样,docker ps 命令可以让我们查看当前容器的状态:

docker ps

输出结果是这样的:

提示

由于 docker ps 的输出比较宽,如果你觉得结果不直观的话可以把终端(命令行)拉长,如下图所示:

从这张表中,就可以清晰地看到了我们在后台运行的 Nginx 服务器容器的一些信息:

如果我们要让容器停下来,通过 docker stop 命令指定容器名称或 ID 进行操作即可,命令如下:

docker stop my-nginx
# docker stop 0bddac16b8d8

注意

如果指定容器 ID 的话,记得要换成自己机器上真实的 ID 哦。此外,在没有冲突的情况下,ID 可以只写前几位字符,例如写 0bd 也是可以的。

实验四:交互式运行

在过了一把 Nginx 服务器的瘾之后,我们再来体验一下 Docker 容器的另一种打开方式:交互式运行。运行以下命令,让我们进入到一个 Ubuntu 镜像中:

docker run -it --name dreamland ubuntu

可以看到我们加了 -it 选项,等于是同时指定 -i--interactive,交互式模式)和 -t--tty,分配一个模拟终端) 两个选项。以上命令的输出如下:

Unable to find image 'ubuntu:latest' locally
latest: Pulling from library/ubuntu
2746a4a261c9: Pull complete
4c1d20cdee96: Pull complete
0d3160e1d0de: Pull complete
c8e37668deea: Pull complete
Digest: sha256:9207fc49baba2e62841d610598cb2d3107ada610acd4f47252faf73ed4026480
Status: Downloaded newer image for ubuntu:latest
root@94279dbf5d93:/#

等下,我们怎么被抛在了一个新的命令行里面?没错,你现在已经在这个 Ubuntu 镜像构筑的“梦境”之中,你可以随意地“游走”,运行一些命令:

root@94279dbf5d93:/# whoami
root
root@94279dbf5d93:/# ls
bin   dev  home  lib64  mnt  proc  run   srv  tmp  var
boot  etc  lib   media  opt  root  sbin  sys  usr

例如我们在上面运行了 whoamils 命令,你基本上可以确定现在已经在“梦境”(容器)之中了。这时候打开一个新的终端(命令行),运行 docker ps 命令,就可以看到正在运行中的 Ubuntu 镜像:

回到之前的容器中,按 Ctrl + D (或者输入 exit 命令)即可退出。你可以在之前查看 docker ps 的终端再次检查容器是否已经被关闭了。

销毁容器:听梦碎的声音

筑梦师难免会有失败的作品,而我们刚才创建的 Docker 容器也只是用于初步探索,后续不会再用到。由于 Docker 容器是直接存储在我们本地硬盘上的,及时清理容器也能够让我们的硬盘压力小一些。我们可以通过以下命令查看所有容器(包括已经停止的):

docker ps -a

-a--all)用于显示所有容器,如果不加的话只会显示运行中的容器。可以看到输出如下(这里我把终端拉宽了,方便你看):

提示

你也许观察到,之前的实验一和实验二中我们没有指定容器名称,Docker 为我们取了颇为有趣的默认容器名称(比如 hardcore_nash),格式是一个随机的形容词加上一位著名科学家 /程序员的姓氏(运气好的话,你可能会看到 Linux 之父 torvalds 哦)。

类似 Shell 中的 rm 命令,我们可以通过 docker rm 命令销毁容器,例如删除我们之前创建的 dreamland 容器:

docker rm dreamland
# 或者指定容器 ID,记得替换成自己机器上的
# docker rm 94279dbf5d93

但如果我们想要销毁所有容器怎么办?一次次输入 docker rm 删除显然不方便,可以通过以下命令轻松删除所有容器

docker rm $(docker ps -aq)

docker ps -aq 会输出所有容器的 ID,然后作为参数传给 docker rm 命令,就可以根据 ID 删除所有容器啦。

危险!

执行之前一定要仔细检查是否还有有价值的容器(特别是业务数据),因为容器一旦删除无法再找回(这里不讨论硬盘恢复这种黑科技)!

回忆与升华

关于端口映射

可能有些同学还是没有完全理解“端口映射”的概念,以 8080:80 这一条映射规则为例,我们可以用“传送门”的比喻来理解(下面的图是《传送门 2 》游戏的封面):

还是把容器比作“梦境”,把本机环境比作“现实”,通过建立端口映射,访问本机的 8080 端口的请求就会被“传送”到容器的 80 端口,是不是很神奇呢。

容器生命周期:梦境地图

跟着做完上面四个小实验之后,你或许已经对 Docker 容器有了非常直观的感受和理解了。是时候祭出这张十( sang )分( xin )经( bing )典( kuang )的 Docker 容器生命周期图了(来源: https://docker-saigon.github.io/post/Docker-Internals/):

这张图乍一看颇具视觉冲击力,甚至会让你感觉不知所措。没事,我们大致地解读这张图里面的四类元素:

  1. 容器状态(带颜色的圆圈):包括已创建( Created )、运行中( Running )、已暂停( Paused )、已停止( Stopped )以及被删除( Deleted )
  2. Docker 命令(箭头上以 docker 开头的文字):包括 docker rundocker createdocker stop 等等
  3. 事件(矩形框):包括 createstartdiestop 还有 OOM(内存耗尽)等等
  4. 还有一个条件判断,根据重启策略( Restart Policy )判断是否需要重新启动容器

OK,这张图还是很难一下子理解,不过还记得刚才我们做的四个小实验吗?我们实际上走了一共两条路径(也是日常使用中走的最多的路),接下来将一一进行分析。

第一条路径(自然结束)

如上图所示:

第二条路径(强制结束)

提示

有些眼尖的读者可能发现 docker killdocker stop 的功能非常相似,它们之前存在细微的区别: kill 命令向容器内运行的程序直接发出 SIGKILL 信号(或其他指定信号),而 stop 则是先发出 SIGTERM 再发出 SIGKILL 信号,属于优雅关闭( Graceful Shutdown )。

一条捷径:删除运行中的容器

生命周期图其实有一条捷径没有画出来:直接从运行中(或暂停中)到被删除,通过给 docker rm 命令加上选项 -f--force,强制执行)就可以实现:

# 假设 dreamland 还在运行中
docker rm -f dreamland

同样地,我们可以删除所有容器,无论处于什么状态:

docker rm -f $(docker ps -aq)

自由探索

你尽可以自由探索其他我们没走过的路线,例如尝试再次启动之前已经停止的容器(docker start),或者暂停正在运行的容器(docker pause)。幸运的是,docker help 命令可以为我们提供探索的指南针,例如我们想了解 start 命令的使用方法:

$ docker help start

Usage:	docker start [OPTIONS] CONTAINER [CONTAINER...]

Start one or more stopped containers

Options:
  -a, --attach                  Attach STDOUT/STDERR and forward signals
      --checkpoint string       Restore from this checkpoint
      --checkpoint-dir string   Use a custom checkpoint storage directory
      --detach-keys string      Override the key sequence for
                                detaching a container
  -i, --interactive             Attach container's STDIN

读到这里,相信你已经了解了如何利用现有的镜像创造容器,并进行管理。在接下来,我们将带你创建自己的 Docker 镜像,开始成为一名标准的“筑梦师”!

容器化第一个应用:开启筑梦之旅

在之前的步骤中,我们体验了别人为我们提前准备好的镜像(例如 hello-worldnginxubuntu),这些镜像都可以在 Docker Hub 镜像仓库中找到。在这一步,我们将开始筑梦之旅:学习如何容器化( Containerization )你的应用。

正如开头所说,我们将容器化一个全栈的”梦想清单“应用,运行以下命令来获取代码,然后进入项目:

git clone -b start-point https://github.com/tuture-dev/docker-dream.git
cd docker-dream

在这一步中,我们将容器化这个用 React 编写的前端应用,用 Nginx 来提供前端页面的访问。

什么是容器化

容器化包括三个阶段:

构建镜像

构建 Docker 镜像主要包括两种方式:

  1. 手动:根据现有的镜像创建并运行一个容器,进入其中进行修改,然后运行 docker commit 命令根据修改后的容器创建新的镜像
  2. 自动:创建 Dockerfile 文件,指定构建镜像的命令,然后通过 docker build 命令直接创建镜像

由于篇幅有限,这篇文章只会讲解使用最为广泛的第二种创建镜像的方式。

一些准备工作

我们先把前端项目 client 构建成一个静态页面。确保你的机器上已经安装 Node 和 npm (点击这里下载,或使用 nvm),然后进入到 client 目录下,安装所有依赖,并构建项目:

cd client
npm install
npm run build

等待一阵子后,你应该可以看到 client/build 目录,存放了我们要展示的前端静态页面。

创建 Nginx 配置文件 client/config/nginx.conf,代码如下:

server {
    listen 80;
    root /www;
    index index.html;
    sendfile on;
    sendfile_max_chunk 1M;
    tcp_nopush on;
    gzip_static on;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

不熟悉 Nginx 配置的同学不用担心哦,直接复制粘贴就可以了。上面的配置大致意思是:监听 80 端口,网页根目录在 /www,首页文件是 index.html,如果访问 / 则提供文件 index.html

创建 Dockerfile

然后就是这一步骤中最重要的代码:Dockerfile !创建 client/Dockerfile 文件,代码如下:

FROM nginx:1.13

# 删除 Nginx 的默认配置
RUN rm /etc/nginx/conf.d/default.conf

# 添加自定义 Nginx 配置
COPY config/nginx.conf /etc/nginx/conf.d/

# 将前端静态文件拷贝到容器的 /www 目录下
COPY build /www

可以看到我们用了 Dockerfile 中的三个指令:

是时候来构建我们的镜像了,运行以下命令:

# 如果你已经在 client 目录中
#(注意最后面有个点,代表当前目录)
docker build -t dream-client .

# 如果你回到了项目根目录
docker build -t dream-client client

可以看到我们指定了 -t--tag,容器标签)为 dream-client,最后指定了构建容器的上下文目录(也就是 当前目录 .client)。

运行以上的命令之后,你会发现:

Sending build context to Docker daemon:66.6MB

而且这个数字还在不断变大,就像黑客科幻电影中的场景一样,最后应该停在了 290MB 左右。接着运行了一系列的 Step ( 4 个),然后提示镜像构建成功。

为啥这个构建上下文( Build Context )这么大?因为我们把比“黑洞”还“重”的 node_modules 也加进去了!(忍不住想起了下面这张图)

使用 .dockerignore 忽略不需要的文件

Docker 提供了类似 .gitignore 的机制,让我们可以在构建镜像时忽略特定的文件或目录。创建 client/.dockerignore 文件(注意 dockerignore 前面有一个点):

node_modules

很简单,我们只想忽略掉可怕的 node_modules 。再次运行构建命令:

docker build -t dream-client .

太好了!这次只有 1.386MB,而且速度也明显快了很多!

运行容器

终于到了容器化的最后一步——创建并运行我们的容器!通过以下命令运行刚才创建的 dream-client 镜像:

docker run -p 8080:80 --name client -d dream-client

与之前类似,我们还是设定端口映射规则为 8080:80,容器名称为 client,并且通过 -d 设置为后台运行。然后访问 localhost:8080

成功了!一开始定下的三个梦想也都完成了!

提示

甚至,我们已经可以通过内网来访问“梦想清单”了。Linux 或 macOS 的同学可以在终端输入 ifconfig 命令查询本机内网 IP,Windows 的同学则是在 CMD 输入 ipconfig 查询本机内网 IP,一般是以 10172.16~172.31192.168 开头。例如我的内网 IP 是 192.168.0.2,那么在同一局域网下(一般是 WiFi ),可以用其他设备(比如说你的手机)访问 192.168.0.2:8080

回忆与升华

关于镜像标签

在刚才的实战中,你也许已经注意到在拉取和构建镜像时,Docker 总是会为我们加上一个 :latest 标签,这个 :latest 的含义便是“最新”的意思。和软件的版本机制一样,镜像也可以通过标签实现“版本化”。

注意

latest 字面上的意思的确是“最新的”,但也只是一个普通的标签,并不能确保真的是“最新的”,更不会自动更新。更多讨论请参考这篇文章

实际上,我们完全可以在拉取或构建镜像时指定标签(通常被认为是一种好的做法):

docker pull nginx:1.13
docker build -t dream-client:1.0.0

还可以给现有的镜像打上标签:

# 把默认的 latest 镜像打上一个 newest 标签
docker tag dream-client dream-client:newest
# 甚至可以同时修改镜像的名称和标签
docker tag dream-client:1.0.0 dream-client2:latest

可以看到,标签未必一定是版本,还可以是任何字符串(当然最好要有意义,否则过了一阵子你也不记得这个打了这个标签的容器有什么作用了)。

关于 Dockerfile

Dockerfile 实际上是默认名称,我们当然可以取一个别的名字,例如 myDockerfile,然后在构建镜像时指定 -f--file)参数即可:

docker build -f myDockerfile -t dream-client .

这里举两个经典的使用场景:

  1. 例如在 Web 开发时,分别创建 Dockerfile.dev 用于构建开发镜像,创建 Dockerfile.prod 构建生产环境下的镜像;
  2. 在训练 AI 模型时,创建 Dockerfile.cpu 用于构建用 CPU 训练的镜像,创建 Dockerfile.gpu 构建用 GPU 训练的镜像。

再次思考镜像和容器的关系

经过刚才的容器化实战,相信你对镜像和容器的关系又有了新的理解。请看下面这张图:

在之前的“小试牛刀”环节中(用绿色箭头标出),我们:

  1. 通过 docker pull 从 Docker 镜像仓库拉取镜像到本地
  2. 通过 docker run 命令,根据镜像创建并运行容器
  3. 通过 docker stop 等命令操作容器,使其发生各种状态转变

而在这一节的容器化实战中(用红色箭头标出),我们:

  1. 通过 docker build 命令,根据一个 Dockerfile 文件构建镜像
  2. 通过 docker tag 命令,给镜像打上标签,得到一个新镜像
  3. (由于篇幅有限没有讲)通过 docker commit 命令,将一个现有的容器转化为镜像

俯瞰全景:Docker 架构图

是时候拿出经典的 Docker 架构图了:

可以看到,Docker 遵循经典的客户端-服务器架构( client-server ),核心组成部分包括:

至此,这篇 Docker 快速入门实战教程也就结束啦,希望你已经对 Docker 的概念和使用有了初步的理解。后续我们还会发表 Docker 进阶的内容(例如 Network 网络、Volume 数据卷、Docker Compose 等等),手把手带大家部署一个全栈应用(前后端和数据库)到云主机(或任何你能够登录的机器),敬请期待~

想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。

8914 次点击
所在节点    Docker
29 条回复
whileFalse
2020-04-20 11:12:28 +08:00
你这一杯茶得喝两小时吧。
WilsonGGG
2020-04-20 11:17:00 +08:00
Anarchy
2020-04-20 11:18:39 +08:00
这时间得一壶茶吧
zooo
2020-04-20 11:23:08 +08:00
一桶茶吧
Lin0936
2020-04-20 11:26:00 +08:00
我冲了一杯茶, 喝完了, 才把“准备工作”看完...
CharmanderS5
2020-04-20 11:26:35 +08:00
一箱茶吧
scyuns
2020-04-20 11:36:42 +08:00
我喝了一天的茶终于看懂了
ljpCN
2020-04-20 11:37:40 +08:00
什么杯子这么大?我去工地上需要。
leibuting
2020-04-20 11:55:28 +08:00
我现在决定出门去喜茶门口排队买杯奶茶,一杯奶茶的时间应该是够了
leonme
2020-04-20 12:00:59 +08:00
建议只给个链接,论坛不适合这样的长文
Sanko
2020-04-20 12:48:20 +08:00
真好
tyhunter
2020-04-20 14:20:27 +08:00
请教下大佬,docker 内如何添加可信任根证书,找了篇 ubuntu 的教程,发现只改宿主机的证书是无效的
https://yaxin-cn.github.io/Linux/add-root-certificate-in-ubuntu.html
tt67wq
2020-04-20 14:30:35 +08:00
老茶学家了
hjdtl
2020-04-20 14:41:47 +08:00
深入浅出,资瓷一下
jrtzxh020
2020-04-20 16:20:27 +08:00
通俗易懂,很好的文章啊~
holystrike
2020-04-20 16:31:36 +08:00
来,给楼主倒一杯卡布奇诺,开始你的 docker 秀
tabris17
2020-04-20 16:32:37 +08:00
这得一瓶 1.6 升的乌龙茶才行了
zhjie
2020-04-20 16:51:56 +08:00
我收藏了,一秒关了页面,有什么好说的?
gqbre
2020-04-20 17:31:01 +08:00
写的不错,资瓷一下
Norie
2020-04-20 20:53:37 +08:00
功夫茶

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/664154

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX