分类: Java 模块

  • 为什么我们需要序列化?

    汇编语言中有一个非常有趣的内容,如果使用 gdb 去调试 /bin/ls 这个程序,可以看到直接进入首行地址是不行的,因为在内存中,这个实际上是一个相对地址。

    这也是为什么我们需要序列化。

    因为按照正常的逻辑思路来说,为什么 Java 需要序列化多此一举呢?对象在内存中也是一个正常地一串二进制数据,为什么不能直接复制这些内容传输出去呢?

    首先,这有点类似于我前面提到的像汇编中同样的问题。在 JVM 中,进程访问的是虚拟地址。如果我的电脑内存中存储的这个虚拟空间地址中的绝对地址,直接复制传输到另外一个人的电脑中,它的 JVM 启动并访问了这个绝对地址,也许会出大问题!

    所以一个包装好的,内部处理好的完整的、精致整洁的处理类——序列化,它在安全意义上是非常重要的!

  • 芋道代码解析-后端(1)-安全与鉴权

    主要使用的框架:Spring Boot Security

    前置基本术语和知识

    • 网页服务器(Web Server)一词通常包含两个层面的意义:
      • 硬件层面:指一台负责存放网站文件,并透过网络提供服务的电脑。它借由超文本传输协议(HTTP)与客户端(一般是指网页浏览器)进行资料交换。
      • 软件层面:指一个运行于电脑上的服务器程序(如 Apache, Nginx 或 IIS)。其核心功能是接受用户的请求,并回传相对应的网页内容(如 HTML 文件,图片或资料)。
      • 每一台网页服务器(硬件)至少会执行一个网页服务器程序(软件)。在这篇博客里面,我们主要使用它的软件层面的定义。
    • Servlet(Server Applet)
      • 全称 Java Servlet。是用 Java 编写的服务器端程序。其主要功能在于交互式地浏览和修改数据,生成动态的 Web 内容。
      • 当 servlet 被部署在应用服务器(主要是 Web 服务器)中(服务器中用于管理 Java 组件的部分被抽象成为容器),由容器控制 servlet 的生命周期。默认情况下,在第一次请求的时候,servlet 会被加载实例化(注意这个⌈被动⌋的定义)。初始化并跟着正式执行方法后,它会被常驻,知道服务器关闭或被清理时执行一次销毁方法后实体销毁。
      • 它的工作模式主要如下:
        • 客户端发送请求至服务器(由 Web 服务器管理)
        • 服务器启动并调用 Servlet,Servelt 根据客户端请求生成相应内容并将其传给服务器。
        • 服务器将响应返回客户端(由 Web 服务器管理)
      • HttpServlet 实际上是 Java 服务器页面(Jakarta Server Pages,JSP)的前辈。也就是说,更加擅长于动态生成 HTML 的 JSP 脚本实际上取代了 servlet 中的交互模块。
    • 传统的 JavaWeb 三板斧
      • Servlet
      • 过滤器(Filter)
        • Servlet Filter 是在 Serlet 2.3 规范中加入的功能。过滤器可以动态地拦截请求和响应,以变换或使用包含在请求或响应中的信息。
        • 某些情况下,我们需要在业务代码执行前获取请求中的某些信息,就可以使用过滤器。
        • 简而言之:
          • 在客户端的请求访问服务器中的资源之前,拦截这些请求。
          • 在服务器的响应发送回客户端之前,处理这些响应。
        • 如果我们使用一个过滤器不能解决业务需求,那么就用多个,多个过滤器可以对请求和响应进行多次处理。多个过滤器组合而成的就是过滤器链(Filter Chains),请求会依次按照过滤器的顺序一一进入,直到最后一个过滤器为止。当返回响应的时候,也是一样,从最后一个过滤器依次传递到第一个过滤器,最后到达客户端。
      • 监听器(Listener)

    这个博主的解析会更加详尽生动,具体可以看以下回答。

    为什么要有 Servlet ,什么是 Servlet 容器,什么是 Web 容器? – bravo1988的回答 – 知乎
    https://www.zhihu.com/question/585070524/answer/2939949226

  • 芋道代码解析-后端篇(0)

    江畔何人初见月?江月何年初照人?

    人生代代无穷已,江月年年望相似。

    不知江月待何人,但见长江送流水。

    ——唐·张若虚《春江花月夜》


    先不看所有的租户管理、安全管理和框架部分,先从一个完整的前端请求链路来看芋道中一个最典型的请求的完整后端行为。

    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;
    ...
    }