麋鹿与鲸鱼
type: channel
link: https://elkwhale.com/
description: 林深时见鹿，海蓝时见鲸
items: 当多个容器需要协同：用 Docker Compose 一键拉起整个应用
items: 多容器协作实战：网络、数据共享与依赖管理
items: 在 Docker 里用 pm2：让 Node 应用更稳健
items: Docker 背后的秘密：Namespace、CGroup 和 UnionFS 是怎么工作的？
items: Dockerfile 高级玩法：ARG、ENV、CMD、ENTRYPOINT、ADD
items: 自己动手写 Dockerfile：构建你的第一个镜像
items: Docker 入门：镜像、容器与 Desktop
items: 数据库操作：mysql2（直接 SQL） vs TypeORM（ORM 方式）
items: 文件上传：使用 multer 和 @nestjs/platform-express 轻松搞定
items: 内置 Pipe 与自定义 Pipe：参数校验和转换的利器

当多个容器需要协同：用 Docker Compose 一键拉起整个应用
type: item
date: 2024-05-31T22:57:00+08:00
description: 当多个容器需要协同：用 Docker Compose 一键拉起整个应用前面几篇我们聊了 Docker 的基础、Dockerfile 的写法、pm2 在容器里的应用，可以说已经能熟练地把单个应用打包成镜像并跑起来了。但在实际开发中，一个完整的应用往往不止一个服务——前端可能需要一个 Nginx 容器来 serve 静态文件，后端可能需要一个 Node 容器跑 API，再加上数据库 MySQL 或 Redis，甚至还有消息队列。如果每次都要手动敲 docker run 启动这几个容器，还得操心网络怎么连通、数据卷怎么挂载、启动顺序怎么保证，那可就太折腾了。这时候就该 Docker Compose 登场了。它就是一个“一键启动全家桶”的工具，让你用一个 YAML 文件描述整个应用的服务、网络、数据卷，然后一个命令就能把所有容器拉起来。今天我们就来聊聊 Docker Compose 怎么用，以及它如何让多容器环境的管理变得轻松愉快。Docker Compose 是什么？Docker Compose 是 Docker 官方推出的容器编排工具，专门用来定义和运行多个容器的应用。你只需要写一个 docker-compose.yml 文件，在里面声明有哪些服务（比如 web、db、redis），每个服务用哪个镜像、暴露哪些端口、挂载哪些卷、依赖哪些其他服务，然后执行 docker-compose up，Compose 就会按照依赖顺序启动所有容器，并自动创建一个默认的网络让它们可以互相通信。它最适合用在开发环境和测试环境，也能用于单机的生产环境（虽然生产环境更推荐用 Kubernetes 做集群编排，但 Compose 在简单场景下完全够用）。安装与准备如果你已经安装了 Docker Desktop（Windows/Mac），Compose 是自带的，直接在终端输入 docker-compose --version 就能看到版本。Linux 用户可能需要单独安装，但也很简单，去官网下二进制文件就行。我们假设现在要搭建一个简单的应用：一个 Node.js 写的 Web 服务，加上一个 Redis 来做缓存。两个服务各跑一个容器，Node 服务需要把代码挂载到容器里方便开发时热更新，Redis 需要把数据持久化到宿主机。写一个 docker-compose.yml 文件在项目根目录新建一个文件，名字必须是 docker-compose.yml（或 .yaml）。内容如下：version: '3.8' services: web: build: . # 使用当前目录的 Dockerfile 构建镜像 ports: - "3000:3000" # 宿主机 3000 映射到容器 3000 volumes: - .:/app # 挂载当前目录到容器 /app，实现代码热更新 - /app/node_modules # 匿名卷，避免覆盖容器内的 node_modules environment: - NODE_ENV=development - REDIS_HOST=redis # 服务名就是 hostname，Compose 会自动做 DNS 解析 depends_on: - redis # 确保 redis 先启动 redis: image: redis:alpine ports: - "6379:6379" volumes: - redis_data:/data # 使用命名卷持久化数据 volumes: redis_data: # 声明一个命名卷解释一下关键部分：version：指定 Compose 文件格式的版本，一般用最新的 '3.8' 或 '3'。services：定义要启动的服务。每个服务都是一个容器。web：我们的 Node 应用。build: .：告诉 Compose 用当前目录下的 Dockerfile 构建镜像，而不是直接拉取现成镜像。这样适合开发时频繁修改代码。ports：端口映射，和 docker run -p 一样。volumes：卷挂载。这里把当前目录挂载到容器的 /app，这样本地修改代码容器内立即生效，不用重新构建。同时专门加了一个 /app/node_modules 的匿名卷，防止本地的 node_modules（可能没有或不同平台）覆盖掉容器里安装好的依赖。environment：环境变量，可以在 Node 里通过 process.env.REDIS_HOST 拿到 Redis 的地址。注意这里直接写了 redis，因为 Compose 会为每个服务创建一个可解析的 hostname，服务名就是 hostname。depends_on：指定依赖关系，确保 redis 服务先启动。redis：缓存服务。image：直接…

多容器协作实战：网络、数据共享与依赖管理
type: item
date: 2024-05-31T21:01:00+08:00
description: 多容器协作实战：网络、数据共享与依赖管理上一期我们介绍了 Docker Compose，知道了它能用一个文件定义多个服务，一键启动整个应用。但在实际开发中，多容器协作不只是“一起跑起来”那么简单，还有很多细节需要考虑：容器之间怎么通信才安全高效？数据怎么共享？如果一个服务依赖另一个，如何保证它启动时依赖已经就绪？今天咱们就深入这些话题，通过一个具体的例子，看看多容器协作中的常见问题和最佳实践。场景设定：我们要搭一个博客系统假设我们要用 Ghost（一个基于 Node.js 的开源博客平台）和 MySQL 数据库，再配一个 Nginx 做反向代理。这就有三个服务：ghost、mysql、nginx。它们需要协作：ghost 连接 mysql 存数据；nginx 把请求转发给 ghost，同时可能 serve 静态文件；所有服务的日志、配置文件、上传的文件可能需要持久化或共享。下面我们就用 Compose 来定义它们，并逐步解决协作中的问题。第一步：基础 Compose 文件先写一个初版 docker-compose.yml：version: '3.8' services: mysql: image: mysql:8.0 container_name: ghost_mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: ghost MYSQL_USER: ghost MYSQL_PASSWORD: ghostpass volumes: - mysql_data:/var/lib/mysql ports: - "3306:3306" # 方便本地客户端连接，生产环境可以不暴露 ghost: image: ghost:5-alpine container_name: ghost_blog restart: unless-stopped depends_on: - mysql environment: database__client: mysql database__connection__host: mysql database__connection__user: ghost database__connection__password: ghostpass database__connection__database: ghost url: http://localhost:8080 # 后续会用 Nginx 代理，这里暂时写本地 volumes: - ghost_content:/var/lib/ghost/content ports: - "2368:2368" # Ghost 默认端口 nginx: image: nginx:alpine container_name: ghost_nginx restart: unless-stopped depends_on: - ghost ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ghost_content:/var/www/ghost/content # 共享 Ghost 的 content 目录 networks: - frontend - backend volumes: mysql_data: ghost_content:这个文件定义了三服务，用了两个命名卷：mysql_data 存数据库文件，ghost_content 存 Ghost 上传的图片、主题等。nginx 额外挂载了本地的 nginx.conf 配置文件，并且把 ghost_content 卷也挂载到自己目录下，这样 Nginx 可以直接 serve 静态资源。容器网络：服务名就是 hostname注意 ghost 的环境变量里，database__connection__host: mysql，这里直接用了服务名 mysql。这是因为 Compose 会自动创建一个默认网络，并把所有服务加入其中，同时 DNS 会把服务名解析成对应容器的 IP。这就是服务发现的基础——你不需要手动写死 IP。但有时候你可能希望控制网络拓扑，比如把 Nginx 放在一个对外网络（frontend），数据库放在内部网络（backend），只让 ghost 能访问两者。上面例子中我们在 nginx 服务下加了 networks 定义，但在顶层还没定义网络。完善一下：networks: frontend: backend: services: mysql: ... network…

在 Docker 里用 pm2：让 Node 应用更稳健
type: item
date: 2024-05-12T08:54:00+08:00
description: 在 Docker 里用 pm2：让 Node 应用更稳健前面几篇我们把 Docker 的基础、Dockerfile 的玩法、甚至底层原理都捋了一遍，感觉对容器的掌控力越来越强了。但有一个问题不知道你想过没有：容器里的 Node 应用如果崩溃了怎么办？或者你想利用多核 CPU 跑多个进程提升性能？又或者你想实时查看日志、监控应用状态？这时候就该 pm2 登场了。pm2 是一个 Node.js 的进程管理工具，它能帮你把应用跑起来，挂了自动重启，还能做负载均衡、日志管理、性能监控。在服务器上部署 Node 应用，pm2 几乎是标配。那问题来了：我们已经在 Docker 里跑 Node 了，还需要 pm2 吗？如果需要，怎么把它俩结合起来？今天就来聊聊这个话题。为什么要在容器里用 pm2？你可能会想：Docker 本身不是有重启策略吗？比如 --restart=always，容器挂了会自动重启，那还要 pm2 干嘛？问得好。Docker 的重启策略是针对整个容器的，如果容器内的进程崩溃了，容器也就退出了，Docker 会帮你重启容器。但有时候，进程崩溃可能只是瞬间的事，比如 Node 抛了个未捕获的异常，导致进程退出。如果让 Docker 重启整个容器，那重建容器的开销比单纯重启进程要大，而且容器内的其他进程（如果有）也会被重启。pm2 是在容器内部工作的，它只负责重启 Node 进程，容器本身还活着。这样重启速度更快，而且可以保留其他状态（比如已有的数据库连接池）。另外 pm2 还提供了很多容器没有的功能：比如日志轮转、集群模式（利用多核）、性能监控、API 管理等。所以，把 pm2 放进容器，相当于给 Node 应用加了一层更精细化的保障。pm2 的基本功能速览先简单过一下 pm2 常用的几个功能，方便你理解后面在 Docker 里的用法：进程管理：pm2 start app.js 启动应用，pm2 stop/restart/delete 管理进程。如果进程意外退出，pm2 会自动重启它。日志管理：pm2 logs 可以实时查看所有进程的日志，日志会自动写文件，还支持日志轮转（防止磁盘爆满）。负载均衡：pm2 start app.js -i max 可以启动多个进程（利用多核 CPU），pm2 会自动做负载均衡。性能监控：pm2 monit 可以看 CPU、内存使用情况，pm2 list 查看进程状态。配置文件：通过 ecosystem.config.js 可以定义多个应用、环境变量、启动参数等，然后 pm2 start ecosystem.config.js 批量启动。在 Dockerfile 里集成 pm2既然要在容器里用 pm2，首先得把 pm2 装进镜像。通常有两种方式：全局安装 pm2，然后通过 pm2 启动 Node 应用。但这里有个细节：官方建议使用 pm2-runtime，它是专门为容器环境优化的版本，默认将日志输出到 stdout（这样 Docker 就能捕获日志），并且能正确处理 SIGTERM 信号（让容器优雅关闭）。来看一个 Dockerfile 的例子：FROM node:18-alpine # 安装 pm2 RUN npm install -g pm2 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . # 如果用了 ecosystem 配置文件，也可以复制过去 COPY ecosystem.config.js . EXPOSE 3000 # 使用 pm2-runtime 启动应用 CMD ["pm2-runtime", "start", "ecosystem.config.js"]这里我们用 pm2-runtime 替代了直接 node app.js。pm2-runtime 会把 pm2 的日志转发到容器的 stdout，这样你用 docker logs 就能看到应用的输出。而且当容器收到停止信号时，pm2-runtime 会让所有子进程优雅退出。编写 ecosystem.config.js如果你有多个应用或者需要配置环境变量、进程数量，用 ecosystem 文件更方便。例如：module.exports = { apps: [{ name: 'my-app', script: './server.js', instances: 'max', // 根据 CPU 核心数启动多个进程 exec_mode: 'cluster', // 集群模式，实现负载均衡 env: { NODE_ENV: 'production', PORT: 3000 }, log_date_format: 'YYYY-MM-DD HH:mm:ss', erro…

Docker 背后的秘密：Namespace、CGroup 和 UnionFS 是怎么工作的？
type: item
date: 2024-04-27T11:51:00+08:00
description: Docker 背后的秘密：Namespace、CGroup 和 UnionFS 是怎么工作的？用了这么久的 Docker，你可能会好奇：它到底是怎么做到“隔离”和“轻量”的？为什么一个容器看起来像一台独立的机器，却又能和宿主机共享内核？今天咱们就来揭开 Docker 的实现原理，聊聊 Linux 底层的三大核心技术：Namespace（命名空间）、Control Group（控制组） 和 UnionFS（联合文件系统）。懂了这些，你对容器的理解会上一个新台阶。Namespace：让进程“看”不到外面想象一下，你在一间屋子里，透过窗户只能看到院子里的景色，却看不到外面的街道。这就是 Namespace 的作用——它让容器里的进程只能“看到”一部分系统资源，仿佛自己独占了一台机器。Linux 内核提供了多种 Namespace，每种负责隔离一类资源：PID Namespace：隔离进程 ID。容器里的进程看到的 PID 是从 1 开始的（比如 init 进程），但实际上它在宿主机上可能是一个很大的 PID。Network Namespace：隔离网络设备、IP 地址、端口等。每个容器有自己的虚拟网卡、路由表，所以可以拥有独立的 IP 和端口空间。Mount Namespace：隔离文件系统挂载点。容器只能看到自己的文件系统，看不到宿主机的其他目录。UTS Namespace：隔离主机名和域名。容器可以有自己的 hostname。IPC Namespace：隔离进程间通信资源，比如消息队列。User Namespace：隔离用户和用户组。容器里的 root 不一定等同于宿主机的 root，增强了安全性。当 Docker 启动一个容器时，它会为这个容器创建一组新的 Namespace，然后让容器内的进程加入这些 Namespace。这样一来，进程就以为自己在独立的世界里运行，和其他容器、宿主机互不干扰。Control Group：给容器戴上“紧箍咒”有了 Namespace，容器之间互不看见了，但如果某个容器疯狂占用 CPU、内存，会不会把宿主机搞垮？会的。所以我们需要限制容器的资源使用，这就是 Control Group（cgroups） 的工作。cgroups 可以限制、记录、隔离进程组使用的物理资源（CPU、内存、磁盘 I/O、网络带宽等）。Docker 在启动容器时，会为容器创建对应的 cgroup，并设置限制参数。比如你可以通过 --cpus=1.5 限制容器最多使用 1.5 个 CPU 核心，通过 --memory=512m 限制内存最多 512MB。一旦容器内的进程试图超限使用，cgroups 就会出手干预，比如限制 CPU 时间、触发 OOM（内存溢出）杀死进程。这样就能保证宿主机和其他容器的稳定性。UnionFS：镜像分层的魔法我们之前聊过 Dockerfile 的每一行指令都会生成一个镜像层，最终叠加成一个完整的镜像。这个“叠加”靠的就是 UnionFS（联合文件系统），它允许将多个目录（或文件系统）挂载到同一个挂载点，看起来就像是一个目录。Docker 常用的 UnionFS 实现有 AUFS、overlay2、devicemapper 等。以 overlay2 为例，它把镜像层（只读层）和容器层（读写层）叠加在一起：镜像层：只读，多个镜像可以共享相同的层，节省磁盘空间。容器层：可读写，容器运行时产生的文件修改都写在这一层。当你删除容器时，这一层也被丢弃，但镜像层依然保留。这就是为什么从同一个镜像启动多个容器，它们各自有独立的可写层，互不影响。而当你提交一个新镜像（docker commit）时，就是把当前容器的可写层保存为一个新的镜像层。数据卷：绕过 UnionFS 的持久化容器层是临时的，容器删除后数据就没了。那需要持久化的数据怎么办？Docker 提供了 数据卷（Volume） 的概念。数据卷是直接挂载到容器内的宿主机目录（或专用存储），它绕过 UnionFS，不受容器生命周期影响。即使容器删除，数据卷里的数据依然存在。你可以把数据卷想象成一条管道，直接连接到宿主机硬盘的某个位置，容器读写那里就像读写本地文件，但数据实际在宿主机上。这就是为什么挂载数据卷能实现数据持久化。镜像层的具体结构我们来看一个实际例子：假设你的 Dockerfile 有三条指令：FROM ubuntu、RUN apt-get update、COPY app /app。构建时会生成三个镜像层：第一层：ubuntu 基础镜像层第二层：apt-get update 产生的层（包含更新的包索引）第三层：COPY 产生的层（包含 app 文件）当你运行容器时，Docker 会在这些只读层之上添加一个可写层（容器层）。如果你在容器里修改了 /ap…

Dockerfile 高级玩法：ARG、ENV、CMD、ENTRYPOINT、ADD
type: item
date: 2024-04-12T15:01:00+08:00
description: Dockerfile 高级玩法：ARG、ENV、CMD、ENTRYPOINT、ADD上一篇我们动手写了第一个 Dockerfile，从基础镜像到 COPY 文件、安装依赖，跑起来一个静态服务。但一个真正好用的 Dockerfile 不止于此，它还得够灵活、够精简、够安全。今天咱们就来聊聊那些能让 Dockerfile 更“聪明”的指令和技巧，包括 ARG、ENV、CMD 与 ENTRYPOINT 的配合、ADD 与 COPY 的区别，让你的镜像构建水平再上一个台阶。ARG：构建时的“临时变量”有时候你希望在构建镜像时能传一些参数，比如版本号、编译开关，而不是把这些值硬编码在 Dockerfile 里。这时候就用到了 ARG。ARG 定义了一个可以在构建时传入的变量，默认值可有可无。举个例子：FROM node:18-alpine ARG APP_VERSION=1.0.0 RUN echo "Building version $APP_VERSION"构建时你可以这样传参：docker build --build-arg APP_VERSION=2.1.3 -t myapp .这样在构建过程中就可以根据传入的值执行不同的逻辑，比如下载对应版本的源码包。ARG 只在构建过程中有效，容器运行时是访问不到的（除非你用 ENV 把它存下来）。ENV：运行时的环境变量如果说 ARG 是给构建过程用的，那 ENV 就是给容器运行时用的。它设置的环境变量在容器启动后可以被应用读取。ENV NODE_ENV=production这样在容器里，你用 process.env.NODE_ENV 就能拿到 production。而且 ENV 还可以结合 ARG 来动态设置：ARG DEFAULT_PORT=3000 ENV PORT=$DEFAULT_PORT构建时你可以改变 ARG，从而影响 ENV 的值。这样既保证了构建的灵活性，又让运行时能拿到正确的配置。CMD 与 ENTRYPOINT：一个给默认值，一个定主命令这两个指令都跟容器启动时的命令有关，但分工不同。新手容易混淆，咱们来捋清楚。ENTRYPOINT：定义容器启动时要执行的“主命令”，且不能被 docker run 后面的命令覆盖。如果你想容器只能运行某个固定的程序，就用它。CMD：给 ENTRYPOINT 提供默认参数，或者直接指定要运行的命令（如果没有 ENTRYPOINT）。CMD 可以被 docker run 后面的命令覆盖。最常见的组合是：ENTRYPOINT 写固定的可执行文件，CMD 写默认的参数。这样用户可以在运行时通过追加参数来覆盖默认值。看个例子：FROM alpine ENTRYPOINT ["ping"] CMD ["localhost"]构建后运行：docker run myping # 会 ping localhost docker run myping google.com # 会 ping google.com，因为 CMD 被覆盖了如果你既想固定命令，又想让用户能加参数，这种组合就非常实用。如果只有一个 CMD 而没有 ENTRYPOINT，那 CMD 就是完整的命令，可以被完全替换。如果只有一个 ENTRYPOINT 而没有 CMD，那容器启动只会执行 ENTRYPOINT，不能加额外参数。ADD 与 COPY：复制文件的两种姿势平时我们最常用的是 COPY，它就是把本地文件复制到镜像里，简单直接。而 ADD 除了复制，还有两个隐藏技能：自动解压 tar 文件、支持远程 URL 下载。比如你有一个压缩包 app.tar.gz，用 ADD 复制到镜像里，它会自动解压：ADD app.tar.gz /app/而 COPY 只会把压缩包原样复制过去。另外 ADD 可以直接从 URL 下载文件：ADD https://example.com/file.tar.gz /tmp/但这里有个坑：ADD 下载的 tar 包不会自动解压，而且由于网络不稳定可能导致构建失败，一般建议用 RUN wget 或 curl 代替，更可控。所以最佳实践是：大多数情况下用 COPY，只有当你明确需要自动解压本地 tar 包时再用 ADD。把这些技巧串起来我们来看一个综合一点的 Dockerfile，它用到了 ARG、ENV、ENTRYPOINT 和 CMD，还做了多阶段构建：# 构建阶段 FROM node:18-alpine AS builder ARG APP_VERSION WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN echo "Building version $APP_VERSION" …

自己动手写 Dockerfile：构建你的第一个镜像
type: item
date: 2024-03-21T19:12:00+08:00
description: 自己动手写 Dockerfile：构建你的第一个镜像Dockerfile 长啥样？Dockerfile 其实就是一个文本文件，里面一条一条指令，告诉 Docker 怎么一步步构建你的镜像。来看一个最简单的例子，我们要做一个能提供静态文件的 HTTP 服务镜像：# 使用官方 Node 镜像作为基础 FROM node:18-alpine3.14 # 设置容器内的工作目录 WORKDIR /app # 把当前目录下的所有文件复制到容器的工作目录 COPY . . # 安装依赖（如果有 package.json 的话） RUN npm install # 声明容器运行时监听的端口 EXPOSE 3000 # 容器启动时执行的命令 CMD ["node", "server.js"]这个文件里每一条指令都会生成一个“镜像层”，一层叠一层，最后变成一个完整的镜像。接下来我们一条条解释。指令详解FROM：指定基础镜像。几乎所有镜像都基于一个已有的操作系统或环境镜像，比如 node、nginx、alpine 等。这里我们选了 node:18-alpine3.14，因为 Alpine Linux 非常小巧，能让最终镜像体积小很多。WORKDIR：设置工作目录。后续的 COPY、RUN、CMD 等指令都会在这个目录下执行。如果目录不存在，Docker 会自动创建。COPY：把构建上下文（我们马上会讲）中的文件复制到镜像里。这里 COPY . . 表示把当前目录（构建上下文）的所有内容复制到镜像的 /app 下。RUN：在构建过程中执行命令。比如这里运行 npm install 安装依赖。每一条 RUN 都会生成一个新层。EXPOSE：告诉 Docker 容器内的应用会监听哪个端口。这只是一个声明，实际运行时还需要用 -p 做端口映射。CMD：指定容器启动时执行的命令。注意，这个命令可以被 docker run 后面跟着的命令覆盖。比如你 docker run myimage node server.js 就会覆盖掉 CMD。VOLUME 指令是干嘛的？你可能注意到文件里没写 VOLUME，但文件内容里提到了它。VOLUME 指令的作用是声明容器内的某个目录应该被持久化。即使你运行容器时没挂载数据卷，Docker 也会自动为这个目录创建一个匿名卷，保证里面的数据不会因为容器删除而丢失。比如：VOLUME /data这样即使你忘了 -v，/data 下的数据也会被保留在某个卷里。不过实际生产环境一般还是会手动挂载，便于管理。构建上下文与 .dockerignore当你执行 docker build 命令时，会指定一个构建上下文，通常是当前目录。比如：docker build -t my-http-server .这个点 . 就是构建上下文。Docker 会把该目录下的所有文件（包括子目录）打包发送给 Docker 守护进程（daemon），然后才开始构建。如果你的目录里有 node_modules、日志文件等不必要的东西，发送过去不仅慢，还会让镜像变大（因为 COPY 时会复制它们）。这时候就需要 .dockerignore 文件，写法类似 .gitignore，在里面列出不需要发送的文件和文件夹，比如：node_modules .git *.log Dockerfile这样构建时就忽略它们，加快构建速度，减小镜像体积。多阶段构建：瘦身利器有时我们构建应用需要编译工具、依赖包，但运行应用时并不需要它们。比如一个前端项目，构建时需要 Node 和 Webpack，但运行只需要生成的静态文件。多阶段构建就是用来解决这个问题的。看个例子：# 第一阶段：构建 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build # 第二阶段：运行 FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这里我们用了两个 FROM，第一个阶段叫 builder，专门用来构建，生成了 dist 目录。第二阶段直接用 Nginx 镜像，把第一阶段生成的 dist 目录复制过来。最终镜像只有 Nginx 和静态文件，体积非常小。多阶段构建不仅减小了镜像，还提高了安全性，因为运行环境里没有源代码和构建工具。Alpine 基础镜像的妙处前面我们多次提到 alpine，它到底有多小？以 Node 为例，node:18 镜像大概 1GB 左右，而 node:1…

Docker 入门：镜像、容器与 Desktop
type: item
date: 2024-02-03T14:37:00+08:00
description: Docker 入门：镜像、容器与 Desktop什么是 Docker？简单说，Docker 可以把你的应用和它所需要的环境（比如 Node.js、Nginx、数据库）一起打包成一个“镜像”。这个镜像就像是一个压缩包，里面装好了所有东西。然后你可以把这个镜像“跑起来”，变成一个独立的小房间，这个房间就叫“容器”。容器之间互相隔离，不会打架，但又能通过端口、数据卷等方式和你电脑上的其他程序通信。有了 Docker，你想部署多个相同的服务？直接跑多个容器就行，几分钟搞定，再也不用在一台服务器上折腾环境冲突了。Docker Desktop：可视化神器以前用 Docker 都是在命令行里敲命令，对于新手来说，有点劝退。但 Docker Desktop 出来以后，事情就变得简单多了。它给你提供了一个图形界面，可以直观地看到本地有哪些镜像、哪些容器在运行、数据卷（Volume）里存了啥。你甚至可以点点鼠标就启动一个 Nginx 容器，完全不用背命令。比如你想拉一个 Nginx 镜像，打开 Docker Desktop，搜索 nginx，点击 pull，镜像就下来了。然后你想把它跑起来？点一下 run，弹出窗口让你填端口映射（比如把本地的 8080 映射到容器的 80），再填上要挂载的数据卷（比如把本地的某个文件夹挂到容器的 /usr/share/nginx/html），确定之后容器就启动了。是不是很像装软件？命令行的存在感当然，Docker Desktop 里的所有操作，其实背后都是命令行在干活。比如你刚刚用界面做的那些事，在命令行里对应的是：docker pull nginx docker run -d -p 8080:80 -v /my/local/folder:/usr/share/nginx/html nginx所以当你习惯了 Docker 的基本操作，将来如果要在没有桌面的服务器上部署，直接用命令就行。不过刚开始学，用 Desktop 上手绝对是最舒服的方式。小试牛刀：跑一个 Nginx 服务我们来实操一下，从零开始跑一个 Nginx 容器。拉镜像：在 Docker Desktop 里搜索 nginx，点击 pull，或者命令行 docker pull nginx。运行容器：点 run，设置端口映射为 8080:80，数据卷挂载你电脑上的一个文件夹（比如放静态网站的文件夹），然后启动。访问：打开浏览器，输入 http://localhost:8080，如果看到 Nginx 欢迎页，恭喜你，成功啦！这就是 Docker 的基本用法：镜像 -> 容器，再加上端口映射和数据卷挂载，就能把一个服务跑起来，并且数据还能持久化。为什么数据卷很重要？你可能会问，容器里不是也有文件系统吗？为什么还要挂载数据卷？因为容器一旦被删除，里面的数据就全没了。如果你把重要数据放在挂载的数据卷里，即使容器删了，数据还在，下次再建一个容器挂载同一个卷，数据又回来了。所以数据卷是实现数据持久化的关键。

数据库操作：mysql2（直接 SQL） vs TypeORM（ORM 方式）
type: item
date: 2023-07-22T08:19:00+08:00
description: 数据库操作：mysql2（直接 SQL） vs TypeORM（ORM 方式）大家好，今天我们来聊聊 Node.js 后端开发中的核心话题——数据库操作。在 NestJS 框架里，我们操作数据库主要有两种方式：一种是直接用 SQL 驱动（比如 mysql2）发送原生 SQL 语句，另一种是使用 ORM（对象关系映射）框架，比如 TypeORM，通过类和装饰器来映射数据库表。这两种方式各有千秋，今天我们就来详细对比一下，帮你根据项目需求做出选择。一、文件里的原话先回顾一下文件里是怎么说的：Node 里操作数据库的两种方式：一种是直接用 mysql2 连接数据库，发送 sql 来执行。一种是用 ORM 库，比如 typeorm，它是基于 class 和 class 上的装饰器来声明和表的映射关系的，然后对表的增删改查就变成了对象的操作以及 save、find 等方法的调用。它会自动生成对应的 sql。这段话非常精炼地概括了两种方式的核心区别。下面我们展开细说。二、方式一：直接用 mysql2 发送 SQL是什么？mysql2 是 Node.js 中一个流行的 MySQL 客户端驱动，它是 mysql 包的升级版，支持 Promise、预处理语句等现代特性。用 mysql2 操作数据库，你需要自己写 SQL 语句，自己处理结果集，自己管理连接池。在 NestJS 中怎么用？你可以创建一个服务，封装数据库连接和查询方法。例如：import { Injectable } from '@nestjs/common'; import * as mysql from 'mysql2/promise'; @Injectable() export class DatabaseService { private pool; constructor() { this.pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'password', database: 'test', waitForConnections: true, connectionLimit: 10, }); } async query(sql: string, params?: any[]) { const [rows] = await this.pool.execute(sql, params); return rows; } }然后在你的服务里注入 DatabaseService，直接调用 query 方法执行 SQL：@Injectable() export class UserService { constructor(private db: DatabaseService) {} async findOne(id: number) { const rows = await this.db.query('SELECT * FROM users WHERE id = ?', [id]); return rows[0]; } }优点完全控制：你写什么 SQL，数据库就执行什么 SQL，没有中间层帮你“翻译”，性能最高。学习曲线低：如果你已经熟悉 SQL，上手极快，不需要学习 ORM 的规则。适合复杂查询：对于多表关联、子查询、窗口函数等复杂 SQL，原生 SQL 写起来更直接。轻量：不需要额外的 ORM 依赖，项目更轻。缺点手动映射：查询结果返回的是普通的 JavaScript 对象，你需要手动把它转换成业务对象，或者手动处理关系。类型安全差：TypeScript 类型需要自己定义，容易出错。重复代码：每个查询都要写 SQL，表名、字段名硬编码，容易写错且难以维护。数据库迁移麻烦：表结构变更需要手动维护 SQL 脚本。三、方式二：使用 TypeORM（ORM 框架）是什么？TypeORM 是一个 TypeScript 友好的 ORM 框架，它让你用类和装饰器来定义实体（Entity），每个实体对应数据库中的一张表。然后你就可以通过 Repository 提供的 API 进行 CRUD 操作，而不需要写 SQL。在 NestJS 中怎么用？Nest 官方提供了 @nestjs/typeorm 包，可以无缝集成 TypeORM。首先安装依赖：npm install @nestjs/typeorm typeorm mysql2然后在 AppModule 中配置连接：import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.…

文件上传：使用 multer 和 @nestjs/platform-express 轻松搞定
type: item
date: 2023-07-18T11:17:00+08:00
description: 文件上传：使用 multer 和 @nestjs/platform-express 轻松搞定大家好，今天我们来聊聊 Web 开发中绕不开的一个功能——文件上传。无论是用户头像、图片分享，还是 Excel 导入，都需要处理文件上传。在 NestJS 中，文件上传的实现非常方便，因为底层直接集成了 Express 生态里最流行的 multer 中间件。你只需要引入几个内置的拦截器，就能轻松接收和处理上传的文件。今天我们就来完整地走一遍文件上传的流程。一、文件上传的基本知识在 HTTP 协议中，文件上传通常使用 multipart/form-data 格式的请求。这种格式会将请求体分成多个部分，每个部分有自己的头部和内容，文件数据放在其中一个或多个部分中。这种格式的好处是可以同时传输文件和普通的表单字段（比如文本输入）。NestJS 通过 @nestjs/platform-express 包提供了对 multer 的封装，让你可以用拦截器的方式来处理文件上传。二、安装依赖如果你用的是默认的 Express 适配器，multer 已经作为 @nestjs/platform-express 的依赖被安装了，所以你不需要额外安装。但如果需要类型定义，可以安装 @types/multer：npm install --save-dev @types/multer三、单文件上传最常见的场景是上传单个文件。Nest 提供了 FileInterceptor 拦截器，它会从请求中取出指定字段的文件，然后挂到 req.file 上。你可以通过 @UploadedFile() 装饰器获取这个文件对象。步骤：在控制器方法上使用 @UseInterceptors(FileInterceptor('file'))，其中 'file' 是表单中文件字段的名称。方法参数中使用 @UploadedFile() file: Express.Multer.File 来接收文件。其他非文件字段仍然通过 @Body() 获取。import { Controller, Post, UseInterceptors, UploadedFile, Body } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; @Controller('upload') export class UploadController { @Post('single') @UseInterceptors(FileInterceptor('file')) uploadSingle(@UploadedFile() file: Express.Multer.File, @Body() body) { console.log(file); return { message: '文件上传成功', filename: file.originalname }; } }默认情况下，multer 会把文件保存在内存中（作为 buffer），如果你想保存到磁盘，可以配置 dest 或 storage 选项。配置保存路径FileInterceptor 可以接受第二个参数，是一个 multer 的选项对象：@UseInterceptors(FileInterceptor('file', { dest: './uploads' }))这样文件就会自动保存到 uploads 目录下，文件名是随机生成的。如果你想自定义文件名或存储逻辑，可以使用 diskStorage：import { diskStorage } from 'multer'; import { extname } from 'path'; @UseInterceptors(FileInterceptor('file', { storage: diskStorage({ destination: './uploads', filename: (req, file, callback) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const ext = extname(file.originalname); callback(null, file.fieldname + '-' + uniqueSuffix + ext); }, }), }))四、多文件上传（同一个字段多个文件）如果前端上传多个文件，且使用同一个字段名（比如 的 name="files"），你需要用 FilesInterceptor（注意是复数）。@Post('multiple')…

内置 Pipe 与自定义 Pipe：参数校验和转换的利器
type: item
date: 2023-07-01T15:59:00+08:00
description: 内置 Pipe 与自定义 Pipe：参数校验和转换的利器大家好，今天我们来聊聊 NestJS 里一个非常实用但又容易被低估的组件——管道（Pipe）。如果你写过 Controller，一定见过 @Body() createUserDto: CreateUserDto 这种写法，配合 ValidationPipe 就能自动验证参数格式。这就是管道最典型的应用场景。管道主要有两个作用：参数校验和参数转换。它可以在请求到达控制器方法之前，对参数进行预处理，如果不符合要求就直接抛出异常，避免脏数据进入业务逻辑。Nest 内置了很多好用的管道，同时也允许我们自定义管道，今天我们就来一起看一下。一、内置管道：开箱即用NestJS 提供了一系列内置管道，覆盖了大部分常见需求。我们简单过一遍：ValidationPipe：最强大的管道，基于 class-validator 和 class-transformer，用于验证 DTO 对象。ParseIntPipe：将字符串参数转换为整数，如果转换失败抛出异常。ParseBoolPipe：将字符串转换为布尔值（比如 'true' 转成 true）。ParseArrayPipe：将字符串解析为数组，常用于 query 参数传递数组的场景。ParseUUIDPipe：验证参数是否为有效的 UUID。DefaultValuePipe：当参数未提供时，设置一个默认值。ParseEnumPipe：验证参数是否属于某个枚举值。ParseFloatPipe：转换为浮点数。ParseFilePipe：验证上传的文件。这些管道可以直接用在参数上，比如：@Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { // id 已经是 number 类型 }如果参数转换失败，Nest 会自动返回 400 错误，非常省心。二、ValidationPipe：最常用的校验管道在实际开发中，最常用的当属 ValidationPipe。它通常配合 DTO（数据传输对象）使用，让你通过装饰器声明校验规则。首先需要安装两个依赖包：npm install class-validator class-transformer然后定义一个 DTO 类，用 class-validator 的装饰器标记规则：import { IsString, IsInt, Min, Max } from 'class-validator'; export class CreateUserDto { @IsString() name: string; @IsInt() @Min(0) @Max(150) age: number; }接着在控制器中使用：@Post() create(@Body() createUserDto: CreateUserDto) { // 如果参数不符合规则，ValidationPipe 会自动抛出 BadRequestException return createUserDto; }为了启用 ValidationPipe，你需要把它应用到全局或局部。最简单的就是在 main.ts 中设置全局管道：app.useGlobalPipes(new ValidationPipe());ValidationPipe 的工作原理文件里提到了它的原理：基于 class-transformer 把普通对象转换成 DTO 类的实例，然后基于 class-validator 的装饰器对这个实例进行验证。如果验证失败，就抛出异常。也就是说，Nest 会自动把请求体对象（比如 { name: 'John', age: 30 }）转换成 CreateUserDto 的实例，然后检查 name 是不是字符串、age 是不是整数且在 0~150 之间。验证不通过就会返回详细的错误信息。三、自定义管道：自己的需求自己造虽然内置管道已经很丰富了，但总有特殊需求需要自己动手。比如你想把参数值乘以 10 再传给控制器，或者做更复杂的校验。自定义管道需要实现 PipeTransform 接口，也就是写一个类，实现 transform 方法。这个方法接收两个参数：value：当前参数的值（来自请求）metadata：包含该参数的元数据，比如参数名、类型、装饰器类型等transform 方法的返回值就是最终传给控制器方法的值。如果校验失败，可以直接抛出异常（比如 BadRequestException）。例子：自定义一个 ParseIntPipe其实 Nest 已经内置了 ParseIntPipe，但我们自己实现一个来理解流程：import { PipeTransform, Injectable, ArgumentM…