Compose 的简介和安装 文档参考 、快速入门 、安装综述 、Linux 安装文档 。
在单容器时代,我们使用 docker run 配合各种参数就能玩得风生水起。但随着业务演进到微服务时代,问题接踵而至:一个标准的电商系统可能需要 1 个 Spring Boot 业务容器、1 个 Redis 缓存、1 个 MySQL 数据库、1 个 RabbitMQ 消息队列。如果依然用普通的 Docker 命令,你必须手动敲 4 次极其冗长的 docker run,还要小心翼翼地处理它们的启动顺序、手动创建网络并把它们一个个拉进来。一旦漏掉一个参数,整个系统就会瘫痪。为了解决单机多容器分布式应用的编排与部署痛点,生产的标配工具就是 Docker Compose 。
什么是 Compose Compose 最核心的哲学就是 声明式基础设施即代码 (IaC) 。它允许你通过一个单独的 docker-compose.yml 配置文件,把组成应用的所有容器服务、网络、数据卷全部声明式地下定义。随后,你只需要在终端轻飘飘地敲下一行 docker compose up,整个微服务群落就会像乐高积木一样,自动、有序、完美地在后台拼装并运行起来。
Compose 和 Spring 框架都极力反对命令式的硬编码,推崇声明式的配置。你只需要声明你想要达到的最终状态(What to do),而不需要关心中间具体怎么创建的细节(How to do),全部交由底层的“容器”去反转控制。Spring 是代码维度的 IoC 容器,而 Docker Compose 是容器维度的 IoC 容器。 它们一个在软件内部负责对象的编排,一个在软件外部负责集装箱的编排,两者核心思想完全一脉相承,都是为了让分布式系统解耦、自动化和具备极高的弹性。
控制反转与声明式管理 :
在没有 Spring 之前,你想用一个对象,必须自己手动 new UserService(),并手动把 UserDao 塞进去(命令式)。 有了 Spring,你只需要在类上打一个 @Service 或 @Component 标签。Spring 容器启动时,会自动把这些 Bean 实例化并组装好。
在没有 Compose 之前,你需要自己手动敲 docker run -d –name redis …,一步步去肉身构建容器(命令式)。 有了 Compose,你只需要在 docker-compose.yml 里面声明你需要什么服务(Services)。Compose 容器启动时,会自动把这些镜像拉取、创建并运行起来。
依赖注入与服务依赖 :
当 UserService 依赖 UserDao 时,你只需要写上 @Autowired,Spring 自动会把依赖的对象注入进来。如果某个 Bean 必须在另一个 Bean 之后初始化,还可以用 @DependsOn 注解来强制控制顺序。
当你的 Java 容器依赖 Redis 容器时,你在 docker-compose.yml 中写上 depends_on: - my-redis。Compose 就会确保先启动 Redis,等它就绪后,再启动 Java 容器。这在本质上就是基础设施层面的依赖注入与顺序控制。
服务发现与内部通信 :
在 Spring 容器内部,所有的 Bean 都注册在 ApplicationContext 中。你想用谁,直接通过 beanName(比如 userServiceImpl)就能精准找到它并调用其方法,不需要知道这个对象在内存的什么十六进制地址上。
Compose 启动时会默认创建一个专属的内部虚拟网络。所有加入的服务都可以直接通过服务名( 如 http://my-redis:6379 )进行跨容器通信。Compose 内置了 DNS 服务器,会自动把服务名翻译成动态变动的内部 IP,完全不需要硬编码 IP 地址。
模块化与配置复用 :
通过 @Configuration 和 @Bean,你可以把数据源、安全框架、缓存切面做成一个个独立的配置模块。想用哪个,引入依赖、写个配置就能无缝拼装。
通过 docker-compose.yml,把 MySQL、Redis、Nginx、Java微服务定义成一个个独立的服务节点,配合 environment 传递环境变量,支持 “一套模版,到处运行(开发/测试/生产一键切换)”。
核心概念:服务、网络、卷 在写 docker-compose.yml 之前,必须分清它的三个一等公民:
服务 (Services) :一个服务在本质上就代表了一个准备运行的容器实例(如 nginx、mysql、web应用)。你可以在服务里指定它用什么镜像、映射什么端口、挂载什么目录。
网络 (Networks) :Compose 会在背后自动创建一个专属的自定义网桥网络。所有写在同一个 docker-compose.yml 里的服务默认都会自动加入这个网络,天然支持通过服务名进行内部动态 DNS 域名解析。
卷 (Volumes) :用于定义跨容器生命周期的数据持久化卷,确保数据库等容器死掉后数据不丢失。
实际经典案例 docker-compose.yml 准备 cd ~/app_user && vim docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 networks: backend-net: driver: bridge name: backend-net volumes: my_mysql_data: my_redis_data: services: my_mysql: image: mysql:8.4.9-oraclelinux9 container_name: my_mysql_3300 restart: always environment: MYSQL_ROOT_PASSWORD: "123456" MYSQL_ALLOW_EMPTY_PASSWORD: 'no' MYSQL_DATABASE: 'db03' MYSQL_USER: owlias MYSQL_PASSWORD: '123456' TZ: Asia/Shanghai ports: - "3300:3306" privileged: true volumes: - my_mysql_data:/var/lib/mysql - /opt/apps/my_mysql/conf:/etc/mysql/conf.d - /opt/apps/my_mysql/log:/var/log - /opt/apps/my_mysql/init-sql:/docker-entrypoint-initdb.d command: --mysql-native-password=ON networks: - backend-net logging: driver: "json-file" options: max-size: "50m" max-file: "3" healthcheck: test: ["CMD" , "mysqladmin" , "ping" , "-h" , "localhost" , "-u" , "root" , "-p123456" ] interval: 10s timeout: 10s retries: 10 start_period: 30s my_redis: image: redis:6.0.8 container_name: my_redis_6370 restart: always ports: - "6370:6379" volumes: - my_redis_data:/data - /opt/apps/my_redis/conf/redis.conf:/etc/redis/redis.conf networks: - backend-net command: redis-server /etc/redis/redis.conf environment: - TZ=Asia/Shanghai logging: driver: "json-file" options: max-size: "20m" max-file: "3" healthcheck: test: ["CMD" , "redis-cli" , "-a" , "123456" , "ping" ] interval: 10s timeout: 5s retries: 5 app_user: image: app_user:v1.0 container_name: app_user_8081 restart: always ports: - "8081:8081" volumes: - /opt/apps/app_user/app:/app - /opt/apps/app_user/logs:/data/logs environment: - TZ=Asia/Shanghai - SPRING_PROFILES_ACTIVE=prod - JAVA_OPTS=-Xms512m -Xmx512m -XX:+UseG1GC networks: - backend-net depends_on: my_mysql: condition: service_healthy my_redis: condition: service_healthy logging: driver: "json-file" options: max-size: "100m" max-file: "5"
检查 docker-compose.yml 的语法
1 2 $ docker compose config -q
WEB应用镜像的准备 准备 app_user:v1.0 的镜像: 将以下内容写入到对应目录的 Dockerfile 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 FROM my-centos7-java17-dev:v1.0 LABEL maintainer="owlias" ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY docker-app-user.jar /app/app.jar VOLUME /data/logs EXPOSE 8081 ENTRYPOINT ["sh" , "-c" , "java $JAVA_OPTS -jar /app/app.jar" ]
构建镜像:
1 2 $ docker build -t app_user:v1.0 . $ docker images
数据卷以及配置准备 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 $ mkdir -p /opt/apps/my_mysql/{conf,init-sql} $ cat <<EOF > /opt/apps/my_mysql/conf/my.cnf [client] default_character_set=utf8 [mysqld] collation_server=utf8_general_ci character_set_server=utf8 EOF $ cat <<'EOF' > /opt/apps/my_mysql/init-sql/01_schema.sql -- 1. 允许 root 账号从任何远程 IP (%) 登录,并锁定密码和传统认证插件 ALTER USER 'root' @'%' IDENTIFIED WITH 'mysql_native_password' BY '123456' ; GRANT ALL PRIVILEGES ON *.* TO 'root' @'%' WITH GRANT OPTION; FLUSH PRIVILEGES; -- 2. 顺手兜底:激活你在 YML 里创建的业务账号 owlias,确保它也能远程连接并拥有 db03 的完全控制权 ALTER USER 'owlias' @'%' IDENTIFIED WITH 'mysql_native_password' BY '123456' ; GRANT ALL PRIVILEGES ON db03.* TO 'owlias' @'%' ; FLUSH PRIVILEGES; -- 3. 创建数据库 CREATE DATABASE IF NOT EXISTS db03; USE db03; -- 4. 创建表 CREATE TABLE IF NOT EXISTS `t_user` ( `id ` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '用户名' , `password` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '密码' , `sex` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '性别 0=女 1=男 ' , `deleted` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '删除标志,默认0不删除,1删除' , `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' , `create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' , PRIMARY KEY (`id `) ) ENGINE=INNODB AUTO_INCREMENT=1114 DEFAULT CHARSET=utf8mb4 COMMENT='用户表' ; EOF $ mkdir -p /opt/apps/my_redis/conf $ cat <<EOF > /opt/apps/my_redis/conf/redis.conf # 允许任意 IP 连接访问 bind 0.0.0.0 # 非守护模式启动 daemonize no # 关闭保护模式,允许外部网络访问 protected-mode no # 开启 AOF 持久化(强力数据保护) appendonly yes # 设置你的强 Redis 访问密码 requirepass 123456 EOF $ mkdir -p /opt/apps/app_user $ cp /root/docker-app-user.jar /opt/apps/app_user/app/app.jar
执行 docker-compose.yml
如果存在上一次启动失败了,比如本地的命名数据卷 my_mysql_data 里面已经是一堆残缺不全的死锁系统文件了。如果不清空,直接再次 up 依然会报错。请在终端果断敲入以下命令,将失败的容器与污染的卷连根拔起:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 $ docker logs my_mysql_3300 $ docker compose down -v $ rm -rf /opt/apps/my_mysql/log/* $ rm -rf /opt/apps/app_user/logs $ docker compose up -d [+] up 6/6 ✔ Network backend-net Created 0.2s ✔ Volume app_user_my_mysql_data Created 0.0s ✔ Volume app_user_my_redis_data Created 0.0s ✔ Container my_mysql_3300 Healthy 11.8s ✔ Container my_redis_6370 Healthy 11.8s ✔ Container app_user_8081 Started 12.7s $ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS app_user_8081 app_user:v1.0 "sh -c 'java $JAVA_O …" app_user About a minute ago Up About a minute 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp my_mysql_3300 mysql:8.4.9-oraclelinux9 "docker-entrypoint.s…" my_mysql About a minute ago Up About a minute (healthy) 33060/tcp, 0.0.0.0:3300->3306/tcp, [::]:3300->3306/tcp my_redis_6370 redis:6.0.8 "docker-entrypoint.s…" my_redis About a minute ago Up About a minute (healthy) 0.0.0.0:6370->6379/tcp, [::]:6370->6379/tcp
常用指令快查 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 $ docker compose up $ docker compose up -d $ docker compose up -d --build $ docker compose stop $ docker compose down $ docker compose down -v $ docker compose stop my_redis_6370 $ docker compose rm -f my_redis_6370 $ docker compose up -d my_redis_6370 $ docker compose ps $ docker compose logs -f app_user_8081
附 app-user 应用源码 pom.xml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.3.5</version > <relativePath /> </parent > <groupId > com.demo.user</groupId > <artifactId > docker-app-user</artifactId > <version > 1.0-SNAPSHOT</version > <properties > <java.version > 17</java.version > <mybatis.version > 3.0.3</mybatis.version > <springdoc.version > 2.6.0</springdoc.version > <lombok.version > 1.18.30</lombok.version > <mapstruct.version > 1.5.5.Final</mapstruct.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.mybatis.spring.boot</groupId > <artifactId > mybatis-spring-boot-starter</artifactId > <version > ${mybatis.version}</version > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > ${lombok.version}</version > <scope > provided</scope > </dependency > <dependency > <groupId > org.mapstruct</groupId > <artifactId > mapstruct</artifactId > <version > ${mapstruct.version}</version > </dependency > <dependency > <groupId > org.springdoc</groupId > <artifactId > springdoc-openapi-starter-webmvc-ui</artifactId > <version > ${springdoc.version}</version > </dependency > </dependencies > <build > <finalName > docker-app-user</finalName > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > <configuration > <excludes > <exclude > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </exclude > </excludes > </configuration > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <configuration > <source > ${java.version}</source > <target > ${java.version}</target > <annotationProcessorPaths > <path > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > ${lombok.version}</version > </path > <path > <groupId > org.mapstruct</groupId > <artifactId > mapstruct-processor</artifactId > <version > ${mapstruct.version}</version > </path > </annotationProcessorPaths > </configuration > </plugin > </plugins > </build > </project >
日志配置 src/main/resources/logback-spring.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 <?xml version="1.0" encoding="UTF-8" ?> <configuration scan ="true" scanPeriod ="60 seconds" > <property name ="LOG_PATH" value ="/data/logs" /> <property name ="CONSOLE_LOG_PATTERN" value ="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:- }) --- [%15.15thread] %cyan(%-40.40logger{39}) : %m%n" /> <property name ="FILE_LOG_PATTERN" value ="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:- } --- [%thread] %logger{50} - [%method,%line] - %m%n" /> <appender name ="CONSOLE" class ="ch.qos.logback.core.ConsoleAppender" > <encoder > <pattern > ${CONSOLE_LOG_PATTERN}</pattern > <charset > UTF-8</charset > </encoder > </appender > <appender name ="INFO_FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender" > <file > ${LOG_PATH}/sys-info.log</file > <rollingPolicy class ="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy" > <fileNamePattern > ${LOG_PATH}/archive/sys-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern > <maxFileSize > 100MB</maxFileSize > <maxHistory > 30</maxHistory > <totalSizeCap > 30GB</totalSizeCap > </rollingPolicy > <encoder > <pattern > ${FILE_LOG_PATTERN}</pattern > <charset > UTF-8</charset > </encoder > <filter class ="ch.qos.logback.classic.filter.LevelFilter" > <level > ERROR</level > <onMatch > DENY</onMatch > <onMismatch > ACCEPT</onMismatch > </filter > </appender > <appender name ="ERROR_FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender" > <file > ${LOG_PATH}/sys-error.log</file > <rollingPolicy class ="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy" > <fileNamePattern > ${LOG_PATH}/archive/sys-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern > <maxFileSize > 100MB</maxFileSize > <maxHistory > 60</maxHistory > </rollingPolicy > <encoder > <pattern > ${FILE_LOG_PATTERN}</pattern > <charset > UTF-8</charset > </encoder > <filter class ="ch.qos.logback.classic.filter.ThresholdFilter" > <level > ERROR</level > </filter > </appender > <root level ="INFO" > <appender-ref ref ="CONSOLE" /> <appender-ref ref ="INFO_FILE" /> <appender-ref ref ="ERROR_FILE" /> </root > </configuration >
应用配置文件 src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 server: port: 8081 spring: application: name: docker-app-user profiles: active: prod mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.user.model.entity configuration: map-underscore-to-camel-case: true
src/main/resources/application-dev.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.1.8:3306/db03?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 data: redis: host: 192.168 .1 .8 port: 6379 password: 123456 timeout: 5000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 springdoc: api-docs: enabled: true path: /v3/api-docs swagger-ui: enabled: true operations-sorter: alpha
src/main/resources/application-prod.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://my_mysql_3300:3306/db03?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: owlias password: 123456 data: redis: host: my_redis_6370 port: 6379 password: 123456 timeout: 5000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 springdoc: api-docs: enabled: false path: /v3/api-docs swagger-ui: enabled: false operations-sorter: alpha
启动类 1 2 3 4 5 6 @SpringBootApplication public class App { public static void main (String[] args) { SpringApplication.run(App.class, args); } }
配置类 RedisConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 package com.demo.user.config;import com.fasterxml.jackson.annotation.JsonAutoDetect;import com.fasterxml.jackson.annotation.PropertyAccessor;import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;import org.springframework.data.redis.serializer.StringRedisSerializer;@Configuration public class RedisConfig { public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper () .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); @Bean public RedisTemplate<String, Object> redisTemplate (RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate <>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer <>(OBJECT_MAPPER, Object.class); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer (); template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }
SwaggerConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.demo.user.config;import io.swagger.v3.oas.models.OpenAPI;import io.swagger.v3.oas.models.info.Info;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration public class SwaggerConfig implements WebMvcConfigurer { @Bean public OpenAPI userMicroserviceOpenAPI () { return new OpenAPI () .info(new Info () .title("用户中心微服务 API 文档" ) .description("对外暴露的用户新增与主键查询微服务接口" ) .version("v1.0" )); } }
model 类 Result
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package com.demo.user.model.common;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;import java.io.Serializable;@Data @Schema(description = "全局统一响应包装对象") public class Result <T> implements Serializable { @Schema(description = "状态码 (200成功,其他失败)") private int code; @Schema(description = "提示信息/错误描述") private String message; @Schema(description = "承载的业务数据") private T data; @Schema(description = "错误追踪码 (失败时用于前后端日志对齐排查)") private String traceId; public static <T> Result<T> success (T data) { Result<T> result = new Result <>(); result.setCode(200 ); result.setMessage("success" ); result.setData(data); return result; } public static <T> Result<T> fail (int code, String message, String traceId) { Result<T> result = new Result <>(); result.setCode(code); result.setMessage(message); result.setTraceId(traceId); return result; } }
ResultCode
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 package com.demo.user.model.common;import lombok.Getter;@Getter public enum ResultCode { SUCCESS(200 , "操作成功" ), PARAM_ERROR(400 , "参数校验未通过" ), UNAUTHORIZED(401 , "暂未登录或Token失效" ), FORBIDDEN(403 , "没有相关操作权限" ), NOT_FOUND(404 , "请求的资源不存在" ), USER_NOT_EXIST(5001 , "该用户在系统内不存在" ), USER_ALREADY_EXIST(5002 , "用户名已被占用" ), SYSTEM_ERROR(500 , "系统内部服务器大崩溃,请联系运维排查" ); private final int code; private final String message; ResultCode(int code, String message) { this .code = code; this .message = message; } }
BizException
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.demo.user.model.ex;import com.demo.user.model.common.ResultCode;import lombok.Getter;@Getter public class BizException extends RuntimeException { private final int code; public BizException (ResultCode resultCode) { super (resultCode.getMessage()); this .code = resultCode.getCode(); } public BizException (int code, String message) { super (message); this .code = code; } }
User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.demo.user.model.entity;import lombok.Data;import java.io.Serializable;import java.time.LocalDateTime;@Data public class User implements Serializable { private Integer id; private String username; private String password; private Integer sex; private Integer deleted; private LocalDateTime updateTime; private LocalDateTime createTime; }
UserDTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package com.demo.user.model.dto;import io.swagger.v3.oas.annotations.media.Schema;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.NotNull;import jakarta.validation.constraints.Size;import lombok.Data;import org.hibernate.validator.constraints.Range;@Data @Schema(description = "用户业务传输对象") public class UserDTO { @Schema(description = "用户ID (新增时为空)") private Integer id; @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "用户名不能为空") @Size(min = 4, max = 20, message = "用户名长度必须在 4 到 20 个字符之间") private String username; @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "密码不能为空") @Size(min = 6, max = 30, message = "密码长度必须在 6 到 30 个字符之间") private String password; @Schema(description = "性别 0=女 1=男", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "性别不能为空") @Range(min = 0, max = 1, message = "性别输入非法,0代表女,1代表男") private Integer sex; }
UserConvert
1 2 3 4 5 6 7 8 9 10 11 package com.demo.user.model.convert;import com.demo.user.model.dto.UserDTO;import com.demo.user.model.entity.User;import org.mapstruct.Mapper;@Mapper(componentModel = "spring") public interface UserConvert { User toEntity (UserDTO dto) ; UserDTO toDTO (User user) ; }
mapper dao UserMapper
1 2 3 4 5 6 7 8 9 10 11 package com.demo.user.mapper;import com.demo.user.model.entity.User;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;@Mapper public interface UserMapper { int insertUser (User user) ; User selectById (@Param("id") Integer id) ; }
src/main/resources/mapper/UserMapper.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.demo.user.mapper.UserMapper" > <insert id ="insertUser" useGeneratedKeys ="true" keyProperty ="id" > INSERT INTO t_user (username, password, sex, deleted) VALUES (#{username}, #{password}, #{sex}, 0) </insert > <select id ="selectById" resultType ="com.demo.user.model.entity.User" > SELECT id, username, password, sex, deleted, update_time, create_time FROM t_user WHERE id = #{id} AND deleted = 0 </select > </mapper >
service UserService
1 2 3 4 5 6 7 8 package com.demo.user.service;import com.demo.user.model.dto.UserDTO;public interface UserService { UserDTO addUser (UserDTO userDTO) ; UserDTO findUserById (Integer id) ; }
UserServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 package com.demo.user.service.impl;import com.demo.user.config.RedisConfig;import com.demo.user.mapper.UserMapper;import com.demo.user.model.common.ResultCode;import com.demo.user.model.convert.UserConvert;import com.demo.user.model.dto.UserDTO;import com.demo.user.model.entity.User;import com.demo.user.model.ex.BizException;import com.demo.user.service.UserService;import jakarta.annotation.Resource;import lombok.extern.slf4j.Slf4j;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.util.concurrent.TimeUnit;@Slf4j @Service public class UserServiceImpl implements UserService { private static final String CACHE_KEY_PREFIX = "user:cache:id:" ; private static final long CACHE_TTL = 60 ; @Resource private UserMapper userMapper; @Resource private UserConvert userConvert; @Resource private RedisTemplate<String, Object> redisTemplate; @Override @Transactional(rollbackFor = Exception.class) public UserDTO addUser (UserDTO userDTO) { User user = userConvert.toEntity(userDTO); userMapper.insertUser(user); UserDTO resultDTO = userConvert.toDTO(user); String cacheKey = CACHE_KEY_PREFIX + resultDTO.getId(); redisTemplate.opsForValue().set(cacheKey, resultDTO, CACHE_TTL, TimeUnit.MINUTES); log.info("用户数据成功写入 DB 与 Redis 缓存,UserID: {}" , resultDTO.getId()); return resultDTO; } @Override public UserDTO findUserById (Integer id) { String cacheKey = CACHE_KEY_PREFIX + id; Object cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null ) { log.info("命中 Redis 缓存,直接返回用户数据,UserID: {}" , id); if (cachedValue instanceof java.util.Map) { return RedisConfig.OBJECT_MAPPER.convertValue(cachedValue, UserDTO.class); } return (UserDTO) cachedValue; } log.warn("❄️ 缓存未命中,开始下沉穿透查询数据库,UserID: {}" , id); User user = userMapper.selectById(id); if (user == null ) { throw new BizException (ResultCode.USER_NOT_EXIST); } UserDTO resultDTO = userConvert.toDTO(user); redisTemplate.opsForValue().set(cacheKey, resultDTO, CACHE_TTL, TimeUnit.MINUTES); return resultDTO; } }
controller GlobalExceptionHandler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 package com.demo.user.controller.ex;import com.demo.user.model.common.Result;import com.demo.user.model.common.ResultCode;import com.demo.user.model.ex.BizException;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.validation.FieldError;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.resource.NoResourceFoundException;import java.util.HashMap;import java.util.Map;import java.util.UUID;@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) @ResponseStatus(HttpStatus.OK) public Result<Void> handleBizException (BizException e) { String traceId = generateTraceId(); log.warn("[BizException] 业务链阻断 -> TraceID: {}, Code: {}, Message: {}" , traceId, e.getCode(), e.getMessage()); return Result.fail(e.getCode(), e.getMessage(), traceId); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Result<Map<String, String>> handleValidationExceptions (MethodArgumentNotValidException ex) { String traceId = generateTraceId(); Map<String, String> errors = new HashMap <>(); ex.getBindingResult().getAllErrors().forEach((error) -> { String fieldName = ((FieldError) error).getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); }); log.warn("[ValidationException] 参数拦截 -> TraceID: {}, Details: {}" , traceId, errors); return Result.fail(ResultCode.PARAM_ERROR.getCode(), ResultCode.PARAM_ERROR.getMessage() + ": " + errors, traceId); } @ExceptionHandler(NoResourceFoundException.class) public Result<Void> handleNoResourceFoundException (NoResourceFoundException e) throws NoResourceFoundException { String resourcePath = e.getResourcePath(); if (resourcePath != null && (resourcePath.contains("swagger-ui" ) || resourcePath.contains("api-docs" ) || resourcePath.contains("knife4j" ))) { throw e; } if ("favicon.ico" .equals(resourcePath)) { return Result.success(null ); } String traceId = org.slf4j.MDC.get("traceId" ); if (traceId == null || traceId.isEmpty()) { traceId = generateTraceId(); } log.warn("[404NotFound] 用户访问了不存在的资源或路由 -> TraceID: {}, ResourcePath: {}" , traceId, resourcePath); return Result.fail(ResultCode.NOT_FOUND.getCode(), "请求的接口路径或静态资源不存在: " + resourcePath, traceId); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Result<Void> handleException (Exception e) { String traceId = generateTraceId(); log.error("[SystemError] 发现未捕获的异常!! TraceID: " + traceId + ",原因: " , e); return Result.fail(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMessage(), traceId); } private String generateTraceId () { return UUID.randomUUID().toString().replace("-" , "" ).substring(0 , 16 ); } }
UserController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package com.demo.user.controller;import com.demo.user.model.common.Result;import com.demo.user.model.dto.UserDTO;import com.demo.user.service.UserService;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.tags.Tag;import jakarta.annotation.Resource;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.*;@Tag(name = "用户管理接口", description = "提供用户的新增和根据 ID 查询功能") @RestController @RequestMapping("/api/v1/user") public class UserController { @Resource private UserService userService; @Operation(summary = "新增用户", description = "数据经过严格参数校验后,同步写入数据库和缓存") @PostMapping public Result<UserDTO> addUser (@Validated @RequestBody UserDTO userDTO) { UserDTO createdUser = userService.addUser(userDTO); return Result.success(createdUser); } @Operation(summary = "根据 ID 查询用户", description = "优先查询 Redis 缓存,未命中则下沉穿透到 MySQL") @GetMapping("/{id}") public Result<UserDTO> findUserById (@Parameter(description = "用户主键ID", required = true) @PathVariable("id") Integer id) { UserDTO userDTO = userService.findUserById(id); return Result.success(userDTO); } }
标题:
Docker 基础(四)- Compose