江畔何人初见月?江月何年初照人?
人生代代无穷已,江月年年望相似。
不知江月待何人,但见长江送流水。
——唐·张若虚《春江花月夜》
先不看所有的租户管理、安全管理和框架部分,先从一个完整的前端请求链路来看芋道中一个最典型的请求的完整后端行为。
Controller 部分
Controller 监听来自客户端的请求,也就是来自前端的 HTTP 请求(确切地说,是 Restful API 请求)。
@Tag(name = "管理后台 - 商品 SPU")
@RestController
@RequestMapping("/product/spu")
@Validated
public class ProductSpuController {
@Resource
private ProductSpuService productSpuService;
@Resource
private ProductSkuService productSkuService;
...
@GetMapping("/get-detail")
@Operation(summary = "获得商品 SPU 明细")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('product:spu:query')")
public CommonResult<ProductSpuRespVO> getSpuDetail(@RequestParam("id") Long id) {
// 获得商品 SPU
ProductSpuDO spu = productSpuService.getSpu(id);
if (spu == null) {
return success(null);
}
// 查询商品 SKU
List<ProductSkuDO> skus = productSkuService.getSkuListBySpuId(spu.getId());
return success(ProductSpuConvert.INSTANCE.convert(spu, skus));
}
...
首先定义该类为一个 RestController,便于 Spring Boot 扫描、注册、进行管理,接着定义该 Controller 管理的全局请求的路由连接 /product/spu,其他的注解大多是用于团队协作的文档型注解。
接着,启动 Service 实例。我们要用到的大部分业务代码,都会通过该实例进行调用,从而隐藏了具体的业务逻辑细节,实现了解耦的目的。解耦使代码更加易于团队协作和维护。
@RestController
@RequestMapping("/product/spu")
public class ProductSpuController {
@Resource
private ProductSpuService productSpuService;
@Resource
private ProductSkuService productSkuService;
...
}
随后,我们来看一个具体实现的 Controller 类方法。以 getSpuDetail 为例。首先,该方法的核心注解 @GetMapping 定义了具体的路由 /get-detail,随后注解 @PreAuthorize 则检查了用户的具体权限,作了安全的权限处理,如果存在问题则会直接拦截,不再继续方法,返回给前端对应的错误。
在具体的方法定义中,我们可以看见,方法 getSpuDetail 的输出是 CommonResult<T>,这里的 T 是 ProductSpuRespVO,是一个专门定义过的用于前端展示的视图对象(View Object),接受的输入则是经过注解的@RequestParam Long id。
关于 @RequestParam
在一般的 java 方法获取输入时,我们只需要写输入的类型和变量名就好。为什么这个方法要在输入中使用注解呢?因为在这里,我们的 Controller 获得的请求实际上是一个完整的 HTTP 请求,而具体到请求的资源路径,也是一个很长的 URL,而不是一个具体的 java 类型的变量。
因此,注解 @RequestParam 对该 URL 进行了解析,根据方法定义的变量名找出对应的 key-value(本方法中,也就是 id),并对原本是 String 类型的 value 进行转换,转换为方法定义的类型 Long。如果转换失败,则会在这里进行 return 返回,而不是继续执行程序。
于是做完所有的工作后,方法 getSpuDetail 接收到的输入就是一个 Long 类型的具体数值了。
通过调用 Service 实例的 getSpu 方法,我们获得了一个 ProductSpuDO 类型的 spu 变量。在这里,我们需要有一个信念,即我相信这个方法返回给我的是一个正常正确的值,这也是分层的意义所在,每一层做好自己负责的工作内容,为其他层提供对应的服务并保证可靠的交付成果。
随后,如果该 spu 变量存储的对应数据信息是不为空的,也就是有正常存储内容的,继续执行该方法;如果为空,则直接返回以 success 方法处理空值的结果,因为在与前端的交互中,我们是以 Restful API 的形式交互的,肯定不能只返回一个 null 值——这在我们的方法定义中也有表现,返回的是一个 CommonResult<T> 结果。
success 函数具体实现
public static <T> CommonResult<T> success(T data) {
CommonResult<T> result = new CommonResult<>();
result.code = GlobalErrorCodeConstants.SUCCESS.getCode();
result.data = data;
result.msg = "";
return result;
}
接下来的逻辑也大体相同。刚才我们获得了 spu 的具体内容,随后通过 Sku Service 实例调用对应的方法,查询该 spu id 对应的具体明细,获得对应数据结构类型 List<ProductSkuDO> 的具体数据结果,存储在 skus 变量中,并调用 success 方法进行返回具体获取的 data 内容。
@GetMapping("/get-detail")
@Operation(summary = "获得商品 SPU 明细")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('product:spu:query')")
public CommonResult<ProductSpuRespVO> getSpuDetail(@RequestParam("id") Long id) {
// 获得商品 SPU
ProductSpuDO spu = productSpuService.getSpu(id);
if (spu == null) {
return success(null);
}
// 查询商品 SKU
List<ProductSkuDO> skus = productSkuService.getSkuListBySpuId(spu.getId());
return success(ProductSpuConvert.INSTANCE.convert(spu, skus));
}
Service 部分
接下来我们谈论 Service 部分。以 ProductSpuService 为例。
首先我们来到源码部分。
当我们进入 ProductSpuService.java 文件的时候,会发现它是一个定义的接口。这是一种设计模式——面向接口编程。在该接口中,我们定义了我们所需的所有方法——我们相信这些方法是任何实现接口时必须实现的核心方法——换句话说,我们认为,实现这个 Service 服务,必须要有这些方法的实现。因此我们预先做了定义。这是一种非常规范的架构设计思维。
值得一提的是,如果在像 Java 这样的强类型语言中,接口定义的方法没有被实现,那么在编译阶段编译器就会出现报错。
public interface ProductSpuService {
/**
* 获得商品 SPU
*
* @param id 编号
* @param includeDeleted 是否包含已删除的
* @return 商品 SPU
*/
ProductSpuDO getSpu(Long id, boolean includeDeleted);
}
对应的具体实现,在 ProductSpuServiceImpl.java 文件中。在这里,我们可以看到,该类 ProductSpuServiceImpl 是接口(interface)的一个实现(implement)。
在具体的实现上,首先讨论注解。@Service 注解告诉 Spring Boot,该类是一个 Service。
@Service
@Validated
public class ProductSpuServiceImpl implements ProductSpuService {
@Resource
private ProductSpuMapper productSpuMapper;
@Resource
@Lazy // 循环依赖,避免报错
private ProductSkuService productSkuService;
...
}
我们详细看 Controller 中实例调用的 getSpu()方法的实现。
首先,@Override 是一个覆写注解。说明我要重写这个方法了。这里的业务逻辑很简单,该方法有两个具体的业务实现,一个是简单的查询逻辑,另一个是包含是否查询已删除的逻辑。这并不是多此一举,在不同的情况下,我们可以通过键入不同的参数来实现不同的查询逻辑。返回的结果都是 ProductSpuDO 类型的数据,接收 Long id 类型的数据。
那么具体是如何实现查询的呢?逻辑上,我们只看到该方法调用了 productSpuMapper 实例的 selectById 方法。我们可以注意到,数据库的具体操作被继续隐藏起来了。
@Override
public ProductSpuDO getSpu(Long id) {
return productSpuMapper.selectById(id);
}
@Override
public ProductSpuDO getSpu(Long id, boolean includeDeleted) {
if (includeDeleted) {
return productSpuMapper.selectByIdIncludeDeleted(id);
}
return getSpu(id);
}
Mapper
Mapper层是与数据库交互的核心层次。由它的名字(映射),就可以很直观地得知这一点。在这里,我们主要使用了 MyBatis-Plus 这个包。@Mapper 注解告诉 Spring Boot,这个 interface 是一个 Mapper。
@Mapper
public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
...
}
这里有一个非常有趣的 MyBatis-plus 包的特性。如果我们仔细读了 ProductSpuMapper 的源码,我们会发现,ServiceImpl 中调用了非常多在该 Mapper 中没有声明的方法。这是因为 Mapper 继承了 BaseMapperX<T>,在父类中,已经有了对应的基础 CURD 方法,因此子类中并不需要再重写这些,如果需要重写,使用 @Override 进行重写方法也是可以的。
到这里,仿佛一切都结束了。那么,数据库连接呢?我们是如何与数据库进行连接交互的?不是需要写 jdbc 和具体的 SQL 语句吗?我们知道,没有这些,程序是不知道如何找到数据库,更不知道如何与之交互,进行什么样的交互的。那么这些具体的代码都在哪里?
在这里,以上的行为是由 MyBatis-Plus 引擎自动处理的。首先,我们的配置文件 application.yaml 中会定义数据源的 jdbc,在程序启动执行时,配置文件将会被 Spring Boot 扫描和读取,由此确定的对应的数据源位置来进行通信。又是在什么地方生成那些并不需要我们编写的对应的 SQL 语句的呢?我们将把视线回到先前一直在使用的泛型 ProductSpuDO 中,它位于 ProductSpuDO.java 。
@TableName 注解定义了该 java 对象与我们所需的数据库表的具体对应关系。未来,该注解将会负责映射的处理。
注意,这里 ProductSpuDO 是继承了 BaseDO 的子类。
@TableName(value = "product_spu", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductSpuDO extends BaseDO {
/**
* 商品 SPU 编号,自增
*/
@TableId
private Long id;
/**
* 商品名称
*/
private String name;
/**
* 关键字
*/
private String keyword;
/**
* 商品简介
*/
private String introduction;
/**
* 商品详情
*/
private String description;
...
}

发表回复