SaaS短链接系统-新手从零学习 1

eve2333 发布于 2025-05-11 27 次阅读


 短链接(Short Link)是指将一个原始的长 URL(Uniform Resource Locator)通过特定的算法或服务转化为一个更短、易于记忆的URL。短链接通常只包含几个字符,而原始的长 URL 可能会非常长。

短链接的原理非常简单,通过一个原始链接生成个相对短的链接,然后通过访问短链接跳转到原始链接,典型的 URL 缩短服务(如 Bitly、TinyURL)通过网页直接提供服务,无需用户安装软件。用户按需使用其功能(生成短链、统计分析等)
和部分链接(https://pan.baidu.com/share/init?surl=rIpugCpTJmxd_anLBhnbcA&pwd=cyif)不同,不完全属于 SaaS,更偏向 IaaS/PaaS 混合模式

电商推广:短信、邮件 一般来说,一条短信最多70个汉字,140个字节。如果超出一般会被运营商自动拆分为2条短信,增加了运营成本。
社交网络分享:微博和Twitter都有140字数的限制,如果分享一个长链接,很容易就超出限制。短链接服务可以把一个长链接变成短链接,方便在社交网络上传播。

用户点击这个短链接后,会跳转到内部的长链接。基本流程图: 

客户端-->发出短链接请求--> 重定向跳转--->长链接

为什么使用302重定向

重定向 时效性 请求方式 优点

301永久第一次会重定向,下次直接从浏览器缓存拿到长链接就可跳转效率高
302临时每次请求都会请求短链接服务器,浏览器不会缓存方便统计入口链接的访问次数,短链接服务商主要盈利方式之一

如果更细节一些的话,那就是:

  1. 生成唯一标识符:当用户输入或提交一个长 URL 时,短链接服务会生成一个唯一的标识符或者短码。
  2. 将标识符与长 URL 关联:短链接服务将这个唯一标识符与用户提供的长 URL 关联起来,并将其保存在数据库或者其他持久化存储中。
  3. 创建短链接:将生成的唯一标识符加上短链接服务的域名(例如:http://nurl.ink/)作为前缀,构成一个短链接。
  4. 重定向:当用户访问该短链接时,短链接服务接收到请求后会根据唯一标识符查找关联的长 URL,然后将用户重定向到这个长 URL。
  5. 跟踪统计:一些短链接服务还会提供访问统计和分析功能,记录访问量、来源、地理位置等信息。

 http://url.nageoffer.com/3dO0PZ        类似这两个   https://nageoffer.com/docs/rocketmq/

短链接经常出现在咱们日常生活中,大家总是能在某些活动节日里收到各种营销短信,里边就会出现短链接。

淘宝、抖音、快手、微博等场景都需要短链接。大家在转发淘宝商品、抖音短视频时会有段文本,其中就有短链接。

举例:4.33 复制打开抖音,看看【雷军的作品】昨天的发布会,很多没有到场的好朋友也给我发了祝贺短... https://v.douyin.com/iFEJykTH/ M@J.vs 11/13 lPX:/

通过短链接帮助企业在营销活动中,识别用户行为、点击率等关键信息监控。

主要作用包括但不限于以下几个方面:

  1. 提升用户体验:用户更容易记忆和分享短链接,增强了用户的体验。
  2. 节省空间:短链接相对于长 URL 更短,可以节省字符空间,特别是在一些限制字符数的场合,如微博、短信等。
  3. 美化:短链接通常更美观、简洁,不会包含一大串字符。
  4. 统计和分析:可以追踪短链接的访问情况,了解用户的行为和喜好。

在系统设计中,采用最新 JDK17 + SpringBoot3&SpringCloud 微服务架构,构建高并发、大数据量下仍然能提供高效可靠的短链接生成服务。

通过学习短链接项目,不仅能了解其运作机制,还能接触最新技术体系带来的新特性,从而拓展技术视野并提升自身技术水平。

短链接项目采用 SaaS 方式开发。SaaS代表“软件即服务”(Software as a Service),与传统的软件模型不同,SaaS 不需要用户在本地安装和维护软件,而是通过互联网直接访问在线应用程序。

既然是 SaaS 系统,那势必会带来 N 多个问题。在我看来,问题即项目亮点。一起来看下:

  1. 海量并发:可能会面对大量用户同时访问的情况,尤其在高峰期,这会对系统的性能和响应速度提出很高的要求。
  2. 海量存储:可能需要存储大量的用户数据,包括数据库、缓存等,需要足够的存储空间和高效的存储管理方案。
  3. 多租户场景:通常支持多个租户共享同一套系统,需要保证租户间的数据隔离、安全性和性能。
  4. 数据安全性:需要保证用户数据的安全性和隐私,防止未经授权的访问和数据泄露。

 TPS:描述事务处理系统每秒处理的交易数量。测试短链接创建接口,峰值 12k/秒。
QPS:用于描述系统每秒处理的请求数量。测试短链接跳转接口,峰值 56k/秒。

短链接没有很复杂隐晦的技术,如果说通过 SpringBoot 做过一些项目就能很快上手。使用 SpringBoot 做过项目。在 SpringBoot 中操作过 Redis 缓存数据。

如果你做过这些,就可以开始短链接项目的学习啦。如果对短链接完全没有概念,可以学习以下文章有个印象

1. 一个企业级高并发短链接服务项目分享_牛客网

 2.转转短链平台设计与实现 - 掘金

3.100Wqps短链系统,怎么设计?这段时间,在整理知识星球中面试专栏时看到这么一个字节跳动的二面真题:100Wqps短链 - 掘金

4.京东短网址高可用提升最佳实践什么是短网址? 短网址,是在长度上比较短的网址。简单来说就是帮您把冗长的URL地址缩短成8个 - 掘金

项目包目录介绍 

接口文档

 没有采用swagger尤其是像我们这种比较小众的项目,就是就是项目的体量不是特别大,因为他有一些限制,而且对代码的侵入性比较大,推荐apifox的工具,API 文档、API 调试、API Mock、API 自动化测试,API一体化协作平台,当然有差距,但是他想做到Postman + Swagger + Mock + JMeter
目前 ShortLink 短链接系统开发完成的接口,已汇总至下述接口文档中,本地启动对应项目,通过接口文档访问查看效果。

根据用户名查找用户信息 - shortlink

如果需要通过 Apifox 直接调用,需要安装对应浏览器内网插件,考虑到一部分同学没有科学上网,这里把插件安装包带上,跟着教程安装即可使用。

安装文档

文档地址:浏览器扩展 | Apifox 帮助文档

从零到一创建短链接项目 

如图所示,依旧是祖传的springboot设置,采用maven,jdk17,组id随便写,如果你是公益项目,那么就org,否则是com ,中间是你的公司名称,后面就是你的项目名称了,于是就是com.nageoffer.shortlink

本次项目有许多子model(可以看一下了解一下Maven多模块 博客园),将我们的src删除,导入xml

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.0.7</spring-boot.version>
    <spring-cloud.version>2022.0.3</spring-cloud.version>
    <spring-cloud-alibaba.version>2022.0.0.0-RC2</spring-cloud-alibaba.version>
    <mybatis-spring-boot-starter.version>3.0.2</mybatis-spring-boot-starter.version>
    <shardingsphere.version>5.3.2</shardingsphere.version>
    <jjwt.version>0.9.1</jjwt.version>
    <fastjson2.version>2.0.36</fastjson2.version>
    <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
    <dozer-core.version>6.5.2</dozer-core.version>
    <hutool-all.version>5.8.20</hutool-all.version>
    <redisson.version>3.21.3</redisson.version>
    <guava.version>30.0-jre</guava.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${spring-cloud-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>

        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>shardingsphere-jdbc-core</artifactId>
            <version>${shardingsphere.version}</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>

        <dependency>
            <groupId>com.github.dozermapper</groupId>
            <artifactId>dozer-core</artifactId>
            <version>${dozer-core.version}</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool-all.version}</version>
        </dependency>

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>${redisson.version}</version>
        </dependency>

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

(可能出现的问题)此处 pom 使用maven更新依赖一直爆红是因为用的是 <dependencyManagement>,这个只是对包的版本进行限定,而不会下载,不用管它,后面在子模块的pom中一点点下载就可以了。

也可以先把<dependencyManagement>注释掉,然后再刷新,最后再加上就可以了

 从零到一创建短链接子模块项目

分为三个,一个是后台管理,一个是真正可被访问短链接带点统计,还有就是网关

创造子model前,我们需要在第10行填个

<packaging>pom</packaging>

 这意味在我们的shortlink-all的父模块不参与任何那个打包成jar包那种行为(打Jar包和War包区别 - 腾讯云),springboot执行package后,这个all只是起到一些规约作用,所以说不需要

新建module如下,

 admin的pom中部分properties没有必要,可以删除,将改为src.main.java.com.nageoffer.shortlink.admin

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

 shortlink-admin和shortlink-all中,idea已经为我们写好了modules和aetifactId;接下来我们创建链接系统model,名字就叫project,将artifactId改为shortlink-project,同样将这个的pom的properties删除

接下来写网关gateway

 形成这样的项目,

首先开发后管的分组功能, 在admin下开发controller,service,dao(持久层)和common,dto,dao下面还有mapper(关联mybatis和sql)和entiry实体层;common常量中有constant还有enums等;我们admin是要远程调用project的,因此是remote层,里面除了fin的接口外,还有remote 的专属dto。

比如说我们controller,接收请求以及返回请求数据,它都是需要载体的,对应dto来承载就是请求参数以及返回参数。接下来同样为project创建一模一样的层(不要remote)

 为admin和project的pom.xml添加引用

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
</dependencies>

 在父模块中我们已经整体的依赖都完了,因此在admin中直接继承指定;在三个子项目resource中,都添加application.yaml

server:
  port:8081

 从依赖关系上来讲,project是最底层的依赖,将他变成8001,网关承接上面,就8000,admin是8002

如果你的server一直爆波浪线错,可以用这个,把后面servlet删除即可
server:
  port: 8002
  servlet:
    context-path: /v2

 admin的controller中新建usercontroller文件

package com.nageoffer.shortlink.admin.controller;


import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class UserController {
    /*根据用户名查询用户信息*/
    @GetMapping("/api/shortlink/v1/user/{username}")
    public String getUserByUsername(@PathVariable String username) {
        return "hi"+username;
    }
}

用户模块功能分析

我们一定要先梳理清楚它这个模块对应的功能都有哪些。首先我们先一个去分析,首先第一个就是注册用户比较常见对不对?然后修改用户,因为你注册完之后可以修改,然后根据用户名查询用户,注册用户之前还有一步是需要检查用户名是否存在,因为我们有些情况下用户名是唯一的对不对?比如说我们的12306你去注册的时候,用户名就是唯一的,可以输数字员工英文字母标点符号我忘了能不能行了,但是它的用户名是唯一的,这样的话可以区分用户的唯一标识,然后根据用户名查询用户,然后用户登录检查用户是否登录,因为我们有时候你在去请求的时候,我们要检查用户是否登录状态,如果不是登录状态或者用户没有登录,我们是需要强制让用户去登录的,然后用户退出登录,然后注销用户,基本上我们通过这些常规的网站上去分析我们的用户模块,这些是非常基本的功能,通过这些就能够把一个用户模块给开发出来,我们用我们这里的短链接系统,用户模块不是最重要的,所以这个相当于是SARS里面的一环,其实我们短链接的核心不要用户都行,但是因为我们要做SARS这种系统,所以说我们需要用户的信息去创建这些短链接,然后这样的话才能够体现出来SARS特性好吧? 

功能分析

  • 检查用户名是否存在
  • 注册用户
  • 修改用户
  • 根据用户名查询用户
  • 用户登录
  • 检查用户是否登录
  • 用户退出登录
  • 注销用户

创建用户数据库表

新建数据库, 视频中详细一个一个在sql上生成,这里提供代码直接复制过去

CREATE TABLE `t_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `username` varchar(256) DEFAULT NULL COMMENT '用户名',
  `password` varchar(512) DEFAULT NULL COMMENT '密码',
  `real_name` varchar(256) DEFAULT NULL COMMENT '真实姓名',
  `phone` varchar(128) DEFAULT NULL COMMENT '手机号',
  `mail` varchar(512) DEFAULT NULL COMMENT '邮箱',
  `deletion_time` bigint(20) DEFAULT NULL COMMENT '注销时间戳',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

 为什么分库分表用户id就不能用int,而是要用bigint呀?因为后边会用用雪花算法生成用户id,雪花算法一般是64个二进制位(有效位63个),而在mysql中,int只占4个字节存不下,bigint占8个字节可以存下。

 查询用户信息功能

我们要先创建用户的这个NTT实体类,一般的话这种比较就是不要用我们去手把手的把那些代码敲出来的,直接代码生成器,网上有自动生成的代码(逆向生成工具用不习惯)老师用的那个jully.top已炸,用https://java.bejson.com/generator/

为持久层DO引入框架

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

持久层配置yaml文件 

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/link?characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&serverTimezone=GMT%2B8
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      connection-test-query: select 1
      connection-timeout: 20000
      idle-timeout: 300000
      maximum-pool-size: 5
      minimum-idle: 5

 因为要添加这个持久层的这个扫描的这个注解,就相当于去扫描你Mapper也就是我们持久层的那个接口对应的地址,为shortlinkadminapplication添加 (根据)一般啊我们像这种扫描的这种接口,一定要具体到某个包路径,可以去减少很多无用的扫描流程

@MapperScan("com.nageoffer.shortlink.admin.dao.mapper")

为shortlink-all的pom添加引用

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

 dependencies它是如果说我们在附类里面定义,或者说我们在类里面去定义,它是默认去下载这个依赖的,把这个依赖给传递到这个项目的依赖当中,也就会把这个包给默认下载下来。dependencyManagement它是不会去下载这个依赖,它只会去归约这个版本

 新建UserDO

package com.nageoffer.shortlink.admin.dao.entiry;

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

import java.util.Date;

@Data
@TableName("t_user")
public class UserDO {
    /**
     * id
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
     * ★密码
     */
    private String password;
    /*真实姓名
     */
    private String realName;
    /*手机号
     */
    private String phone;
    /**
     * 邮箱
     */
    private String mail;
    /**
     * 注销时间戳
     */
    private Long deletionTime;
    /*创建时间*/
    private Date createTime;
    /**
     * 修改时间
     */
    private Date updateTime;
    /**
     * 删除标识0:未删除1:已删除
     */
    private Integer delFlag;
}

 usermapper,extend下mybatisplus的basemapper,然后这里面的话会有一系列的默认的方法,就不需要我们再去写,省去了很多繁琐的流程

有的时候controller层里面以前都是会这么写:

@Autowired
private UserService userService;

但是这样写我觉得有一些不太方便的 ,它需要这是两行代码会爆波浪线对强迫症不友好;还有之前大家就是可能网上推荐说resource,但是因为resource在GDK17之后它做了一些改版,你在之前项目用到resource,那么你之你要给他去改对应的这个包目录比如import的jakarta.annotation.Resource;

推荐大家一种用构造器方式注入;我们用配合longbox的这种构造器注入

@RequiredArgsConstructor
public class UserController {
        private final UserService userService;

 service的mybatisplus也提供了接口,也可以继承一个;其实因为我们这里,我们继承的包括这个userservice里面的iservice,它也是一系列的这种增删改查,我们这里就不需要再去单独的去写

public class UserService extends IService<UserDO> {
}

定义DTO,因为我们就是数据库持久层对象 也就是DO是不能往前端去return,它每个领域之间的这个是不一样的,在dto文件夹下新建两个包req和resq,resq下新建为UserRespDTO.java

package com.nageoffer.shortlink.admin.dto.resq;

import lombok.Data;

@Data
public class UserRespDTO {
    /**
     * id
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
    真实姓名
     */
    private String realName;
    /**
     * 手机号
     */
    private String phone;
    /**
     * 邮箱
     */
    private String mail;
}

在数据库中创建一条记录

这时有一个编译上的问题,你可以修改idea的项目结构语言级别 来全部改为17级,不再用原本的只支持lambda的8,实际上你应该修改all的pom,补充如下

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.6.1</version>
            <configuration>
                <source>17</source>
                <target>17</target>
            </configuration>
        </plugin>
    </plugins>
</build>

 哦好的,现在为你提供UserController.java,UserRespDTO.java, UserDO.java, UserMapper.java,UserService.java ,UserServicelmpl.java这几个文件,成功运行(postman死了m一样)

package com.nageoffer.shortlink.admin.service.impl;


import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;


import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.admin.dao.entiry.UserDO;
import com.nageoffer.shortlink.admin.dao.mapper.UserMapper;
import com.nageoffer.shortlink.admin.dto.resq.UserRespDTO;
import com.nageoffer.shortlink.admin.service.UserService;
import org.springframework.beans.BeanUtils;

import org.springframework.stereotype.Service;

/**
 * 用户接口实现层
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {

    @Override
    public UserRespDTO getUserByUsername(String username) {
        LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
                .eq(UserDO::getUsername, username);
        UserDO userDO = baseMapper.selectOne(queryWrapper);
        if (userDO == null) {
            return null; // 直接返回 null,避免后续复制属性时报错
        }
        UserRespDTO result = new UserRespDTO();
        BeanUtils.copyProperties(userDO,result);
        //Spring 的 BeanUtils.copyProperties(Object source, Object target) 方法要求源对象在前,目标对象在后
        return result;
    }
}
package com.nageoffer.shortlink.admin.controller;


import com.nageoffer.shortlink.admin.dto.resq.UserRespDTO;
import com.nageoffer.shortlink.admin.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    /*根据用户名查询用户信息*/
    @GetMapping("/api/shortlink/v1/user/{username}")
    public UserRespDTO getUserByUsername(@PathVariable("username")String username) {
        return userService.getUserByUsername(username);
    }
}
public interface UserMapper extends BaseMapper<UserDO> {

}
package com.nageoffer.shortlink.admin.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.admin.dao.entiry.UserDO;
import com.nageoffer.shortlink.admin.dto.resq.UserRespDTO;
import org.springframework.stereotype.Service;

/**
 * 用户接口层
 */
@Service
public interface UserService extends IService<UserDO> {
    UserRespDTO getUserByUsername(String username);
}

统一全局返回实体对象

就是我们全局返回实体的一个概念,我们在这边去定义的这个查询用户的这个参数,它其实看着是没什么问题啊,首先现在impl中如果说能查出来用户是最好的,查不出来应该抛出异常,不能简单的抛出null,因为难以看出是报错了,还是找不到,找到多条,

 最基本的应该由这四部分信息组成的,code是状态码,比如成功失败,message里面是success或者error信息,data里面是本次请求返回的数据,success就是是否成功true和false

{
    "code": "0",
    "message": "success",
    "data": {
        "id": 1,
        "username": "tom",
        "realName": "汤姆",
        "phone": "15135350808",
        "mail": "1256723624@163.com"
    },
    "success": true
}

那这里可能大家会问一个问题,HTTP也有状态码200,500什么的,500是服务器异常,但是把没法通过拓展状态码来定位为什么,因为你HTTP它的这个扩展的这个有限

我们有一个这种实体,基本上是公用的,我们在common里面再加一层归约,这种约定性的内容,在common新建软件包convention.result,新建Result.java文件如下

package com.nageoffer.shortlink.admin.common.convention.result;

import lombok.Data;
import lombok.experimental.Accessors;

import java.io.Serial;
import java.io.Serializable;

/**
 * 全局返回对象
 */
@Data
@Accessors(chain = true)
public class Result<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = 5679018624309023727L;

    /**
     * 正确返回码
     */
    public static final String SUCCESS_CODE = "0";

    /**
     * 返回码
     */
    private String code;

    /**
     * 返回消息
     */
    private String message;

    /**
     * 响应数据
     */
    private T data;

    /**
     * 请求ID
     * 在复杂系统中是要和全链路ID去挂定的,单机系统拦截器直接生成md5
     */
    private String requestId;

    public boolean isSuccess() {
        return SUCCESS_CODE.equals(code);
    }
    /**
     * json的反序列化,有is开头的,他会给你序列化成字段
     */
}

修改UserController.java 

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    /*根据用户名查询用户信息*/
    @GetMapping("/api/shortlink/v1/user/{username}")
    public Result<UserRespDTO> getUserByUsername(@PathVariable("username")String username) {

        return new Result <UserRespDTO>().setCode("0").setData(userService.getUserByUsername(username));
    }
}

 如果是没找到,是null,应该添加

public class UserController {

    private final UserService userService;
    /*根据用户名查询用户信息*/
    @GetMapping("/api/shortlink/v1/user/{username}")
    public Result<UserRespDTO> getUserByUsername(@PathVariable("username")String username) {
        UserRespDTO result = userService.getUserByUsername(username);
        if(result==null){
            return new Result<UserRespDTO>().setCode("-1").setMessage("用户查询为空");
        }else{
            return new Result <UserRespDTO>().setCode("0").setData(result);
        }
    }
}

设置个-1它就会返回那个那个错误啊,因为issuccess里面SUCCESS_CODE它判断的是当前的这个返回码是不是等于0,不等于就是false,发一个不存在的查询,return -1

{
    "code": "-1",
    "message": "用户查询为空",
    "data": null,
    "requestId": null,
    "success": false
}

但是有2个问题,首先我们每次都要去new这种request,setdata不太好,应该用工具类的方法解决,还有就是不太可能在代码中result封装,controller里面不要写业务代码,尽量在impl里面写,那么我们应该抛异常和全局异常拦截器,这样在整体全局封装起来,有两个前置知识,一个是我们的异常码的设计,还一个是全局异常拦截器

业务全局异常码设计

当这个业务在运行过程当中,那么我们会通过code的方式来对业务的前端进行一个提示,以及让前端去进行一些流程判断,包括说我们如果说作为RPC微服务的一环,如果说你的上游服务调用了你接口之后,他们也可以根据你的这种异常的请求码来做出来一些那个对应异常的一些判断码

阿里巴巴泰山版说如下: 

异常码重点规范。

1)错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号。

说明:错误产生来源分为 A/B/C。A 表示错误来源于用户,比如参数错误,用户安装版本过低,用户支付

超时等问题;B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题;C 表示错误来源

于第三方服务,比如 CDN 服务出错,消息投递超时等问题。四位数字编号从 0001 到 9999,大类之间的步长间距预留 100。

2)编号不与公司业务架构,更不与组织架构挂钩,一切与平台先到先申请的原则进行,审批生效,编号即被永久固定。

异常码分类:一级宏观错误码、二级宏观错误码、三级详细错误码。

客户端异常。

错误码中文描述说明
A0001用户端错误一级宏观错误码
A0100用户注册错误二级宏观错误码
A0101用户未同意隐私协议
A0102注册国家或地区受限
A0110用户名校验失败
A0111用户名已存在
A0112用户名包含敏感词
xxxxxx
A0200用户登录异常二级宏观错误码
A02101用户账户不存在
A02102用户密码错误
A02103用户账户已作废
xxxxxx

服务端异常。

错误码中文描述说明
B0001系统执行出错一级宏观错误码
B0100系统执行超时二级宏观错误码
B0101系统订单处理超时
B0200系统容灾功能被触发二级宏观错误码
B0210系统限流
B0220系统功能降级
B0300系统资源异常二级宏观错误码
B0310系统资源耗尽
B0311系统磁盘空间耗尽
B0312系统内存耗尽
xxxxxx

远程调用异常。

错误码中文描述说明
C0001调用第三方服务出错一级宏观错误码
C0100中间件服务出错二级宏观错误码
C0110RPC服务出错
C0111RPC服务未找到
C0112RPC服务未注册
xxxxxx

 在convention下新建软件包errorcode,新建文件IErrorCode

package com.nageoffer.shortlink.admin.common.convention.errorcode;

/**
 * 平台错误码
 */
public interface IErrorCode {

    /**
     * 错误码
     */
    String code();

    /**
     * 错误信息
     */
    String message();
}

然后我们要在系统里面有一些已经定义过的这种宏观异常码先定义一下,直接枚举,相同的地方BaseErrorCode.java

package com.nageoffer.shortlink.admin.common.convention.errorcode;
/**
 * 基础错误码定义
 */
public enum BaseErrorCode implements IErrorCode {

    // ========== 一级宏观错误码 客户端错误 ==========
    CLIENT_ERROR("A000001", "用户端错误"),

    // ========== 二级宏观错误码 用户注册错误 ==========
    USER_REGISTER_ERROR("A000100", "用户注册错误"),
    USER_NAME_VERIFY_ERROR("A000110", "用户名校验失败"),
    USER_NAME_EXIST_ERROR("A000111", "用户名已存在"),
    USER_NAME_SENSITIVE_ERROR("A000112", "用户名包含敏感词"),
    USER_NAME_SPECIAL_CHARACTER_ERROR("A000113", "用户名包含特殊字符"),
    PASSWORD_VERIFY_ERROR("A000120", "密码校验失败"),
    PASSWORD_SHORT_ERROR("A000121", "密码长度不够"),
    PHONE_VERIFY_ERROR("A000151", "手机格式校验失败"),

    // ========== 二级宏观错误码 系统请求缺少幂等Token ==========
    IDEMPOTENT_TOKEN_NULL_ERROR("A000200", "幂等Token为空"),
    IDEMPOTENT_TOKEN_DELETE_ERROR("A000201", "幂等Token已被使用或失效"),

    // ========== 一级宏观错误码 系统执行出错 ==========
    SERVICE_ERROR("B000001", "系统执行出错"),
    // ========== 二级宏观错误码 系统执行超时 ==========
    SERVICE_TIMEOUT_ERROR("B000100", "系统执行超时"),

    // ========== 一级宏观错误码 调用第三方服务出错 ==========
    REMOTE_ERROR("C000001", "调用第三方服务出错");

    private final String code;

    private final String message;

    BaseErrorCode(String code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public String code() {
        return code;
    }

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

 直接封装下来,继承我们的IErrorCode,枚举文件里面有两个字段code和message,构造器里面定义像这种异常:
枚举类中的每一项都是一个枚举常量,通过私有构造器去初始化常量的值,该枚举类实现IErrorCode接口,使得我们可以获取到每一个枚举常量的code和message

那么我们的UserController里面怎么做呢?用户查询为空,那么可能是有人输错了,或者恶意缓存穿透,(这里补充一点讲解)

package com.nageoffer.shortlink.admin.common.enums;

import com.nageoffer.shortlink.admin.common.convention.errorCode.IErrorCode;

public enum UserErrorCodeEnum implements IErrorCode {

    USER_NULL( "B000200", "用户记录不存在");

    private final String code;

    private final String message;

    UserErrorCodeEnum(String code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public String code() {
        return code;
    }

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

 如果说我们后续再创建对应的这种异常码,直接就在UserErrorCodeEnum.java下面继续创建比如说用户已存在USER_EXIST,这样的话我们就能把异常码通过枚举的形式封装起来,大家如果说你在一个项目里面,大公司里面去创建尽量要做到复用,小项目里面自己排查都可以很好的作用

USER_NULL( "B000200", "用户记录不存在"),
USER_EXIST("B000201","用户记录已存在");

业务全局异常拦截器

什么是全局异常拦截器?假如说我们在这个服务里面,假如是遇到java的天敌-空指针,为了避免空指针传达业务端,我们需要做一个兜底,比如说现在有一个nullptr,在apifox里面就是说内部的异常,理想情况下,我们要判断是否为空,但是没有这么多理想

{
    "timestamp":"2023-10-18T08:33:36.687+00:08",
    "status":500,
    "error": "Internal Server-Error",
    "path": "/api/shortlink/v1/user/mading1"
}

 相当于只要说它这些Controller,这些业务方法里面抛出异常,那么如果说是那种未经捕获的异常比如说run time exception,都会被拦截做处理,比如说返回正常的咱们之前的那个失败的一个result

在异常拦截器之前,我们要先抽象出来异常,为什么呢?因为如果说你抛runtimeexception,你一样没办法去做-些个性化的处理,无法返回异常码,所以要抽象出来我们自已对应的业务异常

可以针对不同的异常类型采取不同的操作,例如报警等 比如用户输入了一个输了一个不存在的用户名,这种没必要报警;真正的业务系统出现的问题,比如说服务端异常和远程调用异常,当我们在全局异常拦截器里面发现了是服务端异常和远程调用异常,这三个他会有一些一些共识的一些东西,那么我们就要在这个基础上抽象出来,然后这些异常由这些去继承,OK这样就完成了我们的异常抽象体系

在exception下新建两个AbstractException和 ClientException

package com.nageoffer.shortlink.admin.common.convention.exception;

import com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.errorCode.IErrorCode;

/**
 * 客户端异常
 */
public class ClientException extends AbstractException {

    public ClientException(IErrorCode errorCode) {
        this(null, null, errorCode);
    }

    public ClientException(String message) {
        this(message, null, BaseErrorCode.CLIENT_ERROR);
    }

    public ClientException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public ClientException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable, errorCode);
    }

    @Override
    public String toString() {
        return "ClientException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}
package com.nageoffer.shortlink.admin.common.convention.exception;
import com.nageoffer.shortlink.admin.common.convention.errorCode.IErrorCode;
import lombok.Getter;

import org.springframework.util.StringUtils;

import java.util.Optional;

/**
 * 抽象项目中三类异常体系,客户端异常、服务端异常以及远程服务调用异常
 *
 * @see ClientException
 * @see ServiceException
 * @see RemoteException
 */
@Getter
public abstract class AbstractException extends RuntimeException {

    public final String errorCode;

    public final String errorMessage;

    public AbstractException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable);
        this.errorCode = errorCode.code();
        this.errorMessage = Optional.ofNullable(StringUtils.hasLength(message) ? message : null).orElse(errorCode.message());
    }
}
package com.nageoffer.shortlink.admin.common.convention.exception;

import com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.errorCode.IErrorCode;

/**
 * 远程服务调用异常
 */
public class RemoteException extends AbstractException {

    public RemoteException(String message) {
        this(message, null, BaseErrorCode.REMOTE_ERROR);
    }

    public RemoteException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public RemoteException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable, errorCode);
    }

    @Override
    public String toString() {
        return "RemoteException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}
package com.nageoffer.shortlink.admin.common.convention.exception;

import com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.errorCode.IErrorCode;

import java.util.Optional;

/**
 * 服务端异常
 */
public class ServiceException extends AbstractException {

    public ServiceException(String message) {
        this(message, null, BaseErrorCode.SERVICE_ERROR);
    }

    public ServiceException(IErrorCode errorCode) {
        this(null, errorCode);
    }

    public ServiceException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public ServiceException(String message, Throwable throwable, IErrorCode errorCode) {
        super(Optional.ofNullable(message).orElse(errorCode.message()), throwable, errorCode);
    }

    @Override
    public String toString() {
        return "ServiceException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}

 这样我们就可以对应的创建它对应的一个实现的impl,

    @Override
    public UserRespDTO getUserByUsername(String username) {
        LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
                .eq(UserDO::getUsername, username);
        UserDO userDO = baseMapper.selectOne(queryWrapper);
        if (userDO == null) {
            throw new ClientException(UserErrorCodeEnum.USER_NULL);
        }
        UserRespDTO result = new UserRespDTO();
        BeanUtils.copyProperties(userDO,result);
        //Spring 的 BeanUtils.copyProperties(Object source, Object target) 方法要求源对象在前,目标对象在后
        return result;
    }

然后这里面干什么,去抛出我们的异常,我们这里它构建了这种client  exception,构建了非常多的这种构造函数,它分别能创造我们的code的,这样的话直接去传错误码和错误信息,可以直接把错误信息打到我们 message里面去,就是返回的 code的就直接达到code的之前的 message里面去,然后 code码就是我们的错误码,这样的话非常友好。有些时候比如说你认为是那种通用的一长,就不想再返回那种具体的那种,你想偷懒,比如说你是后管系统,你想偷懒不想他也涉及异常码也提供了这个东西,我们只把max进行自定义,然后我们统一用一级宏观码就是a000001,这样的话大家都知道用户端错误出现问题再自己去排查,这一般是后管。如果说你想自定义对吧?你想复用同一个异常码,但是你又想自定义message,那么你就在这里去给他又传message又传 icode的,然后统一到这里,最终调用我们abstract exception的类,然后它会调用它自己继承的软temp exception,以及去打印对应的参数。

现在是 全局统一返回实体

results是我们每次去new这种result非常不友好的,我们可以用这种链式的方案去把这个东西给它创建出来,

package com.nageoffer.shortlink.admin.common.convention.result;
import com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.exception.AbstractException;

import java.util.Optional;

/**
 * 全局返回对象构造器
 */
public final class Results {

    /**
     * 构造成功响应
     */
    public static Result<Void> success() {
        return new Result<Void>()
                .setCode(Result.SUCCESS_CODE);
    }

    /**
     * 构造带返回数据的成功响应
     */
    public static <T> Result<T> success(T data) {
        return new Result<T>()
                .setCode(Result.SUCCESS_CODE)
                .setData(data);
    }

    /**
     * 构建服务端失败响应
     */
    public static Result<Void> failure() {
        return new Result<Void>()
                .setCode(BaseErrorCode.SERVICE_ERROR.code())
                .setMessage(BaseErrorCode.SERVICE_ERROR.message());
    }

    /**
     * 通过 {@link AbstractException} 构建失败响应
     */
    public static Result<Void> failure(AbstractException abstractException) {
        String errorCode = Optional.ofNullable(abstractException.getErrorCode())
                .orElse(BaseErrorCode.SERVICE_ERROR.code());
        String errorMessage = Optional.ofNullable(abstractException.getErrorMessage())
                .orElse(BaseErrorCode.SERVICE_ERROR.message());
        return new Result<Void>()
                .setCode(errorCode)
                .setMessage(errorMessage);
    }

    /**
     * 通过 errorCode、errorMessage 构建失败响应
     */
    public static Result<Void> failure(String errorCode, String errorMessage) {
        return new Result<Void>()
                .setCode(errorCode)
                .setMessage(errorMessage);
    }
}

这个时候我们创建对应的UserController,无需在new,直接把这个result放进来,我们可以看它底层Results默认设置正确参数SUCCESS_CODE

    @GetMapping("/api/shortlink/v1/user/{username}")
    public Result<UserRespDTO> getUserByUsername(@PathVariable("username")String username) {
        UserRespDTO result = userService.getUserByUsername(username);
        if(result==null){
            return new Result<UserRespDTO>().setCode(UserErrorCodeEnum.USER_NULL.code()).setMessage(UserErrorCodeEnum.USER_NULL.message());
        } else {
            return Results.success(result);
        }
    }

Results中新构一个(后来又删除了,所以没必要)

    public static Result<Void> failure(IErrorCode errorCode) {
        return new Result<Void>()
                .setCode(errorCode.code())
                .setMessage(errorCode.message());
    }

failure一通过全局异常拦截器实现先在admin的pom中补充hutool

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
</dependency>

common下新建web,创建GlobalExceptionHandler文件

package com.nageoffer.shortlink.admin.common.web;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;

import com.nageoffer.shortlink.admin.common.convention.errorCode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.exception.AbstractException;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.common.convention.result.Results;
import jakarta.servlet.http.HttpServletRequest;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Optional;

/**
 * 全局异常处理器
 *
 */
@Component
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 拦截参数验证异常
     */
    @SneakyThrows
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Result validExceptionHandler(HttpServletRequest request, MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
        String exceptionStr = Optional.ofNullable(firstFieldError)
                .map(FieldError::getDefaultMessage)
                .orElse(StrUtil.EMPTY);
        log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
        return Results.failure(BaseErrorCode.CLIENT_ERROR.code(), exceptionStr);
    }

    /**
     * 拦截应用内抛出的异常
     */
    @ExceptionHandler(value = {AbstractException.class})
    public Result abstractException(HttpServletRequest request, AbstractException ex) {
        if (ex.getCause() != null) {
            log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString(), ex.getCause());
            return Results.failure(ex);
        }
        log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString());
        return Results.failure(ex);
    }

    /**
     * 拦截未捕获异常
     */
    @ExceptionHandler(value = Throwable.class)
    public Result defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
        log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
        return Results.failure();
    }

    private String getUrl(HttpServletRequest request) {
        if (StringUtils.isEmpty(request.getQueryString())) {
            return request.getRequestURL().toString();
        }
        return request.getRequestURL().toString() + "?" + request.getQueryString();
    }
}

接下来讲解Global ExceptionHandler:首先我们要标记个注解resultcontroladvice,方法这里面有个exception, handler的话就是它拦截的异常的一个类,这个类可以是父类,然后如果是父类的话,它就会拦截所有的子类。我这个参数异常是拦截客户端校验的参数异常,然后其实最主要的是拦截应用内抛出的异常,比如说我们这里的AbstractException,可以拦截父类,如果说,你抛出的是它的子类,那么它就会也会同样去拦截到,然后在这里defauLtErrorHandLer是拦截一个未捕获的throwable,你不是抛出了我们这种业务里面,假如你业务里面没有想到的一些问题给他抛出来就成了,可能是任何异常,那么我们直接用他的顶级异常父类throwable去捕获,这样的话就能够100%捕获出来他的问题

 首先抛出来了,然后就被我们这边GlobalExceptionHandler的abstractException拦截到了,接下来做了首先第一步把它的日志要留痕,因为我们后续可能通过ELK等机制把日记留下了,然后我们打印完日志之后构建Results.failure这种构造信息,把它给返回我们对应的提示信息,回到Results里面failure,如果说这个异常里面的errorcode存在就获取,不存在就是b000001,然后error message也是一样的