当多个容器需要协同：用 Docker Compose 一键拉起整个应用
当多个容器需要协同：用 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：直接使用官方 Redis 镜像。volumes：使用命名卷 redis_data 挂载到 /data，Redis 的数据就会持久化到卷里，不会因为容器删除而丢失。volumes：在顶层声明命名卷，这样 Compose 会帮我们创建和管理这个卷。启动和常用命令在项目目录下执行：docker-compose upCompose 就会开始构建 web 镜像（如果本地没有）、拉取 redis 镜像，然后启动两个容器，并把日志输出到当前终端。如果你想在后台运行，加个 -d 参数：docker-compose up -d其他常用命令：docker-compose down：停止并删除所有容器、网络，但不会删除卷（除非加 -v）。docker-compose logs -f：查看所有服务的日志，-f 表示持续跟踪。docker-compose exec web sh：在运行中的 web 容器里执行命令（比如进入容器调试）。docker-compose ps：列出当前项目相关的容器状态。docker-compose build：只构建镜像，不启动容器。网络是怎么工作的？当你执行 up 时，Compose 会自动创建一个默认的网络（名字通常是 项目名_default），并把所有服务都加入到这个网络里。这样服务之间就可以通过服务名直接通信，比如 Node 代码里连接 Redis 的 host 直接写 redis 就行。完全不用手动 --link 或者配置复杂的网络。如果你想自定义网络，也可以在 networks 顶层定义，然后在每个服务的 networks 里指定，但默认网络已经足够满足大多数需求。与 Docker 命令行的对比如果不使用 Compose，你需要依次执行：docker network create myapp docker run -d --name redis --network myapp -v redis_data:/data redis:alpine docker build -t myapp . docker run -d --name web --network myapp -p 3000:3000 -v .:/app -v /app/node_modules -e REDIS_HOST=redis myapp而且停止时还要一个个 docker stop、docker rm，删除卷也要单独处理。用 Compose，只需要一个 docker-compose down。当项目变大、服务变多时，这种简化的优势会更明显。开发环境与生产环境的配置差异通常我们会为不同环境准备不同的 Compose 文件，或者使用 extends 或 profiles。一个常见的做法是：开发环境用 docker-compose.yml，生产环境用 docker-compose.prod.yml，启动时通过 -f 指定：docker-compose -f docker-compose.prod.yml up -d生产环境的配置可能没有代码挂载，而是使用构建好的镜像，并加上重启策略等。Compose 的局限Docker Compose 非常强大，但它只能管理单台 Docker 主机上的容器。如果你需要在多台服务器上部署容器、实现自动伸缩、滚动更新，就需要用到更高级的编排工具，比如 Docker Swarm（也是 Docker 原生）或 Kubernetes。不过对于中小型项目、开发测试环境，Compose 已经足够好用。小结Docker Compose 是 Docker 生态里的一把利器，它把多容器的管理变得像单容器一样简单。通过一个 YAML 文件，你可以清晰地描述整个应用架构，然后用几个命令控制整个生命周期。结合之前学的 Dockerfile、数据卷、环境变量等知识，现在你完全可以构建出复杂的多服务应用了。
多容器协作实战：网络、数据共享与依赖管理
多容器协作实战：网络、数据共享与依赖管理上一期我们介绍了 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: ... networks: - backend ghost: ... networks: - backend # ghost 不需要前端网络，除非你需要直接访问它 nginx: ... networks: - frontend - backend这样，Nginx 在两个网络里，可以代理 ghost（通过 backend 网络），而外界只能通过 Nginx 暴露的端口访问，ghost 和 mysql 完全不暴露端口给宿主机，更加安全。数据共享：多个容器挂载同一个卷上面我们把 ghost_content 卷同时挂载到了 ghost 和 nginx 容器。这是多容器共享数据的常用方式。ghost 容器往 /var/lib/ghost/content 写入上传的图片，Nginx 容器在 /var/www/ghost/content 下也能看到同样的文件，这样就可以直接让 Nginx 处理静态文件请求，减轻 Node 的压力。注意：卷是 Docker 管理的，多个容器同时读写时要注意文件锁问题。这里 Ghost 主要是写入，Nginx 读取，没有问题。依赖与启动顺序：depends_on 不够用我们在 ghost 和 nginx 里用了 depends_on，它能保证容器启动的顺序（先 mysql，再 ghost，最后 nginx）。但这只保证容器启动了，不保证里面的服务已经就绪。比如 mysql 容器起来了，但 MySQL 服务可能还在初始化，这时 ghost 连接数据库就会失败。解决办法是：在应用层面添加等待机制。常见的有：使用脚本（如 wait-for-it.sh 或 dockerize）在容器启动时等待依赖服务端口可用。使用健康检查（healthcheck）结合 depends_on 的 condition 特性（Compose 3.8+ 支持）。例如，给 mysql 加健康检查：mysql: image: mysql:8.0 ... healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 interval: 10s然后在 ghost 的 depends_on 里指定条件：ghost: ... depends_on: mysql: condition: service_healthy这样，只有当 mysql 健康检查通过后，才会启动 ghost。同样可以给 ghost 加健康检查，让 nginx 等待它。环境变量与配置管理上面我们把数据库密码直接写在了 Compose 文件里，这不安全。更好的做法是用 .env 文件或 Docker secrets。Compose 支持从 .env 文件读取变量。在项目目录创建 .env：MYSQL_ROOT_PASSWORD=rootpassword MYSQL_PASSWORD=ghostpass然后在 Compose 文件中引用：mysql: environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD}注意，这个 .env 文件不要提交到代码仓库，加进 .gitignore。对于更敏感的场景，可以使用 Docker swarm 的 secrets 或者外部密钥管理工具。与外界通信：端口映射与反向代理上面例子中，我们只暴露了 Nginx 的 80 端口到宿主机，ghost 和 mysql 都没有对外暴露端口。Nginx 通过 backend 网络访问 ghost 的 2368 端口。Nginx 配置文件 nginx.conf 里可以这样写：server { listen 80; server_name localhost; location / { proxy_pass http://ghost:2368; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /content/images/ { alias /var/www/ghost/content/images/; } }注意 proxy_pass 里的 ghost 就是服务名，会被解析为 ghost 容器的 IP。更复杂的场景：多个服务副本与负载均衡如果某个服务需要水平扩展（比如多个 ghost 实例），Compose 可以用 scale 或在 docker-compose up 时加 --scale ghost=3。但这时候服务名 ghost 会解析到多个容器 IP，请求会以轮询方式分发，需要保证应用是无状态的（比如 session 存外部存储）。Nginx 作为反向代理时，可以配置 upstream 来实现负载均衡。例如在 nginx.conf 里：upstream ghost_upstream { server ghost:2368; # 这里的 ghost 会被解析为多个 IP } server { location / { proxy_pass http://ghost_upstream; } }但要注意，默认的 DNS 解析可能不会自动更新 IP 列表，如果容器动态变化，可能需要使用如 resolver 配合 set 等方式，或者用服务发现工具。Compose 在单机环境下 scale 相对简单，但生产环境建议用 Swarm 或 K8s。小结通过这个 Ghost + MySQL + Nginx 的例子，我们看到了多容器协作的几个关键点：网络：用自定义网络隔离服务，通过服务名进行通信。数据共享：使用命名卷让多个容器读写同一份数据。启动控制：依赖关系 + 健康检查确保服务顺序。配置管理：用环境变量和 .env 文件避免敏感信息硬编码。对外暴露：统一入口（Nginx）代理内部服务，提升安全性。Docker Compose 让这一切变得简单，但也有些局限（比如跨主机编排）。不过对于开发、测试和小型生产环境，它已经足够强大。现在你可以自己尝试搭建一个多服务应用，在实践中体会这些技巧。如果遇到问题，记得容器日志是你的好朋友（docker-compose logs）。
在 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', error_file: '/var/log/pm2/err.log', // 如果想把日志写文件，可以指定路径，但记得挂载卷 out_file: '/var/log/pm2/out.log', combine_logs: true }] };然后在 Dockerfile 里把配置文件复制进去，CMD 里指定这个文件。关于日志和数据持久化如果你在 ecosystem 里配置了日志写文件（比如上面的 error_file），那这些日志是写在容器内的。一旦容器删除，日志就没了。所以如果你需要长期保留日志，可以把日志目录挂载成数据卷。同样，如果你的应用需要上传文件或者有数据要持久化，也要挂载对应的数据卷。另外，pm2 在容器里运行时，你还可以通过 pm2 plus 或者 pm2.io 实现云端监控，不过那是另一个话题了。用 pm2 的好处再总结稳定性：进程崩溃自动重启，不用等 Docker 重启整个容器。性能：利用多核 CPU，集群模式提升吞吐量。可观测性：通过 pm2 内置的监控命令，可以实时了解容器内应用的资源占用。日志管理：自动轮转、合并日志，避免日志文件过大。需要注意的点pm2 本身也是一个进程，它会占用少量资源，但相比带来的好处可以忽略。如果你在容器里只跑一个进程，也可以不用 pm2，直接用 Node 原生启动。但一旦有多个进程或需要自动重启，pm2 就很有用了。确保容器停止时能优雅退出：pm2-runtime 会处理，但你的应用代码最好也能监听 SIGTERM 信号，做清理工作。小结把 pm2 和 Docker 结合起来，相当于给 Node 应用上了双重保险：Docker 保证容器级别的重启和隔离，pm2 保证进程级别的稳定和管理。而且配置起来也不复杂，只要在 Dockerfile 里加一行安装，CMD 改成 pm2-runtime 就行了。生产环境的 Node 应用，强烈推荐这样用。
Docker 背后的秘密：Namespace、CGroup 和 UnionFS 是怎么工作的？
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 会在这些只读层之上添加一个可写层（容器层）。如果你在容器里修改了 /app 下的文件，修改会写入容器层，下面的镜像层保持不变（通过写时复制技术）。这样既节省空间，又保持了镜像的不可变性。把这些串起来：Docker 的完整工作流程docker build：读取 Dockerfile，每一条指令生成一个镜像层，最终构建成一个镜像。docker run：基于镜像创建容器，为容器创建新的 Namespace 和 cgroups，挂载 UnionFS（镜像层+容器层），然后启动容器内的进程。如果指定了数据卷，Docker 会在宿主机创建（或使用已有）目录，并挂载到容器的指定路径，绕开 UnionFS。容器运行时，进程通过 Namespace 隔离资源，通过 cgroups 限制资源使用，通过 UnionFS 实现文件系统分层。小结现在你应该明白，Docker 并不是什么黑科技，而是巧妙组合了 Linux 内核已有的特性：Namespace 实现隔离；Control Group 实现限制；UnionFS 实现镜像分层和存储。理解了这些，你就能更好地解释为什么容器是轻量的（共享内核）、为什么镜像可以分层复用、为什么数据卷能持久化。这些都是 Docker 的基石。
Dockerfile 高级玩法：ARG、ENV、CMD、ENTRYPOINT、ADD
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" && npm run build # 运行阶段 FROM nginx:alpine ARG NGINX_PORT=80 ENV NGINX_PORT=$NGINX_PORT COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE $NGINX_PORT ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"]这里我们在构建阶段用 ARG 传入版本号，在运行阶段用 ARG 设置默认端口，并通过 ENV 传递给容器。ENTRYPOINT 固定为 nginx，CMD 提供默认参数，如果你需要修改配置，可以 docker run mynginx nginx -g 'daemon off; -c /custom/nginx.conf' 这样追加参数。小结掌握了这些指令，你的 Dockerfile 就不仅仅是“能工作”，而是“好用”了：ARG 让构建可配置；ENV 让运行时能读取配置；ENTRYPOINT + CMD 让启动命令既固定又灵活；COPY 为主，ADD 为辅 让文件复制更清晰。
自己动手写 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:18-alpine 只有 100 多 MB。因为 Alpine 是一个极简的 Linux 发行版，只包含最基本的工具，像 sh、apk 包管理器等。用它做基础镜像，构建出来的镜像体积小，传输快，部署也快。总结通过写一个 Dockerfile，你可以精确控制镜像的每一层。记住几个要点：选择合适的 FROM（优先 Alpine），利用 .dockerignore 减少上下文，用 多阶段构建 瘦身，理解 CMD 和 ENTRYPOINT 的区别（我们后面会细讲）。现在你可以试着为自己的 Node 项目写个 Dockerfile 了。
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 方式）大家好，今天我们来聊聊 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.entity'; @Module({ imports: [ TypeOrmModule.forRoot({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: 'password', database: 'test', entities: [User], synchronize: true, // 开发环境自动建表，生产环境建议关闭 }), ], }) export class AppModule {}定义一个实体类：import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column() age: number; }在模块中引入实体的 Repository：@Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UserService], }) export class UserModule {}然后在服务里注入 Repository 进行操作：@Injectable() export class UserService { constructor( @InjectRepository(User) private userRepository: Repository<User>, ) {} async findOne(id: number) { return this.userRepository.findOne({ where: { id } }); } async create(data: Partial<User>) { const user = this.userRepository.create(data); return this.userRepository.save(user); } }优点开发效率高：不用写 SQL，所有操作通过方法调用完成，代码量少。类型安全：实体类就是 TypeScript 类，字段类型明确，IDE 自动提示。关系映射方便：可以用 @OneToMany、@ManyToOne 等装饰器定义表关系，查询时自动关联。迁移工具：TypeORM 提供了 CLI 工具，可以根据实体变化生成迁移脚本，方便版本控制。数据库无关：切换数据库（比如从 MySQL 换到 PostgreSQL）只需要改配置，代码几乎不用动。缺点学习曲线：需要学习 TypeORM 的 API 和装饰器，理解它的工作原理。性能开销：ORM 会做一些额外的操作，比如解析实体、生成 SQL，比原生 SQL 稍慢，但对于大多数应用来说可以忽略。复杂查询不够直观：对于非常复杂的 SQL，用 ORM 的方法链可能会变得晦涩难懂，不如直接写 SQL。灵活性受限：有些数据库特性 ORM 可能不支持，需要回退到原生查询。四、如何选择？其实没有绝对的好坏，主要看你的项目场景和团队偏好。如果你喜欢完全掌控，SQL 功底扎实，或者项目中有大量复杂查询，直接用 mysql2 会更灵活。如果你追求开发效率，希望代码更易于维护，团队有 TypeScript 规范，TypeORM 是更好的选择。混合使用：即使用了 TypeORM，遇到复杂查询也可以通过 createQueryBuilder 或者直接执行原生 SQL，两种方式可以并存。Nest 里你可以同时使用 TypeORM 和原生查询。文件里也提到两种方式，没有偏向哪一种，只是客观介绍。在实际 Nest 项目中，官方推荐 TypeORM 因为集成度高，但如果你有特殊需求，完全可以用 mysql2 自己封装。五、总结无论是用 mysql2 直接写 SQL，还是用 TypeORM 操作实体，都是 Node.js 操作 MySQL 的成熟方案。mysql2 给你自由，TypeORM 给你效率。在 NestJS 里，你可以根据项目阶段和复杂度灵活选择，甚至两者结合使用。关键是理解它们的适用场景，做出最适合项目的决策。
文件上传：使用 multer 和 @nestjs/platform-express 轻松搞定
文件上传：使用 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') @UseInterceptors(FilesInterceptor('files', 10)) // 最多10个文件 uploadMultiple(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body) { console.log(files); return { message: '多文件上传成功', count: files.length }; }FilesInterceptor 的第一个参数是字段名，第二个参数是最大文件数量限制（可选）。五、多字段文件上传有时候一个表单里可能有多个不同的文件字段，比如头像字段 avatar 和背景图字段 background。这时候可以用 FileFieldsInterceptor，它允许你指定多个字段及其最大文件数。@Post('fields') @UseInterceptors(FileFieldsInterceptor([ { name: 'avatar', maxCount: 1 }, { name: 'background', maxCount: 1 }, ])) uploadFields( @UploadedFiles() files: { avatar?: Express.Multer.File[], background?: Express.Multer.File[] }, @Body() body, ) { console.log(files.avatar?.[0]); console.log(files.background?.[0]); return { message: '多字段上传成功' }; }@UploadedFiles() 返回的是一个对象，键是字段名，值是对应文件数组（即使 maxCount:1 也是数组，需要取第 0 项）。六、任意字段文件上传（不指定字段名）如果你不知道前端会传什么字段，或者想处理所有上传的文件，可以用 AnyFilesInterceptor。它会捕获请求中的所有文件，不管字段名是什么。@Post('any') @UseInterceptors(AnyFilesInterceptor({ dest: 'uploads/' })) uploadAny(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body) { console.log(files); // 所有文件都在这个数组里 return { message: '任意文件上传成功' }; }文件里给的例子就是用的 AnyFilesInterceptor。七、文件对象的属性通过 @UploadedFile() 或 @UploadedFiles() 拿到的文件对象是标准的 multer 文件对象，包含以下常用属性：fieldname：表单字段名originalname：原始文件名encoding：文件编码mimetype：文件 MIME 类型size：文件大小（字节）destination：保存路径（如果配置了 dest）filename：保存后的文件名path：完整路径buffer：文件内容的 Buffer（如果没有保存到磁盘）八、文件验证和限制你可以通过 multer 的 limits 选项来限制文件大小等：@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 }, // 限制 2MB }))如果需要根据文件类型进行过滤，可以使用 fileFilter 函数：@UseInterceptors(FileInterceptor('file', { fileFilter: (req, file, callback) => { if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { return callback(new Error('只允许上传图片文件'), false); } callback(null, true); }, }))注意：fileFilter 中如果回调错误，会抛给 Nest 的异常过滤器。你可以自定义异常过滤器来捕获这些错误并返回友好的响应。九、结合 DTO 和 ValidationPipe有时候你可能需要同时验证非文件字段，比如上传图片时附带一个描述字段。你可以定义一个 DTO，然后在 @Body() 中使用，配合 ValidationPipe：export class UploadDto { @IsString() @IsOptional() description: string; } @Post('single') @UseInterceptors(FileInterceptor('file')) uploadSingle( @UploadedFile() file: Express.Multer.File, @Body() body: UploadDto, ) { // body 已经过 ValidationPipe 验证 }记得在全局或控制器上启用 ValidationPipe。十、错误处理当上传出错时（比如文件太大、文件类型不对、超过文件数量限制），multer 会抛出错误。这些错误会被 Nest 的异常层捕获，默认返回 500 错误。你可以通过自定义异常过滤器来捕获这些错误，并返回更友好的响应。import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; import { Request, Response } from 'express'; import { MulterError } from 'multer'; @Catch(MulterError) export class MulterExceptionFilter implements ExceptionFilter { catch(exception: MulterError, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); let message = '文件上传错误'; if (exception.code === 'LIMIT_FILE_SIZE') { message = '文件大小超过限制'; } else if (exception.code === 'LIMIT_FILE_COUNT') { message = '文件数量超过限制'; } else if (exception.code === 'LIMIT_UNEXPECTED_FILE') { message = '意外字段'; } response.status(400).json({ statusCode: 400, message, }); } }然后在控制器或全局使用 @UseFilters(MulterExceptionFilter)。十一、关于文件和字段的顺序有一点需要注意：@Body() 不能用来获取文件字段的值，因为 multipart/form-data 中文件字段是单独的部分，不会被解析到 body 中。所有文件字段都应该通过 @UploadedFile() 或 @UploadedFiles() 获取，而其他普通字段通过 @Body() 获取。总结NestJS 的文件上传功能基于成熟的 multer 库，通过几个内置拦截器就能轻松处理各种上传场景。你可以：用 FileInterceptor 处理单文件用 FilesInterceptor 处理同一个字段的多个文件用 FileFieldsInterceptor 处理多个不同字段的文件用 AnyFilesInterceptor 处理所有文件同时可以通过 multer 的选项配置存储、限制、过滤，并结合 DTO 和 ValidationPipe 进行数据验证。错误处理也可以通过自定义异常过滤器来优化。
内置 Pipe 与自定义 Pipe：参数校验和转换的利器
内置 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, ArgumentMetadata, BadRequestException } from '@nestjs/common'; @Injectable() export class MyParseIntPipe implements PipeTransform<string, number> { transform(value: string, metadata: ArgumentMetadata): number { const val = parseInt(value, 10); if (isNaN(val)) { throw new BadRequestException('参数必须为数字'); } return val; } }使用：@Get(':id') findOne(@Param('id', MyParseIntPipe) id: number) {}例子：更复杂的校验管道假设我们需要一个管道，它根据参数元数据来动态校验。比如我们想实现一个类似 ValidationPipe 但只对特定字段生效的管道。实际上，Nest 的 ArgumentMetadata 包含了 type（是 body、query 还是 param）、metatype（参数的类型，比如 DTO 类）、data（装饰器传的值，比如 @Query('name') 的 'name'）。我们可以利用这些信息做更精细的控制。例如，我们想写一个管道，如果参数是数字字符串就转成数字，否则原样返回：@Injectable() export class ParseOptionalIntPipe implements PipeTransform { transform(value: any, metadata: ArgumentMetadata) { if (value === undefined || value === null) return value; const val = parseInt(value, 10); return isNaN(val) ? value : val; } }依赖注入与全局管道自定义管道也是 provider，所以它可以通过构造函数注入其他服务。比如我们想注入一个日志服务：@Injectable() export class CustomPipe implements PipeTransform { constructor(private loggerService: LoggerService) {} transform(value: any, metadata: ArgumentMetadata) { this.loggerService.log('开始校验参数...'); // 校验逻辑 return value; } }但需要注意的是，如果你在 main.ts 中用 app.useGlobalPipes(new CustomPipe()) 注册全局管道，这个管道实例不在 IoC 容器中，无法注入依赖。解决办法是用 APP_PIPE 令牌在模块中注册：import { Module } from '@nestjs/common'; import { APP_PIPE } from '@nestjs/core'; @Module({ providers: [ LoggerService, { provide: APP_PIPE, useClass: CustomPipe, // 这样 CustomPipe 就能注入 LoggerService 了 }, ], }) export class AppModule {}这样，CustomPipe 就会成为全局管道，并且享受依赖注入。四、总结管道是 NestJS 中实现参数预处理的核心机制。内置管道覆盖了大多数场景，尤其是 ValidationPipe，配合 DTO 和 class-validator 能极大简化参数校验代码。而当内置管道不能满足需求时，自定义管道给了我们充分的灵活性，甚至可以通过注入依赖实现复杂的校验逻辑。记住，管道可以在参数级别、方法级别、控制器级别甚至全局使用，灵活组合能让你的代码既简洁又健壮。