Skip to content

苍穹外卖笔记

一、Day01技术总结

1.软件开发整体介绍

1.1 软件开发流程

  • 需求分析:需求规格说明书(word文档)、产品原型(静态网页展示功能图片)

  • 设计:UI设计(用户界面,小到按钮,大到页面布局,人机交互)、数据库设计(表结构、字段、类型等)、接口设计

  • 编码:项目代码、单元测试

  • 测试:测试用例、测试报告

  • 上线运维:软件环境安装、配置

1.2 角色分工

  • 项目经理:对整个项目负责、任务分配、把控进度

  • 产品经理:进行需求调研,输出需求文档、产品原型等

  • UI设计师:根据产品原型输出界面效果图

  • 架构师:项目整体架构设计、技术选型等

  • 开发工程师:编写测试用例、输出测试报告

  • 运维工程师:软件环境搭建、项目上线

1.3 软件环境

  • 开发环境(development):开发人员在开发阶段使用的环境,一般外部用户无法访问

  • 测试环境(testing):专门给测试人员使用的环境,用于测试项目,一般外部用户无法访问

  • 生产环境(production):上线环境,正式提供对外服务的环境

2.苍穹外卖项目介绍

为餐饮企业(餐厅、饭店)定制的一款软件产品。

功能架构:体现项目中的业务功能模块:

dd62f53de5074828bcd06d4299854fd3png

产品原型(产品经理):用于展示项目的业务功能(一般用静态的HTML页面+适当的说明文字进行展示),见资料。

技术选型(架构师):展示项目中使用到的技术框架和中间件等:

a8cd270b195c46ccbfc7871aa08bc9d6png

3.前端环境搭建

nginx-1.20.2放在无中文的目录下,双击nginx.exe启动Nginx服务器:

nginx-1.20.2.zip

4.后端环境搭建

sky-take-out项目导入到idea中:

sky-take-out.zip

4.1 项目框架

名称说明
sky-take-outmaven父工程,统一管理依赖版本,聚合其他子模块
sky-common子模块,存放公共类,例如工具类、常量类、异常类等
sky-pojo子模块,存放实体类、VO、DTO等
sky-server子模块,后端服务,存放配置文件、Controller、Service、Mapper等

61b3834dba854833b28122f3142af07apng

4.1.1 sky-common

common存放的是公共类:constant常量类,context项目上下文,enumeration枚举类,exception异常类,json处理json转换的类,properties是Springboot中的一些配置属性类,会把配置文件中的配置项封装成对象,result后端的返回结果,utils工具类。

4.1.2 sky-pojo

  • Entity就是实体类,实体类一般与数据库表对应(数据库字段一般是下划线命名,实体类属性一般是驼峰命名)

  • DTO数据传输对象,DTO一般是作为方法传入的参数在使用,不局限于前端给controller层传参,也可以是controller层给service层传参

  • VO是视图对象,用于前端数据的展示,所以一般是controller层把VO传给前端,然后前端展示

4.1.3 sky-server

server子模块存放的是配置文件、配置类、拦截器、controller、service、mapper、启动类等。

4.2 使用Git进行版本控制

.gitignore:存放不需要git管理的文件,比如编译后生成的targit文件,以及测试类、测试包还有idea自带的一些文件。

4.2.1 本地仓库

VCS - Create Git Repository创建远程仓库,选中根目录即可,若右上角出现标志说明成功:

351aeb2489ad450abd809c09e9df1dffpng

打钩是提交按钮,点击后勾选所有文件,编写版本文字,点击Commit,这步是将项目提交到本地仓库:

3263beb264c84569b8eb295a3c19a57cpng

若想移除本地仓库重新添加,移除方法是 首先在settings中移除本地仓库,然后关闭idea,把仓库地址下的.git、.idea、.gitignore文件删除,重新启动idea打开项目即可:

3acfd803e6de4ffeb6e367c5d4bce5e0png

4.2.2 远程仓库

在gitee上创建远程仓库sky-take-out。

点击下面的链接,定义远程仓库,然后将刚刚复制的链接粘贴进来点击OK,即可将本地仓库与远程仓库关联:

43289d65908c4802a3454cb91fac1f8apng

最后将本地文件推送到Git远程仓库即可。

4.3 数据库创建

sky.sql

在mysql图形化界面中运行上面的脚本sky.sql即可。

数据表名中文名称
employee员工表
category分类表
dish菜品表
dish_flavor菜品口味表
setmeal套餐表
setmeal_dish套餐菜品关系表
user用户表
address_book地址表
shopping_cart购物车表
orders订单表
order_detail订单明细表

数据库详细信息参考数据库设计文档

4.4 前后端联调

  1. application-dev.yml中连接数据库的密码改为自己的密码:
YAML
sky:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    host: localhost
    port: 3306
    database: sky_take_out
    username: root
    password: 123456 #密码使用自己的mysql密码
  1. 执行sky-take-out的compile生命周期,出现BUILD SUCCESS表示成功

如果编译不成功,可以先执行clean,如果还出错,检查设置中的maven版本(建议3.6.1)、java编译器版本(推荐11或17)和jdk版本(建议jdk11)。

  1. 在sky-server目录下的SkyApplication类中启动项目

  2. 浏览器输入localhost进入苍穹外卖登录页面

5.断点调试

点击小虫(进入断点调试),打上断点,然后前端点击登录(此时前端的数据会作为参数传入):

41fd96d0a6a143cf8a1fe4a650a1cbadpng

光标放在字段上还会显示接收到的数据:

c7a3fd1a5c924f378567a26fba8252d9png

若想程序在所希望的地方停止,可以添加断点,然后点击左下角的右箭头,意思是放行;点击一折的箭头,意思是前进一步:

013c40cba86040c5b41fc6eb47e48341png

执行之后会在其中标明注入的数据:

02721cde7b7c4c2f8746442fa80f8603png

技巧备注写上TODO可以在IDEA下方的TODO列表看到待做的操作

e3b613be0dc54e389778836e7c879a12png

6.配置类和builder创建对象

@ConfigurationProperties注解代表当前类是一个配置属性类,作用是封装配置文件中的一些配置项到本类的对应属性中。

16d7820f360c41aeaed552040d8f9f9cpng

在注解内的参数指示了配置类中的参数,比如sky.jwt,就去application.yml文件中找sky jwt的配置项。

传统的通过new创建对象需要参数一一对应,而且参数非常多的情况很繁琐,Lombok提供了使用builder链式创建对象的方法。如下图使用builder方式来建造对象,前提是要在EmployeeLoginVo类上加上**@Builder**注解

11f34675707f4911a4202e41e248d945png

7.nginx反向代理

前端请求是http://localhost:80/api/employee/login,后端地址是http://localhost:8080/admin/employee/login,但是却能匹配成功,是因为反向代理,即将前端发送的动态请求由nginx转发到后端服务器。

nginx反向代理好处

  1. 提高访问速度(可以进行数据缓存,如果访问相同资源可以直接响应数据)

  2. 可以进行负载均衡(如果没有nginx前端只能固定地访问后端某一台服务器,加入nginx则可以将请求分发给后端不同的服务器)

    • 负载均衡:把大量的请求按照、我们指定的指定的方式均衡的分配给集群中的每台服务器
  3. 保证后端服务安全(前端不能直接请求到后端服务器,需要通过Nginx转发)

nginx反向代理的搭建

Nginx
server{
        listen 80;
        server_name localhost;  
        
        location /api/{
                proxy_pass http://localhost:8080/admin/; #反向代理
        }
}

proxy_pass:该指令的作用是设定转发的目的地,其后跟的是转发的目的地址。

以上代码的含义是:监听80端口号, 当访问 http://localhost:80/api/\.\./\.\.这样的接口时,会通过 location /api/ {} 这样的反向代理到 http://localhost:8080/admin/\.\./\.\.上来。

nginx负载均衡的配置

在webservers里面定义一组服务器,用于承接访问负载:

Nginx
upstream webservers{
        server 192.168.100.128:8080 #服务器1;
        server 192.168.100.129:8080 #服务器2;
}

server{
        listen 80;
        server_name localhost;
        
        location /api/{
                proxy_pass http://webservers/admin/; #负载均衡
        }
}

upstream:如果代理服务器是一组服务器的话,可以使用upstream指令配置后端服务器组。

以上代码的含义是:监听80端口号, 当访问 http://localhost:80/api/\.\./\.\.这样的接口时,通过 location /api/ {} 这样的反向代理到 http://webservers/admin,根据webservers名称找到一组服务器,根据设置的负载均衡策略(默认是轮询)转发到具体的服务器。

:upstream后面的名称可自定义,但要上下保持一致。

nginx负载均衡策略

名称说明
轮询默认方式
weight权重方式,默认为1,权重越高,被分配的客户端请求就越多
ip_hash依据ip分配方式,这样每个访客可以访问一个后端服务
least_conn依据最少连接方式,把请求优先级分配给连接数少的后端服务
url_hash依据url分配方式,这样相同的url会被分配到同一个后端服务
fair依赖响应时间方式,响应时间短的服务将会被优先分配

轮询:

Nginx
upstream webservers{
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
}

weight:

Nginx
upstream webservers{
    server 192.168.100.128:8080 weight=90;
    server 192.168.100.129:8080 weight=10;
}

ip_hash:

Nginx
upstream webservers{
    ip_hash;
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
}

least_conn:

Nginx
upstream webservers{
    least_conn;
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
}

url_hash:

Nginx
upstream webservers{
    hash &request_uri;
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
}

fair:

Nginx
upstream webservers{
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
    fair;
}

8.MD5加密

密码如果明文存放在数据库中安全性很低,将密码采用MD5方式加密后进行存储,提高安全性(存储入数据库的密码是加密后的数据,并且加密的过程是不可逆的,无法通过加密结果算出明文)。

MD5信息摘要算法:一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值,用于确保信息传输完整一致。

md5加密是不可逆的,不能由密文解密得到原文,但是缺点也很明显,同一个字符串加密后密文一致,采用暴力破解就能解密,所以对于安全性要求高的数据建议采用其他算法。

EmployeeServiceImpl类中只需调用Spring提供的DigestUtils类中的md5DigestAsHex方法对密码进行加密转换:

Java
//密码比对
//md5加密前端传递的密码
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
    //密码错误
    throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}

9.导入接口文档

打开yapi官网https://yapi.rightknights.com/,创建两个项目苍穹外卖-管理端接口苍穹外卖-用户端接口,分别导入相关资料中相关的json文件:

苍穹外卖-管理端接口.json

苍穹外卖-用户端接口.json

836b0e86f62e42169efdae3d85991012png

10.Swagger

使用Swagger只需要按照规范去定义接口及接口相关的信息,就可以做到在线自动生成接口文档,以及在线接口调试页面。Swagger可以帮助后端生成接口文档、进行在线接口测试。

Knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui。

10.1 Swagger使用步骤

  1. 导入knife4j的maven坐标
XML
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.2</version>
</dependency>
  1. 在配置类中加入knife4j相关配置(配置类需要@Configuration注解)

WebMvcConfiguration

Java
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
    ApiInfo apiInfo = new ApiInfoBuilder()
                            .title("苍穹外卖项目接口文档")  
                            .version("2.0")
                            .description("苍穹外卖项目接口文档")
                            .build();
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
                            .apiInfo(apiInfo)
                            .select()
                            .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
                            .paths(PathSelectors.any())
                            .build();
    return docket;
}

它会扫描controller里面的所有方法,然后通过反射去解析,最终生成接口文档。

  1. 设置静态资源映射,否则接口文档页面无法访问

WebMvcConfiguration

Java
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
    /**
    * 设置静态资源映射
    * @param registry
    */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
         registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
         registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

访问http://localhost:8080/doc\.html即可,可以直接在页面的测试栏中对方法进行测试:

83f5fe2ef79a4801b2e1e863ebc4a3dcpng

Swagger和Yapi的区别:

  1. Yapi是设计阶段使用的工具,管理和维护接口

  2. Swagger在开发阶段使用的框架,帮助后端开发人员做接口测试

10.2 Swagger常用注解

注解说明
@Api用在类上,例如Controller,表示对类的说明
@ApiModel用在类上,例如entity、DTO、VO
@ApiModelProperty用在属性上,描述属性信息
@ApiOperation用在方法上,例如Controller的方法,说明方法的用途、作用

@Api注解使用样例(记得要加tags=):

Java
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {

@ApiModel和@ApiModelProperty注解使用样例:

Java
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {
    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("密码")
    private String password;
}

@ApiOperation注解使用样例:

Java
@ApiOperation(value = "员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {...}

二、Day02技术总结

1.DTO、VO和pojo

sky-pojo:模块中存放的是一些 entity、DTO、VO

POJO:一种简单的Java实体类,不包含业务逻辑,属性和数据库表中的字段一一对应,存放在entity包下。

DTO:实体类,用来封装前端传递的数据,属性和前端传递的数据一一对应,不包含业务逻辑或行为。

VO:实体类,和DTO相反,用来封装需要响应给前端的数据,属性和响应数据一一对应,不包含业务逻辑或行为。

建议:当前端提交的数据和实体类中对应的属性差别较大时(也就是实体类中会有很多多余属性),建议使用DTO来封装。

2.Swagger全局参数配置

对于每一个请求,后端的拦截器都会进行JWT令牌的校验,如果没有携带令牌或令牌无效,会出现401错误,使用Swagger调试时需要携带有效的JWT令牌才能被放行请求后端资源,JWT令牌可以放在全局参数中,每次请求都会自动携带JWT令牌,从而实现调试。

将合法的JWT令牌添加到全局参数中步骤

文档管理 -> 全局参数设置 -> 添加参数

06ca19ceed1644228507cc5ac6d106adpng

之后的每一次请求,请求头都会携带token参数到服务端。

3.全局异常处理器

当服务端出现异常后,如果没有对异常信息进行处理,那么异常信息会直接抛给前端,但是前端是看不懂这些异常信息,服务端需要对异常处理成前端能看懂的信息,这时就需要全局异常处理器。

在Spring框架中,可以通过@ExceptionHandler注解来定义一个全局异常处理器方法。当程序运行时抛出异常时,Spring会自动调用该方法来处理异常,而不是直接抛出到客户端。

全局异常处理步骤

进入到sky-server模块,com.sky.hander包下,GlobalExceptionHandler.java添加方法

Java
/**
* 处理SQL异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
     //Duplicate entry 'zhangsan' for key 'employee.idx_username'
     String message = ex.getMessage();
     if(message.contains("Duplicate entry")){
          String[] split = message.split(" ");
          String username = split[2];
          String msg = username + MessageConstant.ALREADY_EXISTS;
          return Result.error(msg);
     }else{
          return Result.error(MessageConstant.UNKNOWN_ERROR);
     }
}

进入到sky-common模块,在MessageConstant.java添加

Java
public static final String ALREADY_EXISTS = "已存在";

4.ThreadLocal

ThreadLocal 并不是一个Thread,而是Thread的局部变量。为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。

常用方法:

方法说明
public void set(T value)设置当前线程的线程局部变量的值
public T get()返回当前线程对应的线程局部变量的值
public void remove()移除当前线程的线程局部变量

拦截器进行令牌校验时会解析出JWT令牌内存放的id,那怎么把id送到service层呢?

对于Spring项目,每次请求都对应一个线程,一次请求中controller、service和拦截器中输出线程的id是一致的,所以可以使用ThreadLocal把id送到service层。

Java
System.out.println("当前线程的id:"+Thread.currentThread().getId());

解决步骤

初始工程的sky-common模块中已经封装了 ThreadLocal 操作的工具类:

Java
package com.sky.context;

public class BaseContext {

    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    public static Long getCurrentId() {
        return threadLocal.get();
    }

    public static void removeCurrentId() {
        threadLocal.remove();
    }

}

在拦截器中解析出当前登录员工id,并放入线程局部变量中(在sky-server模块中):

Java
package com.sky.interceptor;

@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 校验jwt
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                ...
        //2、校验令牌
        try {
            ...
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
            log.info("当前员工id:", empId);
            ///将用户id存储到ThreadLocal
            BaseContext.setCurrentId(empId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            ...
        }
    }
}

在Service中获取线程局部变量中的值:

Java
/**
* 新增员工
*/
public void save(EmployeeDTO employeeDTO) {
     ...
     //设置当前记录创建人id和修改人id
     employee.setCreateUser(BaseContext.getCurrentId());
     employee.setUpdateUser(BaseContext.getCurrentId());

     employeeMapper.insert(employee);
}

5.日期格式化

服务端的日期类型数据的格式前端是看不懂的,如果想要将后端的日期格式数据格式化为前端能看懂的数据,就需要进行日期格式化,日期格式化有两种实现方式:

5.1 方式一

在属性上加上注解,对日期进行格式化:

676336b12b1140d6be67f4f3bb7988ecpng

这种方式需要在每个时间属性上都要加上该注解,使用较麻烦,不能全局处理。

5.2 方式二

在WebMvcConfiguration中扩展SpringMVC的消息转换器,统一对日期类型进行格式处理:

Java
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
    /**
    * 扩展Spring MVC框架的消息转化器
    * @param converters
    */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
         log.info("扩展消息转换器...");
         //创建一个消息转换器对象
         MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
         //需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
         converter.setObjectMapper(new JacksonObjectMapper());
         //将自己的消息转化器加入容器中
         converters.add(0,converter);
    }
}

对象转换器JacksonObjectMapper(sky-common模块):

Java
package com.sky.json;

/**
 * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
 * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
 */
public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    //public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
        super();
        //收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        //反序列化时,属性不存在的兼容处理
        this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        SimpleModule simpleModule = new SimpleModule()
                .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
                .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

        //注册功能模块 例如,可以添加自定义序列化器和反序列化器
        this.registerModule(simpleModule);
    }
}

:具体的原理和详细说明参考日期格式化详解.md

三、Day03技术总结

1.公共字段自动填充

1.1 问题分析

在多个业务表中都有公共字段create_time、create_user;update_time,update_user,我们希望在执行插入或更新时自动填充这四个属性,而不需要每一次都手动赋值:

字段名含义数据类型操作类型
create_time创建时间datetimeinsert
create_user创建人idbegintinsert
update_time修改时间datetimeinsert、update
update_user修改人idbigintinsert、update

实现思路:使用自定义注解AutoFill,用于标识需要进行公共字段自动填充的方法。然后自定义切面类AutoFillAspect,统一拦截加入了AutoFill注解的方法,通过反射为公共字段赋值。在Mapper的方法上加入AutoFill注解。

1.2 代码实现

在com.sky下创建annotation包,创造一个AutoFill的Annotation注解。

Java
//自定义注解,用于标识某个方法需要进行功能字段自动填充处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
    //数据库操作类型:UPDATE INSERT
    OperationType value();
}

Target注解指定加在什么上面,Retention注解指定什么时候用。

OperationType为自定义枚举类型,源码如下:

Java
public enum OperationType {
    UPDATE,
    INSERT
}

在com.sky下创建aspect包,创建类AutoFillAspect,写入如下代码:

Java
//自定义切面,实现公共字段自动填充处理逻辑
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
    //切入点,定位mapper下所有类的所有被@AutoFill注解的方法
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut() {
    }

    //前置通知,在通知中进行公共字段的赋值
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint) {
        log.info("开始自动填充");
        //获取到当前被拦截的方法上的数据库操作类型
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();//方法签名对象
        AutoFill autoFill = methodSignature.getMethod().getAnnotation(AutoFill.class);//方法上的注解对象
        OperationType operationType = autoFill.value();//获取数据库操作类型

        //获取当前被拦截的方法的参数--实体对象
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) {
            return;
        }

        Object entity = args[0];

        //准备赋值的数据
        LocalDateTime now = LocalDateTime.now();
        Long currentId = BaseContext.getCurrentId();

        //根据当前不同的操作类型,为对应的属性根据反射来赋值
        if (operationType == OperationType.INSERT) {
            //为4个公共字段赋值
            try {
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

                //使用反射为对象属性赋值
                try {
                    setCreateTime.invoke(entity, now);
                    setCreateUser.invoke(entity, currentId);
                    setUpdateTime.invoke(entity, now);
                    setUpdateUser.invoke(entity, currentId);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        } else if (operationType == OperationType.UPDATE) {
            //为2个公共字段赋值
            //为4个公共字段赋值
            try {
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

                //使用反射为对象属性赋值
                try {
                    setUpdateTime.invoke(entity, now);
                    setUpdateUser.invoke(entity, currentId);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

然后在sky-server下的mapper中的EmployeeMapper类里,insert上加入@AutoFill(value= OperationType.INSERT)注解,update上加入@AutoFill(value= OperationType.UPDATE)注解,并删除之前开发的所有插入或更新操作手动为这四个属性赋值的代码。

四、Day05技术总结

1.Redis入门

1.1 Redis简介

Redis是一个基于内存的key-value结构数据库,是互联网技术领域使用最为广泛的存储中间件

Redis是一种非关系型数据库,主要有如下特点:

  • 基于内存存储,读写性能高

  • 适合存储热点数据(热点商品、资讯、新闻)

  • 企业应用广泛

1.2 Redis下载与安装

直接解压Redis-x64-3.2.100.zip即可:

Redis-x64-3.2.100.zip

1.3 Redis服务启动与停止

服务启动:在终端进入redis的安装目录,运行命令redis-server.exe redis.windows.conf即可

Redis服务默认端口号为 6379 ,通过快捷键Ctrl + C 即可停止Redis服务

客户端连接:redis服务启动后,在终端进入redis的安装目录,运行命令redis-cli.exe即可

通过redis-cli.exe命令默认连接的是本地的redis服务,并且使用默认6379端口。也可以通过指定如下参数连接:

  • -h ip地址

  • -p 端口号

  • -a 密码(如果需要)

修改Redis配置文件:设置Redis服务密码,修改redis.windows.conf

Properties
requirepass 123456

注意:

  • 修改密码后需要重启Redis服务才能生效

  • Redis配置文件中 # 表示注释

重启Redis后,再次连接Redis时,需加上密码,否则连接失败。

1.4 Redis客户端图形工具

默认提供的客户端连接工具界面不太友好,同时操作也较为麻烦,可以使用图形化工具操作Redis。

双击资料中的Another-Redis-Desktop-Manager.1.5.5.exe安装即可。

2.Redis数据类型

Redis存储的是key-value结构的数据,其中key是字符串类型,value有5种常用的数据类型:

  • 字符串(string):普通字符串,Redis中最简单的数据类型

  • 哈希(hash):也叫散列,类似于Java中的HashMap结构

  • 列表(list):按照插入顺序排序,可以有重复元素,类似于Java中的LinkedList

  • 集合(set):无序集合,没有重复元素,类似于Java中的HashSet

  • 有序集合(sorted set/zset):集合中每个元素关联一个分数(score),根据分数升序排序,没有重复元素

4826467a875640e18b128537d51484ecpng

3.Redis常用命令

3.1 字符串操作命令

Redis 中字符串类型常用命令:

  • SET key value 设置指定key的值

  • GET key 获取指定key的值

  • SETEX key seconds value 设置指定key的值,并将 key 的过期时间设为 seconds 秒

  • SETNX key value 只有在 key 不存在时设置 key 的值

3.2 哈希操作命令

Redis hash 是一个string类型的 field 和 value 的映射表,hash特别适合用于存储对象,常用命令:

  • HSET key field value 将哈希表 key 中的字段 field 的值设为 value

  • HGET key field 获取存储在哈希表中指定字段的值

  • HDEL key field 删除存储在哈希表中的指定字段

  • HKEYS key 获取哈希表中所有字段

  • HVALS key 获取哈希表中所有值

475a97caaa7e4aad9c941d3e7697fcaepng

3.3 列表操作命令

Redis 列表是简单的字符串列表,按照插入顺序排序,常用命令:

  • LPUSH key value1 [value2] 将一个或多个值插入到列表头部

  • LRANGE key start stop 获取列表指定范围内的元素

  • RPOP key 移除并获取列表最后一个元素

  • LLEN key 获取列表长度

  • BRPOP key1 [key2 ] timeout 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超 时或发现可弹出元素为止

f6176937fef64e6cbbed64a30bd5e5d6png

3.4 集合操作命令

Redis set 是string类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据,常用命令:

  • SADD key member1 [member2] 向集合添加一个或多个成员

  • SMEMBERS key 返回集合中的所有成员

  • SCARD key 获取集合的成员数

  • SINTER key1 [key2] 返回给定所有集合的交集

  • SUNION key1 [key2] 返回所有给定集合的并集

  • SREM key member1 [member2] 移除集合中一个或多个成员

341635f842be40f491c710945f9910eapng

3.5 有序集合操作命令

Redis有序集合是string类型元素的集合,且不允许有重复成员。每个元素都会关联一个double类型的分数。常用命令:

常用命令:

  • ZADD key score1 member1 [score2 member2] 向有序集合添加一个或多个成员

  • ZRANGE key start stop [WITHSCORES] 通过索引区间返回有序集合中指定区间内的成员

  • ZINCRBY key increment member 有序集合中对指定成员的分数加上增量 increment

  • ZREM key member [member ...] 移除有序集合中的一个或多个成员

cfef3e9a4187498fae0d82c91c7f9896png

3.6 通用命令

Redis的通用命令是不分数据类型的,都可以使用的命令:

  • KEYS pattern 查找所有符合给定模式( pattern)的 key

  • EXISTS key 检查给定 key 是否存在

  • TYPE key 返回 key 所储存的值的类型

  • DEL key 该命令用于在 key 存在是删除 key

4.在Java中操作Redis

Redis的Java客户端最常用的有三种:

  • Jedis

  • Lettuce

  • Spring Data Redis

Spring 对 Redis 客户端进行了整合,提供了 Spring Data Redis,在Spring Boot项目中还提供了对应的Starter,即 spring-boot-starter-data-redis。

4.1 Spring Data Redis

Spring Data Redis 是 Spring 的一部分,通过简单的配置就可以访问 Redis 服务,对 Redis 底层开发包进行了高度封装。

Spring Data Redis中提供了一个高度封装的类:RedisTemplate,对相关api进行了归类封装,将同一类型操作封装为operation接口,具体分类如下:

  • ValueOperations:string数据操作

  • SetOperations:set类型数据操作

  • ZSetOperations:zset类型数据操作

  • HashOperations:hash类型的数据操作

  • ListOperations:list类型的数据操作

4.2 环境搭建

1.Spring Boot提供了Spring Data Redis的maven坐标

XML
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.配置Redis数据源

在application-dev.yml中添加

YAML
sky:
  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 10

在application.yml中添加读取application-dev.yml中的相关Redis配置

YAML
spring:
  profiles:
    active: dev
  redis:
    host: ${sky.redis.host}
    port: ${sky.redis.port}
    password: ${sky.redis.password}
    database: ${sky.redis.database}

database:指定使用Redis的哪个数据库,Redis服务启动后默认有16个数据库,编号分别是从0到15。可以通过修改Redis配置文件来指定数据库的数量。

3.编写配置类,创建RedisTemplate对象

Java
package com.sky.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@Slf4j
public class RedisConfiguration {

    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        log.info("开始创建redis模板对象...");
        RedisTemplate redisTemplate = new RedisTemplate();
        //设置redis的连接工厂对象
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //设置redis key的序列化器
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

当前配置类不是必须的,因为 Spring Boot 框架会自动装配 RedisTemplate 对象,但是默认的key序列化器为

JdkSerializationRedisSerializer,导致我们存到Redis中后的数据和原始数据有差别,故设置为

StringRedisSerializer序列化器。

4.通过RedisTemplate对象操作Redis

在test下新建测试类

Java
package com.sky.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.*;

@SpringBootTest
public class SpringDataRedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedisTemplate(){
        System.out.println(redisTemplate);
        //string数据操作
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //hash类型的数据操作
        HashOperations hashOperations = redisTemplate.opsForHash();
        //list类型的数据操作
        ListOperations listOperations = redisTemplate.opsForList();
        //set类型数据操作
        SetOperations setOperations = redisTemplate.opsForSet();
        //zset类型数据操作
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
    }
}

4.3 操作常见类型数据

4.3.1 操作字符串类型数据

Java
/**
* 操作字符串类型的数据
*/
@Test
public void testString(){
     // set get setex setnx
     redisTemplate.opsForValue().set("name","小明");
     String city = (String) redisTemplate.opsForValue().get("name");
     System.out.println(city);
     redisTemplate.opsForValue().set("code","1234",3, TimeUnit.MINUTES);
     redisTemplate.opsForValue().setIfAbsent("lock","1");
     redisTemplate.opsForValue().setIfAbsent("lock","2");
}

4.3.2 操作哈希类型数据

Java
/**
* 操作哈希类型的数据
*/
@Test
public void testHash(){
     //hset hget hdel hkeys hvals
     HashOperations hashOperations = redisTemplate.opsForHash();

     hashOperations.put("100","name","tom");
     hashOperations.put("100","age","20");

     String name = (String) hashOperations.get("100", "name");
     System.out.println(name);

     Set keys = hashOperations.keys("100");
     System.out.println(keys);

     List values = hashOperations.values("100");
     System.out.println(values);

     hashOperations.delete("100","age");
}

4.3.3 操作列表类型数

Java
/**
* 操作列表类型的数据
*/
@Test
public void testList(){
    //lpush lrange rpop llen
     ListOperations listOperations = redisTemplate.opsForList();

     listOperations.leftPushAll("mylist","a","b","c");
     listOperations.leftPush("mylist","d");

     List mylist = listOperations.range("mylist", 0, -1);
     System.out.println(mylist);

     listOperations.rightPop("mylist");

     Long size = listOperations.size("mylist");
     System.out.println(size);
}

4.3.4 操作集合类型数据

Java
*/***
* * 操作集合类型的数据*
* */*
@Test
public void testSet(){
    *//sadd smembers scard sinter sunion srem*
*    *SetOperations setOperations = redisTemplate.opsForSet();

    setOperations.add("set1","a","b","c","d");
    setOperations.add("set2","a","b","x","y");

    Set members = setOperations.members("set1");
    System.*out*.println(members);

    Long size = setOperations.size("set1");
    System.*out*.println(size);

    Set intersect = setOperations.intersect("set1", "set2");
    System.*out*.println(intersect);

    Set union = setOperations.union("set1", "set2");
    System.*out*.println(union);

    setOperations.remove("set1","a","b");
}

4.3.5 操作有序集合类型数据

Java
*/***
* * 操作有序集合类型的数据*
* */*
@Test
public void testZset(){
    *//zadd zrange zincrby zrem*
*    *ZSetOperations zSetOperations = redisTemplate.opsForZSet();

    zSetOperations.add("zset1","a",10);
    zSetOperations.add("zset1","b",12);
    zSetOperations.add("zset1","c",9);

    Set zset1 = zSetOperations.range("zset1", 0, -1);
    System.*out*.println(zset1);

    zSetOperations.incrementScore("zset1","c",10);

    zSetOperations.remove("zset1","a","b");
}

4.3.6 通用命令操作

Java
*/***
* * 通用命令操作*
* */*
@Test
public void testCommon(){
    *//keys exists type del*
*    *Set keys = redisTemplate.keys("*");
    System.*out*.println(keys);

    Boolean name = redisTemplate.hasKey("name");
    Boolean set1 = redisTemplate.hasKey("set1");

    for (Object key : keys) {
        DataType type = redisTemplate.type(key);
        System.*out*.println(type.name());
    }

    redisTemplate.delete("mylist");
}

五、Day06技术总结

1.HttpClient

在使用扫描支付、查看地图时,应用程序本身并未实现这些功能,都是在应用程序里访问提供这些功能的服务,访问这些服务需要发送HTTP请求,并且接收响应数据,可通过HttpClient来实现。

HttpClient 是Apache Jakarta Common 下的子项目,用以实现发送http请求,并接受响应数据。

HttpClient的核心API:

  • HttpClient:Http客户端对象类型,使用该类型对象可发起Http请求

  • HttpClients:可认为是构建器,可创建HttpClient对象

  • CloseableHttpClient:实现类,实现了HttpClient接口

  • HttpGet:Get方式请求类型

  • HttpPost:Post方式请求类型

HttpClient发送请求步骤:

  • 创建HttpClient对象

  • 创建Http请求对象

  • 调用HttpClient的execute方法发送请求

1.1 入门案例

HttpClient的maven坐标:

XML
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.13</version>
</dependency>

1.1.1 GET方式请求

在项目中,已经导入了aliyun-sdk-oss坐标,而aliyun-sdk-oss依赖于httpclient,所以可以不用导入httpclient依赖。

  1. 创建HttpClient对象

  2. 创建请求对象

  3. 发送请求,接受响应结果

  4. 解析结果

  5. 关闭资源

Java
package com.sky.test;

import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class HttpClientTest {

    /**
     * 测试通过httpclient发送GET方式的请求
     */
    @Test
    public void testGET() throws Exception{
        //创建httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();

        //创建请求对象
        HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");

        //发送请求,接受响应结果
        CloseableHttpResponse response = httpClient.execute(httpGet);

        //获取服务端返回的状态码
        int statusCode = response.getStatusLine().getStatusCode();
        System.out.println("服务端返回的状态码为:" + statusCode);

        HttpEntity entity = response.getEntity();
        String body = EntityUtils.toString(entity);
        System.out.println("服务端返回的数据为:" + body);

        //关闭资源
        response.close();
        httpClient.close();
    }
}

1.1.2 POST方式请求

  1. 创建HttpClient对象

  2. 创建请求对象

  3. 发送请求,接收响应结果

  4. 解析响应结果

  5. 关闭资源

Java
*/***
* * 测试通过httpclient发送POST方式的请求*
* */*
@Test
public void testPOST() throws Exception{
    *// 创建httpclient对象*
*    *CloseableHttpClient httpClient = HttpClients.createDefault();

    *//创建请求对象*
*    *HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");

    JSONObject jsonObject = new JSONObject();
    jsonObject.put("username","admin");
    jsonObject.put("password","123456");

    StringEntity entity = new StringEntity(jsonObject.toString());
    *//指定请求编码方式*
*    *entity.setContentEncoding("utf-8");
    *//数据格式*
*    *entity.setContentType("application/json");
    httpPost.setEntity(entity);

    *//发送请求*
*    *CloseableHttpResponse response = httpClient.execute(httpPost);

    *//解析返回结果*
*    *int statusCode = response.getStatusLine().getStatusCode();
    System.*out*.println("响应码为:" + statusCode);

    HttpEntity entity1 = response.getEntity();
    String body = EntityUtils.toString(entity1);
    System.*out*.println("响应数据为:" + body);

    *//关闭资源*
*    *response.close();
    httpClient.close();
}

2.微信小程序开发

小程序是一种新的开放能力,开发者可以快速地开发一个小程序。可以在微信内被便捷地获取和传播,同时具有出色的使用体验。

官方网址:

https://mp.weixin.qq.com/cgi-bin/wx?token=&lang=zh_CN

2.1 准备工作

2.1.1 注册或登录小程序

注册地址:

https://mp.weixin.qq.com/wxopen/waregister?action=step1

注册的时候,分不同的注册主体,个人、企业、政府、媒体、其他组织,不同主体注册的小程序,开放的权限不一样,以个人身份注册时,无法开通支付权限。

2.1.2 完善小程序信息

登录小程序后台:

https://mp.weixin.qq.com/

完善小程序信息、小程序类目

6a6f0df3df3541b3894e73984c3442c3png

查看小程序的 AppID

f00e35aea68743d9981bcf125ae5b048png

2.1.3 下载开发者工具

下载地址:

https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html

直接双击资料中的wechat_devtools_1.05.2204250_x64.exe安装即可。

扫码登录开发者模式后,创建小程序项目:

2216b424b5bf4edaa3ced537a3c1a2aepng

熟悉开发者工具布局:

c3ede79841224700ae88227e7da8a3c7png

设置不校验合法域名:

fe5b598189e042e385e6037b4952275apng

开发阶段,小程序发出请求到后端的Tomcat服务器,若不勾选,请求发送失败。

2.2 入门案例

2.2.1 小程序目录结构

一个小程序主体部分由三个文件组成,必须放在项目的根目录:

2a0c30ecd3494ee6b76f7ab8b93f10d3png

app.js:必须存在,主要存放小程序的逻辑代码

app.json:必须存在,小程序配置文件,主要存放小程序的公共配置

app.wxss: 非必须存在,主要存放小程序公共样式表,类似于前端的CSS样式

一个小程序有多个页面,这些页面放在pages包下,每个小程序页面主要由四个文件组成:

732f73c49f0c4beca234d002b45788f3png

js文件:必须存在,存放页面业务逻辑代码,编写的js代码

wxml文件:必须存在,存放页面结构,主要是做页面布局,页面效果展示的,类似于HTML页面

json文件:非必须,存放页面相关的配置

wxss文件:非必须,存放页面样式表,相当于CSS文件

2.2.2 编写和编译小程序

进入到index.wxml,编写页面布局:

XML
<view class="container">
  <view>{{msg}}</view>
   <view>
    <button type="default" bindtap="getUserInfo">获取用户信息</button>
    <image style="width: 100px;height: 100px;" src="{{avatarUrl}}"></image>
    {{nickName}}
  </view>
   <view>
    <button type="primary" bindtap="wxlogin">微信登录</button>
    授权码:{{code}}
  </view>
   <view>
    <button type="warn" bindtap="sendRequest">发送请求</button>
    响应结果:{{result}}
  </view>
</view>

进入到index.js,编写业务逻辑代码:

JavaScript
Page({
  data:{
    msg:'hello world',
    avatarUrl:'',
    nickName:'',
    code:'',
    result:''
  },
  getUserInfo:function(){
    wx.getUserProfile({
      desc: '获取用户信息',
      success:(res=> {
        console.log(res)
        this.setData({
          avatarUrl:res.userInfo.avatarUrl,
          nickName:res.userInfo.nickName
        })
      }
    })
  },
  wxlogin:function(){
    wx.login({
      success: (res=> {
        console.log("授权码:"+res.code)
        this.setData({
          code:res.code
        })
      }
    })
  },
  sendRequest:function(){
    wx.request({
      url: 'http://localhost:8080/user/shop/status',
      method:'GET',
      success:(res=> {
        console.log("响应结果:" + res.data.data)
        this.setData({
          result:res.data.data
        })
      }
    })
  }})

点击编译按钮编译后运行效果如下:

1f60be4ca1234f2ea308542b081db95cpng

2.2.3 发布小程序

点击上传按钮:

116bb88aa7604a5c92fb992ff0aa846fpng

指定版本号:

6308c85ce97b4b2f912c1ecc68f4e998png

上传成功后,进到微信公众平台,打开版本管理页面,提交审核,把发行版本变成审核版本,审核通过后,进行发布,变成线上版本:

b344b23051e04ba1b7937b70663c0d4fpng

3.微信登录

3.1 导入小程序代码

mp-weixin.zip

打开开发者工具,点击导入按钮,选中上面提供的mp-weixin文件夹,导入即可:

b695c4e8cf33425da36940d74ed9185apng

修改配置:

common -> vendor.js -> 搜索(ctrl+f) -> baseUri

4947a24cc6b748b7bb1435b1306f07d4png

3.2 微信登录流程

微信登录:

https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

1608b95e114d42c691e23cc0d7046c10png

  1. 小程序端,调用wx.login()获取code,就是授权码

  2. 小程序端,调用wx.request()发送请求并携带code,请求开发者服务器(自己编写的后端服务)

  3. 开发者服务端,通过HttpClient向微信接口服务发送请求,并携带appId+appsecret+code三个参数

  4. 开发者服务端,接收微信接口服务返回的数据,session_key+opendId等。opendId是微信用户的唯一标识

  5. 开发者服务端,自定义登录态,生成令牌(token)和openid等数据返回给小程序端,方便后续请求身份校验

  6. 小程序端,收到自定义登录态,存储storage

  7. 小程序端,后续通过wx.request()发起业务请求时,携带token

  8. 开发者服务端,收到请求后,通过携带的token,解析当前登录用户的id

  9. 开发者服务端,身份校验通过后,继续相关的业务逻辑处理,最终返回业务数据

使用Postman进行测试:

1.获取授权码:点击确定按钮,获取授权码,每个授权码只能使用一次,每次测试,需重新获取。

d497771d341a4597a2472f5f225a1723png

2.明确请求接口

HTTP
GET https://api.weixin.qq.com/sns/jscode2session

请求参数:

请求参数参数类型是否必须说明
appidstring小程序 appId
secretstring小程序 appSecret
js_codestring登录时获取的 code
grant_typestring授权类型,此处只需填写 authorization_code

appid和secret可以从小程序官网的开发管理处获取。

六、Day07技术总结

1.Spring Cache简介

在进行查询操作时,每次都需要查找数据库表,效率低,由于redis是存放在内存中的,速度快,可以把查询信息缓存到redis中,每次查询时先判断redis中是否有对应的数据,有就直接取出响应,没有再查询数据库,但是每次操作redis的操作不太方便,这是就用到了Spring Cache技术。

Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。

Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现,例如:

  • EHCache

  • Caffeine

  • Redis(常用)

起步依赖:

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    <version>2.7.3</version>
</dependency>

2.Spring Cache常用注解

在SpringCache中提供了很多缓存操作的注解,常见的是以下的几个:

注解说明
@EnableCaching开启缓存注解功能,通常加在启动类上
@Cacheable在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut将方法的返回值放到缓存中
@CacheEvict将一条或多条数据从缓存中删除

在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。

3.Spring Cache入门案例

3.1 环境准备

导入基础工程:导入springcache-demo项目

springcache-demo.zip

03524ebb82a142d7bc31406bb91460c2png

数据库准备:运行springcachedemo.sql脚本即可

springcachedemo.sql

引导类上加@EnableCaching:在引导类CacheDemoApplication上加上注解@EnableCaching

3.2 @CachePut注解

@CachePut用于将方法的返回值放入缓存,有两个参数:

  • value:缓存的名称,每个缓存下可以有很多key

  • key:缓存的key,支持Spring的表达式语言SPEL语法

用户信息保存到数据库的同时,也往缓存中缓存一份数据:在UserController类的save方法上加上注解@CachePut

SQL
*/***
* * CachePut:将方法返回值放入缓存*
* * value:缓存的名称,每个缓存名称下面可以有多个key*
* * key:缓存的key*
* */*
@PostMapping
@CachePut(value = "userCache", key = "#user.id")*//key的生成:userCache::1*
public User save(@RequestBody User user){
    userMapper.insert(user);
    return user;
}

key的写法如下

#user.id:#user指的是方法形参的名称, id指的是user的id属性 , 也就是使用user的id属性作为key

#result.id:#result代表方法返回值,该表达式 代表以返回对象的id属性作为key

#p0.id:#p0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key

#a0.id:#a0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key

#root.args[0].id:指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key

3.3 @Cacheable注解

在方法执行前,Spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中。

  • cacheNames:缓存的名称,每个缓存下可以有很多key

  • key:缓存的key,支持Spring的表达式语言SPEL语法

在getById上加注解@Cacheable:

SQL
*/***
* * Cacheable:在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,          *调用方法并将方法返回值放到缓存中*
* * value:缓存的名称,每个缓存名称下面可以有多个key*
* * key:缓存的key*
* */*
@GetMapping
@Cacheable(cacheNames = "userCache", key="#id")
public User getById(Long id){
    User user = userMapper.getById(id);
    return user;
}

3.4 @CacheEvict注解

@CacheEvict注解用于清理指定缓存。

  • cacheNames:缓存的名称,每个缓存下可以有很多key

  • key:缓存的key,支持Spring的表达式语言SPEL语法

  • allEntries:是否删除cacheNames缓存下的所有数据

在 delete 方法上加注解@CacheEvict:

TypeScript
@DeleteMapping
@CacheEvict(cacheNames = "userCache",key = "#id") *//删除某个key对应的缓存数据*
public void deleteById(Long id){
    userMapper.deleteById(id);
}

@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true) *//删除userCache下所有的缓存数据*
public void deleteAll(){
    userMapper.deleteAll();
}

七、Day08技术总结

1.微信支付介绍

个人身份注册的微信小程序不具备微信支付功能,所以要想实现微信支付功能,需要注册微信支付的一个商户号,这个商户号是必须要有一家企业并且有正规的营业执照。

微信支付产品:

3f91a1cf1af042e897c6fea91e24d126png

苍穹外卖使用小程序支付功能进行开发。

参考网址:

https://pay.weixin.qq.com/static/product/product_index.shtml

微信支付接入流程:

346923c213b149c69b14f96b6c2b165bpng

微信小程序支付时序图:

7204636345fd431eb9c2e397f70e12f0png

微信支付相关接口:

JSAPI下单:商户系统调用该接口在微信支付服务后台生成预支付交易单(对应时序图的第5步)

f2e6c92d2b924b75a08ee41a32df8e11png

微信小程序调起支付:通过JSAPI下单接口获取到发起支付的必要参数prepay_id,然后使用微信支付提供的小程序方法调起小程序支付(对应时序图的第10步)

0512182eb15044669a8a77d750a846e6png

2.微信支付准备工作

2.1 数据加密

完成微信支付的两个关键步骤:

第一个就是需要在商户系统当中调用微信后台的一个下单接口,就是生成预支付交易单。

第二个就是支付成功之后微信后台会给推送消息。

这两个接口数据的安全性,要求其实是非常高的。

解决:微信提供的方式就是对数据进行加密、解密、签名多种方式。要完成数据加密解密,需要提前准备相应的一些文件(即证书):微信支付平台证书、商户私钥文件

d7a2214bee484e78b1d5ae1c2a7a47a1png

2.2 公网域名获取

微信后台会调用到商户系统给推送支付的结果时,需要本地服务器的公网ip,我们可以使用内网穿透,通过cpolar软件可以获得一个临时域名,这样微信后台就可以请求到商户系统了。

cpolar软件的安装

下载地址:

https://dashboard.cpolar.com/get-started

双击资料中下载好的cpolar_amd64.msi安装即可。

cpolar指定authtoken

复制authtoken:

680c2b880cb645ce852570803f3edecapng

执行命令:

PowerShell
#在Linux上执行:
./cpolar authtoken ZmI3ZjEwY2EtM2QwNi00MDhmLThhYTItN2IyMThhNjQzZmY4
#若在windows终端上执行如下指令:
E:\software\cpolar_amd64> cpolar.exe authtoken ZmI3ZjEwY2EtM2QwNi00MDhmLThhYTItN2IyMThhNjQzZmY4

获取临时域名:

PowerShell
E:\software\cpolar_amd64> cpolar.exe http 8080

八、Day09技术总结

1.百度地图开放平台简介

检验收货地址是否超出配送范围,需要用到百度地图开放平台。

注册账号:

https://passport.baidu.com/v2/?reg&tt=1671699340600&overseas=&gid=CF954C2-A3D2-417F-9FE6-B0F249ED7E33&tpl=pp&u=https%3A%2F%2Flbsyun.baidu.com%2Findex.php%3Ftitle%3D%E9%A6%96%E9%A1%B5

登录百度地图开放平台:

https://lbsyun.baidu.com/

进入控制台,创建应用,获取AK:

0e4ac13d32204f92850b8a239f724c8epng

相关接口:

https://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding

https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-lwrouteplanapi/dirve

(具体用法可以去CSDN搜索:苍穹外卖配送范围识别

2. 代码开发

2.1 application.yml

配置外卖商家店铺地址和百度地图的AK:

1aa96101b8f94563ba746524201df6efpng

2.2 OrderServiceImpl

改造OrderServiceImpl,注入上面的配置项:

Java
@Value("${sky.shop.address}")
private String shopAddress;

@Value("${sky.baidu.ak}")
private String ak;

在OrderServiceImpl中提供校验方法:

TypeScript
*/***
* * 检查客户的收货地址是否超出配送范围*
* * @param address*
* */*
private void checkOutOfRange(String address) {
    Map map = new HashMap();
    map.put("address", shopAddress);
    map.put("output", "json");
    map.put("ak", ak);

    *//获取店铺的经纬度坐标*
*    *String shopCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

    JSONObject jsonObject = JSON.parseObject(shopCoordinate);
    if (!jsonObject.getString("status").equals("0")) {
        throw new OrderBusinessException("店铺地址解析失败");
    }

    *//数据解析*
*    *JSONObject location = jsonObject.getJSONObject("result").getJSONObject("location");
    String lat = location.getString("lat");
    String lng = location.getString("lng");
    *//店铺经纬度坐标*
*    *String shopLngLat = lat + "," + lng;

    map.put("address", address);
    *//获取用户收货地址的经纬度坐标*
*    *String userCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

    jsonObject = JSON.parseObject(userCoordinate);
    if (!jsonObject.getString("status").equals("0")) {
        throw new OrderBusinessException("收货地址解析失败");
    }

    *//数据解析*
*    *location = jsonObject.getJSONObject("result").getJSONObject("location");
    lat = location.getString("lat");
    lng = location.getString("lng");
    *//用户收货地址经纬度坐标*
*    *String userLngLat = lat + "," + lng;

    map.put("origin", shopLngLat);
    map.put("destination", userLngLat);
    map.put("steps_info", "0");

    *//路线规划*
*    *String json = HttpClientUtil.doGet("https://api.map.baidu.com/directionlite/v1/driving", map);

    jsonObject = JSON.parseObject(json);
    if (!jsonObject.getString("status").equals("0")) {
        throw new OrderBusinessException("配送路线规划失败");
    }

    *//数据解析*
*    *JSONObject result = jsonObject.getJSONObject("result");
    JSONArray jsonArray = (JSONArray) result.get("routes");
    Integer distance = (Integer) ((JSONObject) jsonArray.get(0)).get("distance");

    if (distance > 5000) {
        *//配送距离超过5000米*
*        *throw new OrderBusinessException("超出配送范围");
    }
}

在OrderServiceImpl的submitOrder方法中调用上面的校验方法:

7fe6c8085d1749c2a3e76ef747e4160cpng

2.3 小程序代码补充

设计好后端后,如果配送范围超过5000米,用户端并不会有任何提示,这样体验是不好的,可以修改小程序的源码:

在common包下的vendor.js文件中搜索submitOrderSubmit方法(大约在22124行),在方法末尾添加如下内容即可:

JavaScript
.catch(function (error) {
        // 处理错误响应
        console.error('请求失败:', error);
        uni.showToast({
          title: error.msg || '请求失败',
          icon: 'none'
        });
      })

a65910ac3ab74c3c9fb8d2820fbae782png

九、Day10技术总结

1.Spring Task

Spring Task 是Spring框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑。只要是需要定时处理的场景都可以使用Spring Task

1.1 cron表达式

cron表达式其实就是一个字符串,通过cron表达式可以定义任务触发的时间,其构成分为6或7个域,由空格分隔开,每个域代表一个含义:秒、分钟、小时、日、月、周、年(可选),如2022年10月12日上午9点整 对应的cron表达式为0 0 9 12 10 ? 2022

通配符:

  • * 表示所有值

  • ? 表示未说明的值,即不关心它为何值

  • - 表示一个指定的范围

  • , 表示附加一个可能值

  • / 符号前表示开始时间,符号后表示每次递增的值

一般日和周的值不同时设置,其中一个设置,另一个用?表示。

cron表达式有在线生成器,不需要我们自己编写即可:

https://cron.qqe2.com/

1.2 入门案例

1.导入maven坐标 spring-context:由于springboot的起步依赖依赖于spring-context,所以不需要导入也可以

2.启动类添加注解 @EnableScheduling 开启任务调度

3.自定义定时任务类

Java
package com.sky.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;

/**
 * 自定义定时任务类
 */
@Component
@Slf4j
public class MyTask {

    /**
     * 定时任务 每隔5秒触发一次
     */
    @Scheduled(cron = "0/5 * * * * ?")
    public void executeTask(){
        log.info("定时任务开始执行:{}",new Date());
    }
}

2.WebSocket

2.1 WebSocket简介

WebSocket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接, 并进行双向数据传输。

HTTP协议和WebSocket协议对比:

  • HTTP是短连接

  • WebSocket是长连接

  • HTTP通信是单向的,基于请求响应模式

  • WebSocket支持双向通信

  • HTTP和WebSocket底层都是TCP连接

3e17417733924549b2c3c92f5d9a90bcpng

由于websocket的服务器长期维护长连接需要一定成本,各个浏览器的支持程度不一样,而且受网络限制比较大,需要处理好重连,所以websocket不能取代http,只能在特定场景使用。

2.2 入门案例

实现浏览器与服务器全双工通信。浏览器既可以向服务器发送消息,服务器也可主动向浏览器推送消息。

实现步骤:

1). 直接使用websocket.html页面作为WebSocket客户端

2). 导入WebSocket的maven坐标

3). 导入WebSocket服务端组件WebSocketServer,用于和客户端通信

4). 导入配置类WebSocketConfiguration,注册WebSocket的服务端组件

5). 导入定时任务类WebSocketTask,定时向客户端推送数据

websocket.html页面(资料中已提供):

HTML
<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebSocket Demo</title>
</head>
<body>
    <input id="text" type="text" />
    <button onclick="send()">发送消息</button>
    <button onclick="closeWebSocket()">关闭连接</button>
    <div id="message">
    </div>
</body>
<script type="text/javascript">
    var websocket = null;
    var clientId = Math.random().toString(36).substr(2);

    //判断当前浏览器是否支持WebSocket
    if('WebSocket' in window){
        //连接WebSocket节点
        websocket = new WebSocket("ws://localhost:8080/ws/"+clientId);
    }
    else{
        alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function(){
        setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function(){
        setMessageInnerHTML("连接成功");
    }

    //接收到消息的回调方法
    websocket.onmessage = function(event){
        setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function(){
        setMessageInnerHTML("close");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function(){
        websocket.close();
    }

    //将消息显示在网页上
    function setMessageInnerHTML(innerHTML){
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }

    //发送消息
    function send(){
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
        
    //关闭连接
    function closeWebSocket() {
        websocket.close();
    }
</script>
</html>

导入maven坐标(苍穹外卖中已经导入):

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

定义WebSocket服务端组件:

Java
package com.sky.websocket;

import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * WebSocket服务
 */
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {

    //存放会话对象
    private static Map<String, Session> sessionMap = new HashMap();

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
        System.out.println("客户端:" + sid + "建立连接");
        sessionMap.put(sid, session);
    }

    /**
     * 收到客户端消息后调用的方法
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, @PathParam("sid") String sid) {
        System.out.println("收到来自客户端:" + sid + "的信息:" + message);
    }

    /**
     * 连接关闭调用的方法
     * @param sid
     */
    @OnClose
    public void onClose(@PathParam("sid") String sid) {
        System.out.println("连接断开:" + sid);
        sessionMap.remove(sid);
    }

    /**
     * 群发
     * @param message
     */
    public void sendToAllClient(String message) {
        Collection<Session> sessions = sessionMap.values();
        for (Session session : sessions) {
            try {
                //服务器向客户端发送消息
                session.getBasicRemote().sendText(message);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

}

定义配置类,注册WebSocket的服务端组件:

Java
package com.sky.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * WebSocket配置类,用于注册WebSocket的Bean
 */
@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

定义定时任务类,定时向客户端推送数据:

Java
package com.sky.task;

import com.sky.websocket.WebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
public class WebSocketTask {
    @Autowired
    private WebSocketServer webSocketServer;

    /**
     * 通过WebSocket每隔5秒向客户端发送消息
     */
    @Scheduled(cron = "0/5 * * * * ?")
    public void sendMessageToClient() {
        webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now()));
    }
}

十、Day11和Day12技术总结

1.Apache ECharts

1.1 介绍

Apache ECharts 是一款基于 Javascript 的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表,如柱状图、折线图、饼状图等。

不管是哪种形式的图形,最本质的东西实际上是数据,它其实是对数据的一种可视化展示。

官网地址:

https://echarts.apache.org/zh/index.html

1.2 入门案例

Apache Echarts官方提供的快速入门:

https://echarts.apache.org/handbook/zh/get-started/

实现步骤:

echarts.js

  1. 引入echarts.js 文件

  2. 为 ECharts 准备一个设置宽高的 DOM

  3. 初始化echarts实例

  4. 指定图表的配置项和数据

  5. 使用指定的配置项和数据显示图表

HTML
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>ECharts</title>
    <!-- 引入刚刚下载的 ECharts 文件 -->
    <script src="echarts.js"></script>
  </head>
  <body>
    <!-- 为 ECharts 准备一个定义了宽高的 DOM -->
    <div id="main" style="width: 600px;height:400px;"></div>
    <script type="text/javascript">
      // 基于准备好的dom,初始化echarts实例
      var myChart = echarts.init(document.getElementById('main'));

      // 指定图表的配置项和数据
      var option = {
        title: {
          text: 'ECharts 入门示例'
        },
        tooltip: {},
        legend: {
          data: ['销量']
        },
        xAxis: {
          data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']
        },
        yAxis: {},
        series: [
          {
            name: '销量',
            type: 'bar',
            data: [5, 20, 36, 10, 10, 20]
          }
        ]
      };

      // 使用刚指定的配置项和数据显示图表。
      myChart.setOption(option);
    </script>
  </body>
</html>

2.Apache POI

2.1 介绍

Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是,我们可以使用 POI 在 Java 程序中对Miscrosoft Office各种文件进行读写操作。一般情况下,POI 都是用于操作 Excel 文件。

2.2 入门案例

导入Apache POI的maven坐标:(苍穹外卖中已导入)

XML
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>3.16</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>3.16</version>
</dependency>

2.2.1 将数据写入Excel文件

Java
package com.sky.test;

import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;

public class POITest {

    /**
     * 基于POI向Excel文件写入数据
     * @throws Exception
     */
    public static void write() throws Exception{
        //在内存中创建一个Excel文件对象
        XSSFWorkbook excel = new XSSFWorkbook();
        //创建Sheet页
        XSSFSheet sheet = excel.createSheet("itcast");

        //在Sheet页中创建行,0表示第1行,1表示第二行...
        XSSFRow row1 = sheet.createRow(0);
        //创建单元格并在单元格中设置值,单元格编号也是从0开始,1表示第2个单元格...
        row1.createCell(1).setCellValue("姓名");
        row1.createCell(2).setCellValue("城市");

        XSSFRow row2 = sheet.createRow(1);
        row2.createCell(1).setCellValue("张三");
        row2.createCell(2).setCellValue("北京");

        XSSFRow row3 = sheet.createRow(2);
        row3.createCell(1).setCellValue("李四");
        row3.createCell(2).setCellValue("上海");

        FileOutputStream out = new FileOutputStream(new File("D:\\itcast.xlsx"));
        //通过输出流将内存中的Excel文件写入到磁盘上
        excel.write(out);

        //关闭资源
        out.flush();
        out.close();
        excel.close();
    }
    public static void main(String[] args) throws Exception {
        write();
    }
}

2.2.2 读取Excel文件中的数据

Java
package com.sky.test;

import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;

public class POITest {
    /**
     * 基于POI读取Excel文件
     * @throws Exception
     */
    public static void read() throws Exception{
        FileInputStream in = new FileInputStream(new File("D:\\itcast.xlsx"));
        //通过输入流读取指定的Excel文件
        XSSFWorkbook excel = new XSSFWorkbook(in);
        //获取Excel文件的第1个Sheet页(也可按照Sheet页的名字获取)
        XSSFSheet sheet = excel.getSheetAt(0);

        //获取Sheet页中的最后一行的行号
        int lastRowNum = sheet.getLastRowNum();

        for (int i = 0; i <= lastRowNum; i++) {
            //获取Sheet页中的行
            XSSFRow titleRow = sheet.getRow(i);
            //获取行的第2个单元格
            XSSFCell cell1 = titleRow.getCell(1);
            //获取单元格中的文本内容
            String cellValue1 = cell1.getStringCellValue();
            //获取行的第3个单元格
            XSSFCell cell2 = titleRow.getCell(2);
            //获取单元格中的文本内容
            String cellValue2 = cell2.getStringCellValue();

            System.out.println(cellValue1 + " " +cellValue2);
        }

        //关闭资源
        in.close();
        excel.close();
    }

    public static void main(String[] args) throws Exception {
        read();
    }
}