Spring Boot
Spring Boot基础
SpringBoot简介
SpringBoot是由Pivotal团队提供的全新框架,其设计目的是用来简化Spring应用的初始搭建以及开发过程,简单讲就是牺牲项目的自由度来减少配置的复杂度(“契约式编程”思想,SpringBoot自动配置方案的指导思想)。约定一套规则,把这些框架都自动配置集成好,从而达到“开箱即用”。同时,也支持自由配置。这就是一个非常好的方案了。
SpringBoot让创建独立的,生产环境的基于Spring的应用更加快捷简易。 大部分Spring Boot Application只要一些极简的配置,即可“一键运行”。
SpringBoot的特性如下:
- 创建独立的Spring applications
- 能够使用内嵌的Tomcat, Jetty or Undertow,不需要部署war
- 提供定制化的starter poms来简化maven配置(gradle相同)
- 追求极致的自动配置Spring
- 提供一些生产环境的特性,比如特征指标,健康检查和外部配置。
- 零代码生成和零XML配置
快速上手SpringBoot
SpringBoot入门程序开发
模块创建
创建新模块,选择Spring Initializr,并配置模块相关基础信息
![093](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220115659-1244826259.png)
![094](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220118427-2105993499.png)
Tips:
- 打包方式选择jar
- 低版本JDK的Java 版本只能选择8,高版本JDK则可选择11
- 包名止步于com.kiang,不要有后面一串
选择当前模块需要使用的技术集
![095](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220123100-335354940.png)
开发控制器类
package com.kiang.controller;
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping
public String getById() {
System.out.println("springboot is running......");
return "springboot is running......";
}
}
牛哇,不用配那一堆乱七八糟的就能嘎嘎跑
![096](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220127240-48075356.png)
Tips:
-
懒得创建可以直接复制模块:
-
制作模板:
- 删除与Idea相关配置文件,仅保留src目录与pom.xml文件
- 这些东西导入模块会再自动生成,不必担心
-
使用模板:
-
复制一份,文件夹改成模块名
-
修改 pom.xml 文件的
<artifactId>springboot_xx_xx_xx</artifactId> <name>springboot_xx_xx_xx</name>
- artifactId 相当于项目标识,相同则会冲突
- name 是在 Maven 中显示的名称,就算相同也不会冲突,甚至可以删掉,缺省会显示 artifactId。
-
导入模块,选择外部 Maven
-
-
浅谈入门程序工作原理
所以说这玩意儿跑起来全靠:
-
pom.xml文件
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.5</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.kiang</groupId> <artifactId>springboot_01_01_quickstart</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot_01_01_quickstart</name> <description>Demo project for Spring Boot</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.6.5</version> </plugin> </plugins> </build> </project>
Tips:
默认插件配置会爆红
得加自己框架版本号一致的版本号
-
Application类
package com.kiang; @SpringBootApplication public class Springboot0101QuickstartApplication { public static void main(String[] args) { SpringApplication.run(Springboot0101QuickstartApplication.class, args); } }
parent
从pom.xml可以看到开发SpringBoot程序要继承spring-boot-starter-parent:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
而且pom.xml里的依赖都不需要写version,这就是spring-boot-starter-parent的作用。spring-boot-starter-parent中定义了若干个依赖管理,继承parent模块可以避免多个依赖使用相同技术时出现依赖版本冲突(早期是需要手动调包解决冲突的)。
![099](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220149671-1707934804.png)
starter
![100](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220153542-169902730.png)
开发SpringBoot程序需要导入坐标时通常导入对应的starter,每个不同的starter根据功能不同,通常包含多个依赖坐标,使用starter可以实现快速配置的效果,达到简化配置的目的。
引导类
SpringBoot工程提供引导类用来启动程序, 工程启动后创建并初始化Spring容器。
SpringBoot的引导类是Boot工程的执行入口,运行main方法就可以启动项目。SpringBoot工程运行后初始化Spring容器,扫描引导类所在包加载bean。
内嵌tomcat
内嵌Tomcat服务器是SpringBoot辅助功能之一,其工作原理是将Tomcat服务器作为对象运行,并将该对象交给Spring容器管理。变更内嵌服务器思想是去除现有服务器,添加全新的服务器。
其内置了多种服务器:
- tomcat(默认):apache出品,粉丝多,应用面广,负载了若干较重的组件
- jetty:更轻量级,负载性能远不及tomcat
- undertow:undertow,负载性能勉强跑赢tomcat
SpringBoot基础配置
properties文件配置
-
修改服务器端口:
server.port=80
-
关闭运行日志图标(banner)
spring.main.banner-mode=off
-
设置日志相关
logging.level.root=debug
更多属性可在Spring Boot官网文档附录Application Properties查看
多种属性配置方式
SpringBoot提供了多种属性配置方式
-
application.properties
server.port=80
-
application.yml (主流格式)
server: port: 80
-
application.yaml
server: port: 80
-
SpringBoot三种配置文件加载顺序:
application.properties ---> application.yml ---> application.yaml
yaml
简介
YAML(YAML Ain't Markup Language),一种数据序列化格式
- 容易阅读
- 容易与脚本语言交互
- 以数据为核心,重数据轻格式
yaml语法规则
- 大小写敏感
- 属性层级关系使用多行描述,每行结尾使用冒号结束
- 使用缩进表示层级关系,同层级左侧对齐,只允许使用空格(不允许使用Tab键)
- 属性值前面添加空格(属性名与属性值之间使用冒号+空格作为分隔)
- # 表示注释
字面值表示方式
- boolean: TRUE #TRUE,true,True,FALSE,false,False均可
- float: 3.14 #6.8523015e+5 #支持科学计数法
- int: 123 #0b1010_0111_0100_1010_1110 #支持二进制、八进制、十六进制
- null: ~ #使用~表示null
- string: HelloWorld #字符串可以直接书写 string2: "Hello World" #可以使用双引号包裹特殊字符
- date: 2018-02-17 #日期必须使用yyyy-MM-dd格式
- datetime: 2018-02-17T15:02:31+08:00 #时间和日期之间使用T连接,最后使用+代表时区
数组表示方式
在属性名书写位置的下方使用减号作为数据开始符号,每行书写一个数据,减号与数据间空格分隔:
users: #对象数组格式
- name: Tom
age: 4
- name: Jerry
age: 5
users: #对象数组格式二
-
name: Tom
age: 4
-
name: Jerry
age: 5
#对象数组缩略格式
users2: [ { name:Tom , age:4 } , { name:Jerry , age:5 } ]
yaml数据读取
lesson: SpringBoot
server:
port: 82
enterprise:
name: itcast
age: 16
tel: 4006184000
subject:
- Java
- 前端
- 大数据
-
使用@Value读取单个数据,属性名引用方式:${一级属性名.二级属性名……}
@RestController @RequestMapping("/books") public class BookController { @Value("${lesson}") private String lessonName; @Value("${server.port}") private int port; @Value("${enterprise.subject[1]}") private String[] subject_01; }
-
在配置文件中可以使用属性名引用方式引用属性
baseDir: /usr/local/fire center: dataDir: ${baseDir}/data tmpDir: ${baseDir}/tmp logDir: ${baseDir}/log msgDir: ${baseDir}/msgDir
-
属性值中如果出现转义字符,需要使用双引号包裹
lesson: "Spring\tboot\nlesson"
-
可以封装全部数据到Environment对象,使用@Autowired自动装配
@RestController @RequestMapping("/books") public class BookController { @Autowired private Environment env; @GetMapping("/{id}") public String getById(@PathVariable Integer id){ System.out.println(env.getProperty("lesson")); System.out.println(env.getProperty("enterprise.name")); System.out.println(env.getProperty("enterprise.subject[0]")); return "hello , spring boot!"; } }
-
自定义对象封装指定数据
@Component @ConfigurationProperties(prefix = "enterprise") public class Enterprise { private String name; private Integer age; private String[] subject; // 省略get、tostring }
@RestController @RequestMapping("/books") public class BookController { @Autowired private Enterprise enterprise; }
整合第三方技术
整合JUnit
-
导入测试对应的starter
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
-
测试类使用@SpringBootTest修饰
package com.kiang; @SpringBootTest class Springboot04JunitApplicationTests { }
-
使用自动装配的形式添加要测试的对象
package com.kiang; @SpringBootTest class Springboot04JunitApplicationTests { // 注入测试对象 @Autowired private BookDao bookDao; @Test void contextLoads() { bookDao.save(); } }
Tips:
-
测试类如果存在于引导类所在包或子包中无需指定引导类
-
测试类如果不存在于引导类所在的包或子包中需要通过 classes 属性指定引导类
@SpringBootTest(classes = 引导类类名.class) class Springboot04JunitApplicationTests { }
整合MyBatis
-
创建模块
MyBatis需要选择的技术是 MyBatis Framework 和 MySQL Driver
勾选MyBatis技术,即可导入MyBatis对应的starter
-
设置数据源参数
# 配置相关信息 spring: datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC username: root password: 102576
-
定义数据层接口与映射配置
数据库SQL映射需要添加@Mapper被容器识别到,@Mapper注释用来表示该接口类的实现类对象交给mybatis底层创建,然后交由Spring框架管理。
package com.kiang.dao; @Mapper public interface BookDao { @Select("select * from tbl_book where id = #{id}") public Book getById(Integer id); }
-
测试类中注入dao接口,测试功能
会报 "无法自动装配。未找到 'BookDao' 类型的 Bean" 错误,但不影响运行。
package com.kiang; @SpringBootTest class Springboot05MybatisApplicationTests { @Autowired private BookDao bookDao; @Test void contextLoads() { System.out.println(bookDao.getById(1)); ApplicationContext ac = new ClassPathXmlApplicationContext(); } }
整合Druid
-
创建模块
与整合MyBatis一样需要选择的技术是 MyBatis Framework 和 MySQL Driver
对应 starter 需要手动导入
-
导入Druid对应的starter
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.6</version> </dependency>
-
指定数据源类型
#spring: # datasource: # driver-class-name: com.mysql.jdbc.Driver # url: jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC # username: root # password: 102576 # type: com.alibaba.druid.pool.DruidDataSource #推荐下面这种方法: spring: datasource: druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC username: root password: 102576
-
其余同整合MyBatis一样
基于SpringBoot的整合SSMP整合案例
项目创建
勾选 MySQL Driver 和 Web 技术即可,其余的需要手动导入
导入坐标
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.6</version>
</dependency>
实体类开发
Lombok
一个Java类库,提供了一组注解,简化POJO实体类开发
使用需导坐标装插件:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
lombok版本由SpringBoot提供,无需指定版本。
![107](/i/l/?n=22&i=blog/2258925/202210/2258925-20221028220241228-598232230.png)
使用方式:
- @Getter/@Setter:作用类上,生成所有成员变量的getter/setter方法;作用于成员变量上,生成该成员变量的getter/setter方法。可以设定访问权限及是否懒加载等。
- @ToString:作用于类,覆盖默认的toString()方法,可以通过of属性限定显示某些字段,通过exclude属性排除某些字段。
- @EqualsAndHashCode:作用于类,覆盖默认的equals和hashCode
- @RequiredArgsConstructor:生成包含final和@NonNull注解的成员变量的构造器;
- @Data:作用于类上,是以下注解的集合:@ToString @EqualsAndHashCode @Getter @Setter @RequiredArgsConstructor
实体类
package com.kiang.domain;
@Data
public class Book {
private Integer id;
private String type;
private String name;
private String description;
}
数据层开发
技术实现方案:MyBatisPlus + Druid
配置数据源与MyBatisPlus对应的基础配置
(id生成策略使用数据库自增策略)
spring:
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC
username: root
password: 102576
mybatis-plus:
global-config:
db-config:
table-prefix: tbl_
id-type: auto
继承BaseMapper并指定泛型
package com.kiang.domain.dao;
@Mapper
public interface BookDao extends BaseMapper<Book> {
}
MP已实现基本的增删改查,故直接继承即可使用,也再添加。
可以大概测一下基本增删改查:
package com.kiang.dao;
@SpringBootTest
public class BookDaoTestCase {
@Autowired
private BookDao bookDao;
@Test
void testGetById() {
System.out.println(bookDao.selectById(1));
}
@Test
void testSave() {
Book book = new Book();
book.setType("test");
book.setName("test");
book.setDescription("test");
bookDao.insert(book);
}
@Test
void testUpdate() {
Book book = new Book();
book.setId(7);
book.setType("test1");
book.setName("test1");
book.setDescription("test1");
bookDao.updateById(book);
}
@Test
void testDelete() {
bookDao.deleteById(7);
}
}
为方便调试可以开启MyBatisPlus的日志:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
分页功能
@Test
void testGetPage() {
IPage page = new Page(1,5);
IPage page1 = bookDao.selectPage(page,null);
}
IPage对象中封装了分页操作中的所有数据:数据、当前页码值 、每页数据总量、最大页码值 和数据总量。
分页操作是在MyBatisPlus的常规操作基础上增强得到,内部是动态的拼写SQL语句,因此需要增强对应的功能, 使用MyBatisPlus拦截器实现
package com.kiang.config;
@Configuration
public class MPConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
焯,千万不要把@Configuration写成@Configurable
条件查询功能
使用QueryWrapper对象封装查询条件,推荐使用LambdaQueryWrapper对象,所有查询操作封装成方法调用
@Test
void testGetBy() {
QueryWrapper<Book> qw = new QueryWrapper<>();
qw.like("name","a");
bookDao.selectList(qw);
}
@Test
void testGetBy2() {
LambdaQueryWrapper<Book> lqw = new LambdaQueryWrapper<>();
lqw.like(Book::getName,"c");
bookDao.selectList(lqw);
}
业务层开发
接口定义
package com.kiang.service;
public interface BookService {
Boolean save(Book book);
Boolean update(Book book);
Boolean delete(Integer id);
Book getById(Integer id);
List<Book> getAll();
IPage<Book> getPage(int currentPage, int pageSize);
}
实现类定义
package com.kiang.service.impl;
@Service
public class BookServiceImpl2 implements BookService {
@Autowired
private BookDao bookDao;
@Override
public Boolean save(Book book) {
return bookDao.insert(book) > 0;
}
@Override
public Boolean update(Book book) {
return bookDao.updateById(book) > 0;
}
@Override
public Boolean delete(Integer id) {
return bookDao.deleteById(id) > 0;
}
@Override
public Book getById(Integer id) {
return bookDao.selectById(id);
}
@Override
public List<Book> getAll() {
return bookDao.selectList(null);
}
@Override
public IPage<Book> getPage(int currentPage, int pageSize) {
IPage page = new Page(currentPage,pageSize);
bookDao.selectPage(page,null);
return page;
}
}
测试类定义
package com.kiang.service;
@SpringBootTest
public class BookServiceTestCase {
@Autowired
private BookService bookService;
@Test
void testGetById() {
System.out.println(bookService.getById(4));
}
@Test
void testSave(){
Book book = new Book();
book.setType("test");
book.setName("test");
book.setDescription("test");
bookService.save(book);
}
@Test
void testUpdate(){
Book book = new Book();
book.setId(16);
book.setType("test1");
book.setName("test1");
book.setDescription("test1");
bookService.update(book);
}
@Test
void testDelete(){
bookService.delete(16);
}
@Test
void testGetAll(){
bookService.getAll();
}
@Test
void testGetPage() {
IPage<Book> page = bookService.getPage(2, 5);
System.out.println(page.getCurrent());
System.out.println(page.getSize());
System.out.println(page.getTotal());
System.out.println(page.getPages());
System.out.println(page.getRecords());
}
}
快速开发
接口定义
package com.kiang.service;
public interface IBookService extends IService<Book> {
boolean saveBook(Book book);
boolean modify(Book book);
boolean delete(Integer id);
IPage<Book> getPage(int currentPage, int pageSize);
IPage<Book> getPage(int currentPage, int pageSize, Book book);
}
实现类定义
package com.kiang.service.impl;
@Service
public class BookServiceImpl extends ServiceImpl<BookDao, Book> implements IBookService {
@Autowired
private BookDao bookDao;
@Override
public boolean saveBook(Book book) {
return bookDao.insert(book) > 0;
}
@Override
public boolean modify(Book book) {
return bookDao.updateById(book) > 0;
}
@Override
public boolean delete(Integer id) {
return bookDao.deleteById(id) > 0;
}
@Override
public IPage<Book> getPage(int currentPage, int pageSize) {
IPage page = new Page(currentPage,pageSize);
bookDao.selectPage(page,null);
return page;
}
@Override
public IPage<Book> getPage(int currentPage, int pageSize, Book book) {
LambdaQueryWrapper<Book> lqw = new LambdaQueryWrapper<Book>();
lqw.like(Strings.isNotEmpty(book.getType()),Book::getType,book.getType());
lqw.like(Strings.isNotEmpty(book.getName()),Book::getName,book.getName());
lqw.like(Strings.isNotEmpty(book.getDescription()),Book::getDescription,book.getDescription());
IPage page = new Page(currentPage,pageSize);
bookDao.selectPage(page,lqw);
return page;
}
}
表现层开发
表现层接口开发
package com.kiang.Controller;
@RestController
@RequestMapping("/books")
public class BookController {
@Autowired
private IBookService bookService;
@GetMapping
public List<Book> getAll() {
return bookService.list();
}
@PostMapping
public Boolean save(@RequestBody Book book) {
return bookService.save(book);
}
@PutMapping
public Boolean update(@RequestBody Book book) {
return bookService.modify(book);
}
@DeleteMapping("{id}")
public boolean delete(@PathVariable Integer id) {
return bookService.delete(id);
}
@GetMapping("{id}")
public Book getById(@PathVariable Integer id) {
return bookService.getById(id);
}
}
使用Postman逐一测试
表现层消息一致性处理
package com.kiang.Controller.utils;
@Data
public class R {
private Boolean flag;
private Object data;
public R() {
}
public R(Boolean flag) {
this.flag = flag;
}
public R(Boolean flag, Object data) {
this.flag = flag;
this.data = data;
}
}
返回数据改成类似这种:
@GetMapping
public R getAll() {
// return bookService.list();
return new R(true, bookService.list());
}
前后端协议联调
- 单体项目中页面放置在 resources/static 目录下
- created 钩子函数用于初始化页面时发起调用
- 页面使用axios发送异步请求获取数据后确认前后端是否联通
前端发送异步请求,调用后端接口
getAll() {
axios.get("/books").then((res)=>{
console.log(res);
})
}
列表页
将查询数据返回到页面,利用前端数据双向绑定进行数据展示
getAll() {
axios.get("/books").then((res)=>{
// console.log(res);
this.dataList = res.data.data;
})
}
弹出添加窗口
//弹出添加窗口
handleCreate() {
this.dialogFormVisible = true;
}
清除表单数据
//弹出添加窗口
handleCreate() {
this.dialogFormVisible = true;
this.resetForm();
},
//重置表单
resetForm() {
this.formData = {};
}
添加
handleAdd () {
axios.post("/books", this.formData).then((res)=>{
if(res.data.flag){
this.dialogFormVisible = false;
this.$message.success("添加成功");
}else{
this.$message.success("添加失败");
}
}).finally(()=>{
this.getAll();
})
}
取消添加
cancel(){
this.dialogFormVisible = false;
this.$message.info("当前操作取消");
}
删除
- 请求方式使用Delete调用后台对应操作
- 删除操作需要传递当前行数据对应的id值到后台
- 删除操作结束后动态刷新页面加载数据
- 根据操作结果不同,显示对应的提示信息
- 删除操作前弹出提示框避免误操作
handleDelete(row) {
this.$confirm("此操作为永久删除,是否继续?","提示",{type: "info"}).then(()=>{
axios.delete("/books/" + row.id).then((res)=>{
if(res.data.flag){
this.$message.success("删除成功");
}else{
this.$message.error("删除失败");
}
}).finally(()=>{
this.getAll();
})
}).catch(()=>{
this.$message.info("当前操作取消");
})
}
弹出修改窗口
handleUpdate(row) {
axios.get("/books/" + row.id).then((res)=>{
if(res.data.flag && res.data.data != null){
this.dialogFormVisible4Edit = true;
this.formData = res.data.data;
}else {
this.$message.error("数据同步失败");
}
}).finally(()=>{
this.getAll();
})
}
修改
和添加逻辑相同只是请求方式不同
handleEdit() {
axios.put("/books", this.formData).then((res)=>{
if(res.data.flag){
this.dialogFormVisible4Edit = false;
this.$message.success("修改成功");
}else{
this.$message.success("修改失败");
}
}).finally(()=>{
this.getAll();
})
}
业务消息一致性处理
修改表现层返回结果的模型类,封装出现异常后对应的信息
package com.kiang.Controller.utils;
@Data
public class R {
private Boolean flag;
private Object data;
private String msg;
}
写个异常处理,否则出异常会返回:
{
"timestamp": "2021-09-15T03:27:31.038+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/books"
}
写个异常处理模块
package com.kiang.Controller.utils;
@RestControllerAdvice
public class ProjectExceptionAdvice {
@ExceptionHandler
public R doException(Exception ex) {
ex.printStackTrace();
return new R("服务器故障");
}
}
分页功能
getAll() {
axios.get("/books/" + this.pagination.currentPage + "/" + this.pagination.pageSize).then((res)=>{
this.pagination.pageSize = res.data.data.size;
this.pagination.currentPage = res.data.data.current;
this.pagination.total = res.data.data.total;
this.dataList = res.data.data.records;
});
}
handleCurrentChange(currentPage) {
this.pagination.currentPage = currentPage;
this.getAll();
}
标签:Spring,class,Boot,public,Book,id,com,book
From: https://www.cnblogs.com/AncilunKiang/p/16837678.html