用户注册、登录与基础会话

YangeIT大约 17 分钟高级服务框架用户模块短信验证码用户注册用户登录RedisJWT全局异常处理

用户注册、登录与基础会话

目标

  • 完成 user-service 微服务的基础搭建
  • 理解短信验证码在注册流程中的作用
  • 完成用户注册接口
  • 完成用户登录接口
  • 完成根据 token 获取当前用户信息接口
  • 学会使用全局异常处理统一返回错误结果

从这一篇开始,我们正式进入商城前台的用户模块。
前面的商品首页、分类、详情等接口,大多可以在“未登录”状态下访问;但从购物车、收藏、地址、下单开始,系统就必须知道“当前操作的人是谁 ”。

因此,用户模块的地位非常关键。它不只是多了一个“登录页面”,而是给整个商城补上“身份体系”。

本篇会先把最基础的三件事打通:

  1. 获取短信验证码
  2. 用户注册
  3. 用户登录并生成 token

1.用户微服务搭建

前言

用户相关的能力,通常会单独拆成一个 user-service 微服务,原因很简单:

  • 注册、登录、验证码、用户资料,都是高频独立业务
  • 后续购物车、订单、收藏、地址都会依赖用户身份
  • 把用户模块单独拆分后,职责更清晰,也便于后续扩展第三方登录、会员体系等能力

1.1.模块结构

注意:user-service 的父工程是 zx-service

image-20241218101033100
image-20241218101033100

建议模块结构保持简洁:

user-service
com.zx.user
├─ controller   表现层,对外提供接口
├─ service      业务层,编写真正的业务逻辑
├─ mapper       持久层,操作数据库
└─ utils        当前服务专属工具类(可选)

1.2.依赖说明

  • 如果短信发送工具类只在 user-service 中使用,那么第三方依赖放在 user-service 即可。
  • 如果你准备把短信工具抽到 zx-common 供多个微服务复用,那么相关依赖也应该跟着放到 zx-common

课堂工程里,短信验证码目前只给用户模块使用,放在 user-service 更容易理解。

依赖示例:

<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>com.squareup.okhttp3</groupId>
        <artifactId>okhttp</artifactId>
        <version>3.13.1</version>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.32</version>
    </dependency>
</dependencies>

1.3.配置文件与启动类

application.yml

server:
  port: 9002
spring:
  data:
    redis:
      host: localhost
      port: 6379
  application:
    name: user-service
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/db_zx?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC&rewriteBatchedStatements=true
    username: root
    password: 1234
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848

mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  type-aliases-package: com.zx.domain.entity.user
  global-config:
    datacenter-id: 1 # 数据中心
    workerId: 1 # 工作机器ID
  configuration: 
    map-underscore-to-camel-case: true # 开启驼峰命名
    cache-enabled: false # 禁用二级缓存
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

jwt:
  secretKey: abc  # 密钥
  expirationMinutes: 600   # 过期时间,单位:分钟

UserServiceApplication

package com.zx.user;


@Slf4j
@SpringBootApplication
@MapperScan("com.zx.user.mapper")
@ServletComponentScan("com.zx.common")
public class UserServiceApplication {

    public static void main(String[] args) throws UnknownHostException {
        SpringApplication app = new SpringApplication(UserServiceApplication.class);

        
        ConfigurableApplicationContext application = app.run(args);
        Environment env = application.getEnvironment();


        log.info("\n----------------------------------------------------------\n\t" +
                        "Application '{}' is running! Access URLs:\n\t" +
                        "Local: \t\thttp://localhost:{}\n\t" +
                        "External: \thttp://{}:{}\n\t" +
                        "Doc: \t\thttp://{}:{}/doc.html\n" +
                        "----------------------------------------------------------",
                env.getProperty("spring.application.name"),
                env.getProperty("server.port"),
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"),
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"));
    }

    //分页插件对象
    @Bean
    public PaginationInnerInterceptor paginationInnerInterceptor() {
        return new PaginationInnerInterceptor();
    }
}

为什么这里要保留 @ServletComponentScan("com.zx.common")

  • 因为后面我们会把一部分公共过滤器、异常处理等能力放到 zx-common
  • 如果不扫描公共包,这些组件不会生效

2.用户注册

在完成购物车、收藏、地址等功能之前,必须先让系统具备“创建用户”和“识别用户”的能力。
而注册功能里最关键的一步,就是先证明这个手机号确实归当前用户所有,所以要先做短信验证码。

使用MybatisX完成userinfo表的mapper,service,impl代码生成

记得将domain中的实体类删掉,因为实体类都在model模块中

前言

2.1.需求分析

当前课程采用“手机号注册”的方式完成会员注册。
完整注册流程如下:

  1. 前端输入手机号
  2. 点击“获取验证码”
  3. 后端生成验证码,保存到 Redis
  4. 调用短信服务发送验证码
  5. 用户填写验证码、昵称、密码
  6. 后端校验验证码并保存用户

如图所示: 流程图

68653759910
68653759910

涉及两个接口:

获取验证码:
GET /api/user/sms/sendCode/{phone}

提交注册:
POST /api/user/userInfo/register
{
    "username": "15019685678",
    "password": "111111",
    "nickName": "晴天",
    "code": "6799"
}

2.2.发送短信验证码

2.2.1.为什么验证码要放 Redis

验证码属于典型的“短期有效临时数据”:

  • 有效期短,一般 5 分钟左右
  • 查询频繁,但不需要长期持久化
  • 注册成功后可以立即删除

因此它非常适合放在 Redis 中,而不是放入数据库。

建议把验证码的 key 设计得更清晰一点,例如:

user:register:code:15019685678

2.2.2.短信发送流程

发送验证码的核心流程如下:

image-20230706171656448
image-20230706171656448

这里有一个很重要的设计思想:

  • 发短信 不是注册本身
  • 发短信 只是为了给注册流程提供“验证码凭证”

所以在代码层面,短信发送接口尽量只做三件事:

  1. 生成验证码
  2. 保存验证码
  3. 发送短信

不要把注册逻辑写进发送短信接口里。

2.2.3.SmsController

表现层代码:

package com.zx.user.controller;


@Tag(name = "用户短信相关接口")
@RestController
@RequestMapping("/user")
public class SmsController {

    @Autowired
    private UserInfoService userInfoService;

    @GetMapping("/sms/sendCode/{phone}")
    @Operation(summary = "发送短信验证码")
    @CrossOrigin
    public Result<Void> sendValidateCode(@PathVariable String phone) {
       return userInfoService.sendPhoneSmsCode(phone);
    }
}

2.2.4.UserInfoService

业务接口:

void sendPhoneSmsCode(String phone);

业务实现:

package com.zx.user.service.impl;
@Service
@Slf4j
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
    implements UserInfoService{

    private static final String REGISTER_CODE_PREFIX = "user:register:code:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public Result sendPhoneSmsCode(String phone) {
        if (StringUtils.isBlank(phone)) {
            return Result.build(null, ResultCodeEnum.DATA_ERROR);
        }
        // 1. 生成4位验证码
        String code = RandomStringUtils.randomNumeric(4);
        log.info("手机号:{},验证码:{}", phone, code);
        // 2. 保存到 Redis,5分钟过期
        redisTemplate.opsForValue().set(
                REGISTER_CODE_PREFIX + phone,
                code,
                5,
                TimeUnit.MINUTES
        );
        //todo 3. 调用第三方短信服务
        //
        return Result.build(null, ResultCodeEnum.SUCCESS);
    }
}

2.3.调用三方短信服务

2.3.1 云市场-短信API

开通三网106短信

在阿里云云市场搜索“短信”,一般都可用,选择一个即可,例如如下:点击“立即购买”开通

这里开通的是【短信验证码- 快速报备签名】open in new window

image-20230302152247156
image-20230302152247156

获取开发参数

登录云市场控制台open in new window,在已购买的服务中可以查看到所有购买成功的API商品情况,下图红框中的就是AppKey/AppSecret,AppCode的信息。

image-20230320093109432
image-20230320093109432

API方式使用云市场服务

官网示例代码:https://market.aliyun.com/products/57124001/cmapi00037170.html?spm=5176.2020520132.101.3.7d5f7218srVh72#sku=yuncode31170000018open in new window

参考如下例子,复制代码在test目录进行测试

image-20230302175615657
image-20230302175615657

2.3.2 发送短信流程说明

发送短信验证码的流程如下所示:

image-20230706171656448
image-20230706171656448

查看接口文档:

get /api/user/sms/sendCode/{phone}

2.3.4 发送短信接口开发

2.3.4.1 拷贝依赖

zx-common模块

<dependency>
	<groupId>com.squareup.okhttp3</groupId>
	<artifactId>okhttp</artifactId>
	<version>3.13.1</version>
</dependency>
<dependency>
	<groupId>cn.hutool</groupId>
	<artifactId>hutool-all</artifactId>
	<version>5.8.32</version>
</dependency>

<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>fastjson</artifactId>
	<version>1.2.15</version>
</dependency>
<dependency>
	<groupId>org.apache.httpcomponents</groupId>
	<artifactId>httpclient</artifactId>
	<version>4.2.1</version>
</dependency>
<dependency>
	<groupId>org.apache.httpcomponents</groupId>
	<artifactId>httpcore</artifactId>
	<version>4.2.1</version>
</dependency>
<dependency>
	<groupId>commons-lang</groupId>
	<artifactId>commons-lang</artifactId>
	<version>2.6</version>
</dependency>
<dependency>
	<groupId>org.eclipse.jetty</groupId>
	<artifactId>jetty-util</artifactId>
	<version>9.3.7.v20160115</version>
</dependency>
<dependency>
	<groupId>junit</groupId>
	<artifactId>junit</artifactId>
	<version>4.5</version>
	<scope>test</scope>
</dependency>
2.3.4.2 拷贝工具类(暂时没用,后期登录成功后发送token用)

复制甄选资料\资料\day07-甄选-用户注册和登录\资料\utils相关工具类到zx-common模块

包名com.zx.utils

将MyJwtUtils工具类的全限定名,复制到

resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中

com.zx.common.FastJsonLongToStringConfiguration
com.zx.common.Knife4jConfig
com.zx.utils.MyJwtUtils

将三方短信平台上的,粘贴到MySendMessageUtils中

image
image

接着在UserInfoServiceImpl的sendPhoneSmsCode方法中,调用MySendMessageUtils.sendSms方法发送短信

package com.zx.user.service.impl;
@Service
@Slf4j
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
    implements UserInfoService{

    private static final String REGISTER_CODE_PREFIX = "user:register:code:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public Result sendPhoneSmsCode(String phone) {
        if (StringUtils.isBlank(phone)) {
            return Result.build(null, ResultCodeEnum.DATA_ERROR);
        }
        // 1. 生成4位验证码
        String code = RandomStringUtils.randomNumeric(4);
        log.info("手机号:{},验证码:{}", phone, code);
        // 2. 保存到 Redis,5分钟过期
        redisTemplate.opsForValue().set(
                REGISTER_CODE_PREFIX + phone,
                code,
                5,
                TimeUnit.MINUTES
        );
        //todo 3. 调用第三方短信服务
        MySendMessageUtils.sendMessage(code, phone);

        return Result.build(null, ResultCodeEnum.SUCCESS);
    }
}

启动user-service模块,访问 http://localhost:9002/user/sms/sendCode/你的手机号码,open in new window 观察你的手机是否收到号码?

image
image

2.4.用户注册接口

2.4.1.注册流程说明

注册流程如下:

image-20230706171656448
image-20230706171656448

注意这个流程背后的业务意义:

  • 先查用户名是否重复:避免重复注册
  • 再校验验证码:确保手机号归属真实
  • 最后保存用户:正式入库

2.4.2.UserInfo 实体类

用户实体类示例:

@Data
@Schema(description = "用户实体类")
public class UserInfo extends BaseEntity {

    private static final long serialVersionUID = 1L;

    @Schema(description = "用户名")
    private String username;

    @Schema(description = "密码")
    private String password;

    @Schema(description = "昵称")
    private String nickName;

    @Schema(description = "头像")
    private String avatar;

    @Schema(description = "性别")
    private Integer sex;

    @Schema(description = "电话号码")
    private String phone;

    @Schema(description = "备注")
    private String memo;

    @Schema(description = "微信open id")
    private String openId;

    @Schema(description = "微信开放平台unionID")
    private String unionId;

    @Schema(description = "最后一次登录ip")
    private String lastLoginIp;

    @Schema(description = "最后一次登录时间")
    private Date lastLoginTime;

    @Schema(description = "状态:1正常,0禁用")
    private Integer status;
}

2.4.3.UserRegisterDto

@Data
@Schema(description = "注册对象")
public class UserRegisterDto {

    @Schema(description = "用户名")
    private String username;

    @Schema(description = "密码")
    private String password;

    @Schema(description = "昵称")
    private String nickName;

    @Schema(description = "手机验证码")
    private String code;
}

这里用 DTO 的意义要理解清楚:

  • 前端传来的注册参数,不一定和数据库实体一一对应
  • DTO 用来接收入参
  • Entity 用来持久化数据

不要直接让前端把数据库实体对象原样提交上来。

2.4.4.UserInfoController

package com.zx.user.controller;


@Tag(name = "会员用户接口")
@RestController
@RequestMapping("/user/userInfo")
@CrossOrigin(origins = "*")
public class UserInfoController {

    @Autowired
    private UserInfoService userInfoService;

    @Operation(summary = "用户注册")
    @PostMapping("/register")
    public Result<Void> register(@RequestBody UserRegisterDto dto) {
        userInfoService.register(dto);
        return Result.build(null, ResultCodeEnum.SUCCESS);
    }
}

2.4.5.UserInfoServiceImpl

@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
        implements UserInfoService {

    private static final String REGISTER_CODE_PREFIX = "user:register:code:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

   @Override
    public Result register(UserRegisterDto dto) {
        // 1. 参数校验
        if (dto == null
                || StringUtils.isBlank(dto.getUsername())
                || StringUtils.isBlank(dto.getPassword())
                || StringUtils.isBlank(dto.getCode())
                || StringUtils.isBlank(dto.getNickName())) {

           return Result.build(null, ResultCodeEnum.DATA_ERROR);
        }

        // 2. 用户名去重校验
        UserInfo existsUser = lambdaQuery()
                .eq(UserInfo::getUsername, dto.getUsername())
                .one();
        if (existsUser != null) {
            return Result.build(null, ResultCodeEnum.USER_NON_EXISTS);
        }

        // 3. 校验验证码
        String redisCode = redisTemplate.opsForValue().get(REGISTER_CODE_PREFIX + dto.getUsername());
        if (StringUtils.isBlank(redisCode)) {
          return Result.build(null, ResultCodeEnum.VALIDATECODE_ERROR);
        }
        if (!redisCode.equals(dto.getCode())) {
          return Result.build(null, ResultCodeEnum.VALIDATECODE_ERROR);
        }

        // 4. 密码加密
        // 课堂项目中先用 MD5 跑通流程;生产环境建议改为 BCrypt
        String passwordMd5 = DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8));

        // 5. 保存用户
        UserInfo userInfo = new UserInfo();
        BeanUtils.copyProperties(dto, userInfo);
        userInfo.setPassword(passwordMd5);
        userInfo.setPhone(dto.getUsername());
        userInfo.setStatus(1);
        userInfo.setAvatar("http://139.198.127.41:9000/sph/20230505/default_handsome.jpg");
        save(userInfo);

        // 6. 注册成功后删除验证码,避免重复使用
        redisTemplate.delete(REGISTER_CODE_PREFIX + dto.getUsername());
        return Result.build(null, ResultCodeEnum.SUCCESS);
    }
}

启动项目,打开前端页面,修改端口,测试注册页面。 image

点击设置,点击注册,输入手机号码,发送验证码,输入密码,昵称,点击注册

image
image
image
image

3.全局异常处理

如果接口里每次都 return Result.build(...),短期看起来没问题,但业务一复杂就会出现两个问题:

  1. 控制层、业务层到处充满重复判断
  2. 一旦某个地方直接抛异常,前端拿到的就是不友好的 500

因此更合理的做法是: 👍

  • 业务层发现问题时直接抛异常
  • 全局异常处理器统一拦截并封装响应结果

前言

zx-common模块中创建exception包,并定义两个类:CustomExceptionGlobalExceptionHandler

package com.zx.exception;

import com.zx.domain.vo.common.ResultCodeEnum;

public class CustomException extends RuntimeException {

    private final Integer code;
    private final String message;

    public CustomException(ResultCodeEnum resultCodeEnum) {
        this.code = resultCodeEnum.getCode();
        this.message = resultCodeEnum.getMessage();
    }

    public CustomException(String message, Integer code) {
        this.code = code;
        this.message = message;
    }

    @Override
    public String getMessage() {
        return message;
    }

    public Integer getCode() {
        return code;
    }
}
package com.zx.exception;

import com.zx.domain.vo.common.Result;
import com.zx.domain.vo.common.ResultCodeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public Result<Void> handleCustomException(CustomException e) {
        if (e.getCode() != null) {
            return Result.build(null, e.getCode(), e.getMessage());
        }
        return Result.build(null, 500, e.getMessage());
    }

    @ExceptionHandler(BindException.class)
    public Result<Void> handleBindException(BindException e) {
        log.error("参数绑定异常", e);
        return Result.build(null, ResultCodeEnum.PARAM_ERROR);
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.build(null, ResultCodeEnum.SYSTEM_ERROR);
    }
}

3.3.公共模块扫描说明

如果 GlobalExceptionHandler 放在 zx-common 中,就要确保它能被 Spring 扫描到。常见做法有两种:

  1. 启动类扩大扫描包范围
  2. zx-common 中提供自动装配配置类

这里采用在 zx-common 中提供自动装配配置类

resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

com.zx.common.FastJsonLongToStringConfiguration
com.zx.common.Knife4jConfig
com.zx.utils.MyJwtUtils
com.zx.exception.GlobalExceptionHandler

代码改造:

image
image

测试

未使用全局异常处理返回结果

image
image

使用全局异常处理返回结果

image
image

4.用户登录

注册完成之后,系统里只是“有了这个人”。
登录完成之后,系统才能在后续请求中持续识别“当前就是这个人”。

因此登录的核心不是“查库成功”,而是“生成凭证并维持会话状态”。

前言

4.1.登录流程说明

登录流程如下:

image-20230707194338122
image-20230707194338122

流程:

  1. 用户输入用户名和密码
  2. 后端校验账号密码
  3. 校验通过后生成 token
  4. 把用户信息写入 Redis
  5. token 返回给前端
  6. 前端后续访问接口时,把 token 放到请求头里

4.2.UserLoginDto

@Data
@Schema(description = "用户登录请求参数")
public class UserLoginDto {

    @Schema(description = "用户名")
    private String username;

    @Schema(description = "密码")
    private String password;
}

4.3.登录接口

控制层:

@Operation(summary = "用户登录")
@PostMapping("/login")
public Result<String> login(@RequestBody UserLoginDto dto) {
    String token = userInfoService.login(dto);
    return Result.build(token, ResultCodeEnum.SUCCESS);
}

业务实现:

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Autowired
private MyJwtUtils myJwtUtils;

@Override
public String login(UserLoginDto dto) {
    // 1. 参数校验
    if (dto == null
            || StringUtils.isBlank(dto.getUsername())
            || StringUtils.isBlank(dto.getPassword())) {
        throw new CustomException(ResultCodeEnum.PARAM_ERROR);
    }

    // 2. 查询用户
    UserInfo userInfo = lambdaQuery()
            .eq(UserInfo::getUsername, dto.getUsername())
            .one();
    if (userInfo == null) {
        throw new CustomException(ResultCodeEnum.USER_NON_EXISTS);
    }

    // 3. 校验密码
    String passwordMd5 = DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8));
    if (!passwordMd5.equals(userInfo.getPassword())) {
        throw new CustomException(ResultCodeEnum.LOGIN_ERROR);
    }

    // 4. 生成 token
    String token = myJwtUtils.generateToken(userInfo.getId().toString());

    // 5. 写入 Redis
    redisTemplate.opsForValue().set(
            "user:login:token:" + token,
            JSON.toJSONString(userInfo),
            10,
            TimeUnit.HOURS
    );

    return token;
}

4.4.根据 token 获取当前用户信息

很多页面在登录成功后,都会立即请求“当前用户信息”,用于渲染头像、昵称等内容。
因此还需要再补一个接口:

GET /api/user/userInfo/auth/getCurrentUserInfo

4.4.1.UserInfoVo

@Data
@Schema(description = "用户信息视图对象")
public class UserInfoVo {

    @Schema(description = "昵称")
    private String nickName;

    @Schema(description = "头像")
    private String avatar;
}

4.4.2.Controller

@Operation(summary = "根据token获取用户信息")
@GetMapping("/auth/getCurrentUserInfo")
public Result<UserInfoVo> getCurrentUserInfo(@RequestHeader("token") String token) {
    UserInfoVo userInfoVo = userInfoService.getCurrentUserInfo(token);
    return Result.build(userInfoVo, ResultCodeEnum.SUCCESS);
}

4.4.3.Service

@Override
public UserInfoVo getCurrentUserInfo(String token) {
    //1. 从 Redis 中获取用户信息
    String userInfoJsonStr = redisTemplate.opsForValue().get("user:login:token:" + token);
    if (StringUtils.isBlank(userInfoJsonStr)) {
        throw new CustomException(ResultCodeEnum.LOGIN_AUTH);
    }
    //2. 转换成对象
    UserInfo userInfo = JSON.parseObject(userInfoJsonStr, UserInfo.class);
    UserInfoVo vo = new UserInfoVo();
    vo.setNickName(userInfo.getNickName());
    vo.setAvatar(userInfo.getAvatar());
    return vo;
}
image
image

这里先采用“控制层从请求头取 token,再传给业务层”的方式,是为了让登录体系先跑通。
到了下一篇做网关鉴权时,我们会进一步优化成:

  • 网关统一校验 token
  • 微服务直接拿“当前登录用户”
  • 控制层不再手动解析 token

4.5.当前方案的意义

到这里,系统已经具备了最基础的“会话状态能力”:

  • 注册 解决了“用户从无到有”
  • 登录 解决了“如何证明你是谁”
  • token + Redis 解决了“后续请求如何持续识别你”

这三步打通后,后面的购物车、地址、收藏等模块就都能以“当前用户”为前提继续开发。

5.网关

前言

5.1.网关的作用

在微服务架构中,前端不应该直接请求每一个业务微服务,而应该统一请求“网关服务”。

网关就像园区门口的门卫:

  • 你是谁,要先核验
  • 你要去哪里,由门卫帮你转发
  • 不符合规则的请求,直接拦住
image-20241205205335023
image-20241205205335023

微服务中的网关主要承担三类职责:

  1. 请求路由
  2. 统一鉴权
  3. 跨域处理、限流、日志、灰度等横切能力

因此,网关并不是“可有可无的中转站”,它本身就是整个系统入口层的重要组成部分。

5.2.为什么鉴权要放到网关

如果仍然让每个微服务各自做登录校验,会出现这些问题:

  • 每个微服务都要解析 token
  • 每个微服务都要查一次 Redis
  • 每个微服务都要知道 JWT 规则
  • 同样的未登录处理逻辑会到处复制

把鉴权放到网关之后,优势就很明显:

  • 登录校验逻辑只写一次
  • 下游微服务只关注业务本身
  • 统一登录失败响应格式
  • 更方便后续继续叠加限流、黑名单、来源校验等能力

5.3.最终要实现什么

最终效果如下:

  1. 前端请求统一先到 zx-gateway
  2. 网关判断当前接口是否必须登录
  3. 如果必须登录,就从请求头取 token
  4. 再去 Redis 校验登录态
  5. 校验通过后,把当前用户信息放进请求头,继续转发
  6. 下游微服务收到请求后,再把用户信息放到 ThreadLocal
  7. 业务代码通过工具类直接获取当前登录用户

5.4.创建 zx-gateway

zx-front 下新建一个模块:zx-gateway

它本质上也是一个 Spring Boot 微服务,只不过它不负责商品、用户、订单等具体业务,而是负责“入口治理”。

5.5.引入依赖

<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>com.zx</groupId>
        <artifactId>zx-model</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
        <version>4.1.2</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        <version>4.0.4</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.48</version>
    </dependency>
</dependencies>

5.6.启动类

package com.zx.gateway;

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

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class GateWayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GateWayApplication.class, args);
    }
}

为什么这里排除 DataSourceAutoConfiguration

  • 因为网关当前不直接访问数据库
  • 如果不排除,Spring Boot 会尝试自动配置数据源
  • 没有数据库配置时,启动就可能报错

5.7.配置路由

application.yml

server:
  port: 10086
spring:
  data:
    redis:
      host: localhost
      port: 6379
  application:
    name: zx-gateway
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            allowCredentials: true
            allowedHeaders: '*'
            allowedMethods: '*'
            allowedOriginPatterns: '*'
            maxAge: 3600
      routes:
        - id: product
          uri: lb://product-service
          predicates:
            - Path=/api/product/**
          filters:
            - StripPrefix= 1
        # 用户微服务
        - id: user
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix= 1

5.8.StripPrefix=1 到底做了什么

以这个地址为例:

http://localhost:10086/api/user/userInfo/login

如果配置了:

- StripPrefix=1

那么转发给下游微服务时,网关会去掉第一个路径前缀 api,变成:

/user/userInfo/login

所以:

  • 网关对外地址是 /api/user/**
  • user-service 内部控制器路径要写成 /user/**

这就是“对外统一 API 前缀”和“服务内部路由前缀”之间的关系。

重新启动ProductApplication、UserApplication,GatewayApplication

访问商品首页 http://localhost:10086/api/product/indexopen in new window 发现可以访问了

image-20241218223318030
image-20241218223318030

页面测试

image
image

修改页面的baseUrl为网关地址 http://127.0.0.1:10086open in new window

经测试发现页面可以正常访问

6.小结

这一篇完成了用户模块最基础的身份体系闭环:

  1. user-service 基础环境搭建完成
  2. 完成短信验证码发送接口
  3. 完成用户注册接口
  4. 完成用户登录接口
  5. 完成根据 token 获取当前用户信息接口
  6. 完成全局异常处理
  7. 完成网关微服务 zx-gateway 搭建

接下来继续进入更关键的一步:
如何把“登录校验”从每个微服务里抽出来,统一放到网关做,这就是下一篇的重点。