Spring Cloud 学习与实践(2):用户服务开发

本章目标:开发第一个独立运行的业务服务 cloud-user,接入 Spring Boot Web、MySQL 和 MyBatis-Plus,实现用户 CRUD 接口;同时通过一次分页失效故障演练,理解 MyBatis-Plus 分页插件的工作机制。


1. 本章目标

上一章已经完成了 Spring Cloud Alibaba 微服务项目的 Maven 多模块骨架,并在 cloud-common 中建立了统一返回、错误码、业务异常和全局异常处理等公共能力。

本章进入第一个业务服务:

cloud-user

主要完成以下内容:

1. 为 cloud-user 添加 Spring Boot Web、MyBatis-Plus 和 MySQL 依赖
2. 创建 cloud_demo 数据库和 t_user 用户表
3. 配置 application.yml
4. 创建 cloud-user 启动类
5. 创建 User 实体类
6. 创建 UserMapper
7. 创建 UserService 和 UserServiceImpl
8. 创建 UserController
9. 使用 IDEA HTTP Client 测试接口
10. 故意不配置分页拦截器,观察分页失效现象
11. 添加 MyBatisPlusConfig,修复分页问题

本章暂时只开发一个独立运行的 Spring Boot 服务。

暂时不接入:

Nacos
OpenFeign
Gateway
JWT
Sentinel
Redis
RabbitMQ

这些能力将在后续章节中逐步加入。


2. 为什么先开发用户服务

微服务架构并不是一开始就把所有组件全部接入。

如果同时引入注册中心、配置中心、网关、远程调用、限流熔断和消息队列,出现问题时很难判断故障来自哪一个环节。

因此,本课程采用逐步演进方式:

先开发一个可以独立运行的 Spring Boot 服务
        ↓
确认数据库访问、业务分层、统一返回正常
        ↓
再开发商品服务和订单服务
        ↓
再接入 Nacos 注册中心
        ↓
再接入 OpenFeign 完成服务间调用

本章选择用户服务作为第一个业务服务,因为它具备典型的 CRUD 特征,适合验证:

Spring Boot 启动
MySQL 数据源
MyBatis-Plus
Controller / Service / Mapper 分层
统一返回 Result<T>
分页插件

一句话总结:

先让一个服务独立跑通,再逐步增加微服务治理能力。

3. cloud-user 模块结构

本章完成后的 cloud-user 目录结构如下:

cloud-user
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com.example.cloud.user
    │   │       ├── CloudUserApplication.java
    │   │       ├── config
    │   │       │   └── MyBatisPlusConfig.java
    │   │       ├── controller
    │   │       │   └── UserController.java
    │   │       ├── entity
    │   │       │   └── User.java
    │   │       ├── mapper
    │   │       │   └── UserMapper.java
    │   │       └── service
    │   │           ├── UserService.java
    │   │           └── impl
    │   │               └── UserServiceImpl.java
    │   └── resources
    │       └── application.yml
    └── test
    			└── java
		        └── http
		            └── user.http

各目录职责如下:

目录 作用
entity 数据库表对应的实体类
mapper 数据访问层,负责操作数据库
service 业务逻辑接口
service.impl 业务逻辑实现
controller HTTP 接口入口
config Spring 和 MyBatis-Plus 配置
resources 配置文件
test/http IDEA HTTP Client 接口测试文件

4. 创建数据库和用户表

首先在本地 MySQL 中创建数据库:

CREATE DATABASE IF NOT EXISTS cloud_demo
DEFAULT CHARACTER SET utf8mb4;

切换数据库:

USE cloud_demo;

创建用户表:

DROP TABLE IF EXISTS t_user;

CREATE TABLE t_user (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
    username VARCHAR(64) NOT NULL COMMENT '用户名',
    nickname VARCHAR(64) DEFAULT NULL COMMENT '昵称',
    phone VARCHAR(20) DEFAULT NULL COMMENT '手机号',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

插入两条初始测试数据:

INSERT INTO t_user (username, nickname, phone, status)
VALUES
('zhangsan', '张三', '13800000001', 1),
('lisi', '李四', '13800000002', 1);

为了测试分页功能,再插入三条数据:

INSERT INTO t_user (username, nickname, phone, status)
VALUES
('wangwu', '王五', '13800000003', 1),
('zhaoliu', '赵六', '13800000004', 1),
('sunqi', '孙七', '13800000005', 1);

最终表中共有 5 条用户数据。

在这里插入图片描述

5. cloud-user 的 pom.xml

cloud-user/pom.xml 中添加依赖:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example.cloud</groupId>
        <artifactId>cloud-demo</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>cloud-user</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <!-- 公共模块:统一返回、异常、错误码 -->
        <dependency>
            <groupId>com.example.cloud</groupId>
            <artifactId>cloud-common</artifactId>
            <version>${project.version}</version>
        </dependency>

        <!-- Spring Boot Web,用于提供 HTTP 接口 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- MyBatis-Plus,Spring Boot 2 使用 mybatis-plus-boot-starter -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>

依赖作用如下:

依赖 作用
cloud-common 使用统一返回、错误码和全局异常处理
spring-boot-starter-web 提供 Controller、Tomcat 和 Web 接口能力
mybatis-plus-boot-starter 简化 MyBatis 数据库操作
mysql-connector-java 连接 MySQL 数据库
lombok 减少 getter、setter 等样板代码

需要注意:

父工程 dependencyManagement 只负责统一版本。
cloud-user 仍然需要在 dependencies 中主动声明自己使用的依赖。

6. 配置 application.yml

在下面位置创建配置文件:

cloud-user
└── src
    └── main
        └── resources
            └── application.yml

配置如下:

server:
  port: 9200

spring:
  application:
    name: cloud-user

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/cloud_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: your_password

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: auto

主要配置说明:

配置项 作用
server.port cloud-user 服务端口
spring.application.name 服务名称,后续注册到 Nacos 时会使用
spring.datasource MySQL 数据源
mybatis-plus.configuration.log-impl 在控制台打印 SQL
mybatis-plus.global-config.db-config.id-type 主键使用数据库自增

本章将用户服务端口设置为:

9200

后续其他服务会使用不同端口:

Gateway:9000
Auth:9100
User:9200
Product:9300
Order:9400

7. 创建启动类

创建:

cloud-user
└── src/main/java
    └── com.example.cloud.user
        └── CloudUserApplication.java

代码如下:

package com.example.cloud.user;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(scanBasePackages = "com.example.cloud")
public class CloudUserApplication {

    public static void main(String[] args) {
        SpringApplication.run(CloudUserApplication.class, args);
    }
}

其中:

@SpringBootApplication(scanBasePackages = "com.example.cloud")

表示扫描:

com.example.cloud

包下的 Spring Bean。

这样不仅可以扫描 cloud-user 中的 Controller、Service 和配置类,也可以扫描 cloud-common 中的全局异常处理器。

如果只扫描默认路径:

com.example.cloud.user

那么 cloud-common 中的公共 Spring Bean 可能无法自动注册。


8. 创建 User 实体类

创建:

cloud-user
└── src/main/java
    └── com.example.cloud.user.entity
        └── User.java

代码如下:

package com.example.cloud.user.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@TableName("t_user")
public class User {

    @TableId(type = IdType.AUTO)
    private Long id;

    private String username;

    private String nickname;

    private String phone;

    private Integer status;

    private LocalDateTime createTime;
}

关键注解:

注解 作用
@Data Lombok 自动生成 getter、setter、toString 等方法
@TableName(“t_user”) 指定实体类对应的数据库表
@TableId(type = IdType.AUTO) 指定主键由数据库自增生成

MyBatis-Plus 默认支持下划线与驼峰命名转换:

create_time
        ↓
createTime

因此不需要额外添加:

@TableField("create_time")

9. 创建 UserMapper

创建:

cloud-user
└── src/main/java
    └── com.example.cloud.user.mapper
        └── UserMapper.java

代码如下:

package com.example.cloud.user.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.user.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

BaseMapper<User> 已经提供常见数据库操作:

selectById
selectList
insert
updateById
deleteById

因此,简单 CRUD 不需要手写 SQL,也不需要 XML 文件。

一句话总结:

Mapper 负责直接访问数据库。

10. 创建 Service

创建接口:

cloud-user
└── src/main/java
    └── com.example.cloud.user.service
        └── UserService.java

代码如下:

package com.example.cloud.user.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.user.entity.User;

public interface UserService extends IService<User> {
}

创建实现类:

cloud-user
└── src/main/java
    └── com.example.cloud.user.service.impl
        └── UserServiceImpl.java

代码如下:

package com.example.cloud.user.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.user.entity.User;
import com.example.cloud.user.mapper.UserMapper;
import com.example.cloud.user.service.UserService;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl
        extends ServiceImpl<UserMapper, User>
        implements UserService {
}

其中:

IService<User>

提供通用业务方法:

list
getById
save
updateById
removeById
page

而:

ServiceImpl<UserMapper, User>

将通用 Service 方法与 UserMapper 关联起来。

一句话总结:

Mapper 负责数据库访问。
Service 负责编排业务逻辑。

虽然当前示例中的业务逻辑比较简单,但仍然保留 Service 层。

原因是后续真实业务通常会出现:

参数校验
状态判断
事务控制
缓存操作
远程服务调用
消息发送

这些逻辑不适合全部堆在 Controller 中。


11. 创建 UserController

创建:

cloud-user
└── src/main/java
    └── com.example.cloud.user.controller
        └── UserController.java

代码如下:

package com.example.cloud.user.controller;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.user.entity.User;
import com.example.cloud.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    /**
     * 查询全部用户
     */
    @GetMapping
    public Result<List<User>> list() {
        List<User> users = userService.list();
        return Result.success(users);
    }

    /**
     * 根据 ID 查询用户
     */
    @GetMapping("/{id}")
    public Result<User> getById(@PathVariable Long id) {
        User user = userService.getById(id);

        if (user == null) {
            return Result.fail(ErrorCode.NOT_FOUND, "用户不存在");
        }

        return Result.success(user);
    }

    /**
     * 新增用户
     */
    @PostMapping
    public Result<Void> create(@RequestBody User user) {
        boolean success = userService.save(user);

        if (!success) {
            return Result.fail(ErrorCode.BIZ_ERROR, "新增用户失败");
        }

        return Result.success();
    }

    /**
     * 修改用户
     */
    @PutMapping
    public Result<Void> update(@RequestBody User user) {
        if (user.getId() == null) {
            return Result.fail(ErrorCode.PARAM_ERROR, "用户 ID 不能为空");
        }

        boolean success = userService.updateById(user);

        if (!success) {
            return Result.fail(ErrorCode.NOT_FOUND, "用户不存在或修改失败");
        }

        return Result.success();
    }

    /**
     * 删除用户
     */
    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        boolean success = userService.removeById(id);

        if (!success) {
            return Result.fail(ErrorCode.NOT_FOUND, "用户不存在或删除失败");
        }

        return Result.success();
    }

    /**
     * 分页查询用户
     */
    @GetMapping("/page")
    public Result<Page<User>> page(
            @RequestParam(defaultValue = "1") long current,
            @RequestParam(defaultValue = "10") long size) {

        Page<User> page = userService.page(new Page<>(current, size));
        return Result.success(page);
    }
}

接口列表如下:

请求方式 路径 作用
GET /users 查询全部用户
GET /users/{id} 根据 ID 查询用户
POST /users 新增用户
PUT /users 修改用户
DELETE /users/{id} 删除用户
GET /users/page 分页查询用户

Controller 中所有接口均返回:

Result<T>

统一返回格式如下:

{
  "code": 0,
  "message": "success",
  "data": {}
}

查询接口需要返回数据:

Result<User>
Result<List<User>>
Result<Page<User>>

新增、修改和删除接口只需要表示执行成功或失败,因此返回:

Result<Void>

12. 故障演练:分页插件未配置导致分页失效

12.1 故意不添加分页拦截器

完成 Controller 后,暂时不要创建:

MyBatisPlusConfig

直接启动 CloudUserApplication,访问:

GET http://localhost:9200/users/page?current=1&size=2

请求参数表示:

current = 1:查询第 1 页
size = 2:每页最多返回 2 条数据

理论上,第一页应该只返回:

zhangsan
lisi

但是实际响应中返回了全部 5 条用户数据:

{
  "code": 0,
  "message": "success",
  "data": {
    "records": [
      {
        "id": 1,
        "username": "zhangsan"
      },
      {
        "id": 2,
        "username": "lisi"
      },
      {
        "id": 3,
        "username": "wangwu"
      },
      {
        "id": 4,
        "username": "zhaoliu"
      },
      {
        "id": 5,
        "username": "sunqi"
      }
    ],
    "total": 0,
    "size": 2,
    "current": 1,
    "pages": 0
  }
}

请添加图片描述

可以观察到三个异常现象:

1. size 明明等于 2,但 records 返回了全部 5 条数据
2. total 等于 0
3. pages 等于 0

12.2 原因分析

Controller 中已经创建了分页对象:

new Page<>(current, size)

但是,Page 对象本身只是保存分页参数。

它记录:

当前页
每页数量
总记录数
总页数
分页结果

它不会自动修改 SQL。

真正负责改写 SQL 的是:

PaginationInnerInterceptor

没有配置分页拦截器时,数据库执行的 SQL 仍然接近:

SELECT id, username, nickname, phone, status, create_time
FROM t_user;

没有自动追加:

LIMIT 0, 2;

也不会执行统计总数的 SQL:

SELECT COUNT(*) AS total
FROM t_user;

一句话总结:

Page 负责保存分页参数,分页拦截器负责改写 SQL。

13. 修复分页问题:添加 MyBatisPlusConfig

创建:

cloud-user
└── src/main/java
    └── com.example.cloud.user.config
        └── MyBatisPlusConfig.java

代码如下:

package com.example.cloud.user.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        PaginationInnerInterceptor paginationInnerInterceptor =
                new PaginationInnerInterceptor(DbType.MYSQL);

        interceptor.addInnerInterceptor(paginationInnerInterceptor);

        return interceptor;
    }
}

重新启动 CloudUserApplication

再次请求:

GET http://localhost:9200/users/page?current=1&size=2

修复后的响应接近:

{
  "code": 0,
  "message": "success",
  "data": {
    "records": [
      {
        "id": 1,
        "username": "zhangsan",
        "nickname": "张三"
      },
      {
        "id": 2,
        "username": "lisi",
        "nickname": "李四"
      }
    ],
    "total": 5,
    "size": 2,
    "current": 1,
    "pages": 3
  }
}

此时:

records:只返回 2 条数据
total:总记录数为 5
size:每页数量为 2
current:当前页为 1
pages:总页数为 3

请添加图片描述

控制台中可以观察到类似 SQL:

SELECT COUNT(*) AS total
FROM t_user;

SELECT id, username, nickname, phone, status, create_time
FROM t_user
LIMIT 2;

14. 使用 IDEA HTTP Client 测试接口

在下面位置创建:

cloud-user
└── src
    └── test
        └── http
            └── user.http

内容如下:

### 查询全部用户
GET http://localhost:9200/users

### 根据 ID 查询用户
GET http://localhost:9200/users/1

### 查询不存在的用户
GET http://localhost:9200/users/999

### 分页查询:第一页
GET http://localhost:9200/users/page?current=1&size=2

### 分页查询:第二页
GET http://localhost:9200/users/page?current=2&size=2

### 新增用户
POST http://localhost:9200/users
Content-Type: application/json

{
  "username": "test_user",
  "nickname": "测试用户",
  "phone": "13800000006",
  "status": 1
}

### 修改用户
PUT http://localhost:9200/users
Content-Type: application/json

{
  "id": 6,
  "nickname": "修改后的昵称",
  "phone": "13800000007",
  "status": 1
}

### 删除用户
DELETE http://localhost:9200/users/6

创建http测试文件
在这里插入图片描述

IDEA HTTP Client 的优点:

测试文件可以直接保存在项目中
不需要频繁手动拼接 URL
可以和代码一起提交到版本库
适合当前阶段快速验证接口

本课程后续会在专门章节接入 Knife4j。

现阶段先使用 IDEA HTTP Client 测试接口,可以避免过早引入额外依赖和配置。


15. 本章常见问题

15.1 为什么要保留 Controller、Service、Mapper 三层结构?

即使当前示例业务简单,也不建议在 Controller 中直接调用 Mapper。

三层职责不同:

Controller:接收 HTTP 请求,返回 HTTP 响应
Service:处理业务逻辑
Mapper:访问数据库

后续加入缓存、事务、远程调用和消息队列后,Service 层会承载更多业务编排逻辑。


15.2 BaseMapper 和 IService 有什么区别?

BaseMapper<T> 属于数据访问层,提供基础 SQL 操作:

selectById
selectList
insert
updateById
deleteById

IService<T> 属于业务层,提供更适合业务代码调用的方法:

getById
list
save
updateById
removeById
page

本质关系:

Controller
    ↓
Service
    ↓
Mapper
    ↓
MySQL

15.3 为什么创建了 Page 对象,分页仍然不生效?

因为:

Page 只保存分页参数
PaginationInnerInterceptor 才负责修改 SQL

没有分页拦截器时,SQL 不会自动增加:

LIMIT

因此会返回全部数据。


15.4 为什么分页失效时 total 和 pages 都是 0?

因为分页拦截器没有生效时,MyBatis-Plus 不会自动执行总数统计 SQL:

SELECT COUNT(*)

所以:

total = 0
pages = 0

15.5 为什么 MyBatisPlusConfig 放在 cloud-user 中?

因为当前分页能力只在 cloud-user 中使用。

配置类放在:

com.example.cloud.user.config

可以被 CloudUserApplication 自动扫描。

后续如果多个服务都需要相同配置,可以再考虑:

抽取公共配置
建立 starter
复制最小配置

学习阶段先保持简单清晰。


15.6 为什么现在不接入 Knife4j?

Knife4j 在本课程中安排在后续专门章节统一接入。

当前阶段使用:

IDEA HTTP Client

已经足够验证接口。

等用户、商品、订单和网关服务基本完成后,再统一处理:

接口文档
接口分组
请求参数说明
统一返回展示
网关转发
多服务文档聚合

这样不会在当前章节引入过多额外配置,打断学习主线。


15.7 为什么启动类要扩大扫描范围?

启动类默认只扫描自身包和子包。

CloudUserApplication 位于:

com.example.cloud.user

而公共模块中的异常处理器位于:

com.example.cloud.common

如果不扩大扫描范围,两个包不是父子关系,公共模块中的 Spring Bean 可能不会被扫描。

因此使用:

@SpringBootApplication(scanBasePackages = "com.example.cloud")

统一扫描公共根包。


17. 本章结论

本章完成了第一个可以独立运行的 Spring Boot 业务服务:

cloud-user

当前项目已经具备:

MySQL 用户表
Spring Boot Web 接口
MyBatis-Plus 数据访问
Controller / Service / Mapper 分层
统一返回 Result<T>
用户 CRUD
分页查询
IDEA HTTP Client 接口测试
MyBatis-Plus 分页故障演练

本章不仅完成了用户服务功能,还通过分页失效问题理解了:

Page 对象与分页拦截器的职责区别
分页插件如何改写 SQL
为什么排查问题时要关注 SQL 日志

下一章将进入:

第 3 章:商品服务 cloud-product 开发

下一章会完成:

商品表设计
商品服务独立启动
商品详情查询
商品列表查询
库存扣减接口
库存不足业务校验
并发扣减库存风险分析
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐