本文为《从零打造项目》系列第三篇文章,首发于个人网站。

《从零打造项目》系列文章

比MyBatis Generator更强大的代码生成器

SpringBoot项目基础设施搭建

SpringBoot集成Mybatis项目实操

前言

基于 orm-generate 项目可以实现项目模板代码,集成了三种 ORM 方式:Mybatis、Mybatis-Plus 和 Spring JPA,JPA 是刚集成进来的,该项目去年就已经发布过一版,也成功实现了想要的功能,关于功能介绍可以参考我之前的这篇文章。

如何搭建一个项目是每个开发人员会面临的问题。新人一般很难参与项目基础框架搭建,只会在项目基础框架布置完毕后才加入进来,接着进行功能模块代码开发,无法体验项目搭建的过程,其中包括一些通用代码。等到换个项目,可能还是重复之前的功能开发,而且每个项目基础框架可能不太一样,但我们能做的只是按照现有规定进行开发,对于一个项目的基础设施了解不多,也无法明白为何要选某某框架。为了突破,为了成长,新人有必要跟着参与一次项目基础框架搭建,切身体会各种框架的差异,参与一些通用代码的开发,而不仅仅是 CRUD。

上面提到的项目基础架构,比如说选择 SpringBoot 或者 SpringMVC,再比如流行的三种 ORM 框架:Mybatis、Mybatis-Plus 和 Spring JPA,这里我们暂时不关注 SpringCloud 框架,因为每个微服务还是基于 SpringBoot,至于其他各种中间件,暂时也不做考虑(我的视角暂时无法达到那样的高度)。

运行 orm-generate 项目可以从现有的数据库中获取模版代码,大致包含如下几部分内容:

  • entity, 实体层,用于存放我们的实体类,与数据库中的属性值基本保持一致,实现set和get的方法 ;
  • mapper/dao/repository,对数据库进行数据持久化操作,它的方法语句是直接针对数据库操作的,主要实现一些增删改查操作,在 mybatis 中方法主要与与 xxx.xml 内相互一一映射;
  • service,业务 service 层,给 controller 层的类提供接口进行调用。一般就是自己写的方法封装起来,就是声明一下,具体实现在 serviceImpl 中;
  • controller,控制层,负责具体模块的业务流程控制,需要调用 service 逻辑设计层的接口来控制业务流程。因为 service 中的方法是我们使用到的,controller 通过接收前端 H5 或者 App 传过来的参数进行业务操作,再将处理结果返回到前端。
  • dto文件,用来分担实体类的功效,可以将查询条件单独封装一个类,以及前后端交互的实体类(有时候我们可能会传入 entity 实体类中不存在的字段);
  • vo文件,后台返回给前台的数据结构,同样可以自定义字段。
  • struct文件,dto、entity与vo文件相互转换。

有了这些模版代码,我们还需要构建项目基础架构,用来存放这些代码,并对其进行修改,最终实现功能开发。

本文将实现 SpringBoot+Mybatis 的项目搭建,除此之外,还有一些通用代码配置:

  • 统一返回格式
  • 全局异常处理
  • Mybatis 操作工具类
  • 字符串等工具类
  • 请求日志记录

不说废话了,我们直接进入主题。

数据库

本项目采用的是 MySQL 数据库,版本为 8.x,建表语句如下:

CREATE TABLE `user` (`id` varchar(36) NOT NULL,`name` varchar(20) DEFAULT NULL,`age` int(11) DEFAULT NULL,`address` varchar(100) DEFAULT NULL,`created_date` timestamp NULL DEFAULT NULL,`last_modified_date` timestamp NULL DEFAULT NULL,`del_flag` tinyint(1) NOT NULL DEFAULT '0',`create_user_code` varchar(36) DEFAULT NULL,`create_user_name` varchar(50) DEFAULT NULL,`last_modified_code` varchar(36) DEFAULT NULL,`last_modified_name` varchar(50) DEFAULT NULL,`version` int(11) NOT NULL DEFAULT '1',PRIMARY KEY (`id`),UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户';CREATE TABLE `job` (`id` varchar(36) NOT NULL,`name` varchar(20) DEFAULT NULL,`user_id` varchar(36) NOT NULL,`address` varchar(100) DEFAULT NULL,`created_date` timestamp NULL DEFAULT NULL,`last_modified_date` timestamp NULL DEFAULT NULL,`del_flag` tinyint(1) NOT NULL DEFAULT '0',`create_user_code` varchar(36) DEFAULT NULL,`create_user_name` varchar(50) DEFAULT NULL,`last_modified_code` varchar(36) DEFAULT NULL,`last_modified_name` varchar(50) DEFAULT NULL,`version` int(11) NOT NULL DEFAULT '1',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='工作';

构建的数据

INSERT INTO mysql_db.`user` (id,name,age,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES('1f5ffce8eda44809b91af9857cde1870',NULL,25,'中国武汉','2022-09-20 23:15:43','2022-09-20 23:15:43',0,'1','hresh','1','hresh',0),('23dc1219d58c427884212127606fc830','clearLove',28,'中国上海','2022-09-19 21:14:56','2022-09-19 21:14:56',0,'1','hresh','1','hresh',0),('4f5f617e651a4126b2847f2f25537995','',25,'中国武汉','2022-09-20 22:53:29','2022-09-20 22:53:29',0,'1','hresh','1','hresh',0),('55dc89810e394306b66ab9567b568534','ascii0',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),('8ac44600842b427c8ef12978c5e8c501',NULL,25,'中国武汉','2022-09-20 22:58:34','2022-09-20 22:58:34',0,'1','hresh','1','hresh',0),('93473b532494477b9d8d34b3165d216a','ascii1',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),('a45908e8bc874940a6d682370a0ca8d7','clearLove3',28,'中国上海','2022-09-19 22:46:18','2022-09-19 22:46:18',0,'1','hresh','1','hresh',0),('cd613ce660264dc18b15b3333a6421da','ascii2',21,'中国广东','2022-09-19 21:30:06','2022-09-19 21:30:06',0,'1','hresh','1','hresh',0),('e30606f8ee7649499811e74fbc7df583',NULL,25,'中国武汉','2022-09-20 23:11:31','2022-09-20 23:11:31',0,'1','hresh','1','hresh',0),('f68073dc14be4be4a1042fcf78f8f7df','hresh',25,'中国武汉','2022-09-20 07:22:06','2022-09-20 07:22:06',0,'1','hresh','1','hresh',0);
INSERT INTO mysql_db.`user` (id,name,age,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES('fe4f62a77f75468e956fb285475ba3f3','clearLove2',28,'中国上海','2022-09-19 21:16:46','2022-09-19 21:16:46',0,'1','hresh','1','hresh',0);INSERT INTO mysql_db.job (id,name,user_id,address,created_date,last_modified_date,del_flag,create_user_code,create_user_name,last_modified_code,last_modified_name,version) VALUES('55dc89810e394306b66ab9567b568512','程序员','55dc89810e394306b66ab9567b568534','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),('55dc89810e394306b66ab9567b568513','外卖员','55dc89810e394306b66ab9567b568534','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),('55dc89810e394306b66ab9567b568514','外卖员','93473b532494477b9d8d34b3165d216a','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0),('55dc89810e394306b66ab9567b568515','厨师','93473b532494477b9d8d34b3165d216a','中国湖北',NULL,NULL,0,NULL,NULL,NULL,NULL,0);

搭建SpringBoot项目

使用 IDEA 新建一个 Maven 项目,叫做 mybatis-springboot。

一些共用的基础代码可以参考上篇文章,这里不做重复介绍,会介绍一些 Mybatis 相关的代码。

引入依赖

<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.6.3</version>
</parent><properties><java.version>1.8</java.version><fastjson.version>1.2.73</fastjson.version><hutool.version>5.5.1</hutool.version><mysql.version>8.0.19</mysql.version><mybatis.version>2.1.4</mybatis.version><mapper.version>4.1.5</mapper.version><org.mapstruct.version>1.4.2.Final</org.mapstruct.version><org.projectlombok.version>1.18.20</org.projectlombok.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-aop</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>${fastjson.version}</version></dependency><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>${hutool.version}</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${org.projectlombok.version}</version><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>${mysql.version}</version><scope>runtime</scope></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>${mybatis.version}</version></dependency><dependency><groupId>tk.mybatis</groupId><artifactId>mapper</artifactId><version>${mapper.version}</version></dependency><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.4.3</version></dependency><dependency><groupId>org.springframework.data</groupId><artifactId>spring-data-commons</artifactId><version>2.4.6</version></dependency><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-ui</artifactId><version>1.6.9</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.1.18</version></dependency><dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${org.mapstruct.version}</version></dependency><dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${org.mapstruct.version}</version></dependency>
</dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins>
</build>

有些依赖不一定是最新版本,而且你看到这篇文章时,可能已经发布了新版本,到时候可以先模仿着将项目跑起来后,再根据自己的需求来升级各项依赖,有问题咱再解决问题。

分页处理

某些业务场景是需要分页查询和排序功能的,所以我们需要考虑前端如何传递参数给后端,后端如何进行分页查询或者是排序查询。我们使用的是 Mybatis,该框架有个配套的分页插件——PageHelper。

分页基础类

public class SimplePageInfo {private Integer pageNum = 1;private Integer pageSize = 10;public Integer getPageNum() {return pageNum;}public void setPageNum(Integer pageNum) {this.pageNum = pageNum;}public Integer getPageSize() {return pageSize;}public void setPageSize(Integer pageSize) {this.pageSize = pageSize;}
}

排序包装类

@Getter
@Setter
public class OrderInfo {private boolean asc = true;private String column;
}

分页且排序包装类

@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class PageSortInfo extends SimplePageInfo {@Schema(name = "排序信息")private List<OrderInfo> orderInfos;public String parseSort() {if (CollectionUtils.isEmpty(orderInfos)) {return null;}StringBuilder sb = new StringBuilder();for (OrderInfo orderInfo : orderInfos) {sb.append(orderInfo.getColumn()).append(" ");sb.append(orderInfo.isAsc() ? " ASC," : " DESC,");}sb.deleteCharAt(sb.length() - 1);return sb.toString();}
}

前端分页查询的请求体对象

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserQueryPageDTO {private String name;@JsonUnwrappedprivate PageSortInfo pageSortInfo;
}

服务层分页查询

PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),dto.getPageSortInfo().parseSort());
Page<User> userPage = (Page<User>) userMapper.select(user);

关于 PageHelper 的使用这里就不多做介绍了。

我们得到的分页查询结果是 Page 对象,可以直接使用,也可以根据需要进行修改,比如下面这个文件:

@Getter
@Setter
public class PageResult<T> {/*** 总条数*/private Long total;/*** 总页数*/private Integer pageCount;/*** 每页数量*/private Integer pageSize;/*** 当前页码*/private Integer pageNum;/*** 分页数据*/private List<T> data;/*** 处理Mybatis分页结果*/public static <T> PageResult<T> ok(Page<T> page) {PageResult<T> result = new PageResult<T>();result.setPageCount(page.getPages());result.setPageNum(page.getPageNum());result.setPageSize(page.getPageSize());result.setTotal(page.getTotal());result.setData(page.getResult());return result;}
}

Mybatis 分页结果除了 Page,还有 PageInfo,Page 继承 ArrayList,PageInfo 对象中的字段更多,这块可以结合项目实际情况进行选择。

Mybatis基础实体类

作为其他实体类的父类,封装了所有的公共字段,包括逻辑删除标志,版本号,创建人和修改人信息。到底是否需要那么多字段,结合实际情况,这里的示例代码比较全,其中@LogicDelete 和@Version 是 Mybatis 特有的注解,@CreatedBy、@CreatedDate 是Springframework 自带的注解,如果我们需要新建人和修改人姓名,则需要自定义注解。

@Data
@Schema(title = "核心基础实体类")
@NoArgsConstructor
@AllArgsConstructor
public class BaseEntity implements Serializable {private static final long serialVersionUID = 1L;@Schema(description = "删除标记")@LogicDelete@Column(name = "del_flag")private Boolean delFlag;@Schema(description = "创建人代码")@CreatedBy@Column(name = "create_user_code")private String createUserCode;@Schema(name = "创建人姓名")@CreatedName@Column(name = "create_user_name")private String createUserName;@Schema(name = "创建时间")@CreatedDate@Column(name = "created_date")private LocalDateTime createdDate;@Schema(name = "修改人代码")@LastModifiedBy@Column(name = "last_modified_code")private String lastModifiedCode;@Schema(name = "修改人姓名")@LastModifiedName@Column(name = "last_modified_name")private String lastModifiedName;@Schema(name = "修改时间")@LastModifiedDate@Column(name = "last_modified_date")private LocalDateTime lastModifiedDate;@Schema(name = "版本号")@Version@Column(name = "version")private Integer version;
}

自定义注解如下:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CreatedName {}@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LastModifiedName {}

Mybatis拦截器

MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。

通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。

// ExamplePlugin.java
@Intercepts({@Signature(type= Executor.class,method = "update",args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {private Properties properties = new Properties();public Object intercept(Invocation invocation) throws Throwable {// implement pre processing if needObject returnObject = invocation.proceed();// implement post processing if needreturn returnObject;}public void setProperties(Properties properties) {this.properties = properties;}
}

全局xml配置:

<plugins><plugin interceptor="org.format.mybatis.cache.interceptor.ExamplePlugin"></plugin>
</plugins>

这个拦截器拦截 Executor 接口的 update 方法(其实也就是 SqlSession 的新增,删除,修改操作),所有执行executor 的 update 方法都会被该拦截器拦截到。

Mybatis 拦截器接口定义如下:

public interface Interceptor {Object intercept(Invocation var1) throws Throwable;default Object plugin(Object target) {return Plugin.wrap(target, this);}default void setProperties(Properties properties) {}
}

查看我们自定义的拦截器实现类

@Slf4j
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class,Object.class})})
public class AutoFillFieldInterceptor implements Interceptor {private static final Logger logger = LoggerFactory.getLogger(AutoFillFieldInterceptor.class);@Overridepublic Object intercept(Invocation invocation) throws Throwable {logger.info("执行intercept方法:{}", invocation.toString());Object[] args = invocation.getArgs();MappedStatement mappedStatement = (MappedStatement) args[0];SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();if (sqlCommandType != SqlCommandType.INSERT && sqlCommandType != SqlCommandType.UPDATE) {return invocation.proceed();}Object parameter = args[1];Class<?> clazz = parameter.getClass();// 批量SQL操作if (Map.class.isAssignableFrom(clazz)) {Map<String, Object> paramMap = (Map<String, Object>) parameter;if (paramMap.containsKey("recordList")) {processData(sqlCommandType, paramMap.get("recordList"));} else if (paramMap.containsKey("collection")) {processData(sqlCommandType, paramMap.get("collection"));} else if (paramMap.containsKey("list")) {processData(sqlCommandType, paramMap.get("list"));}} else {// 单个SQL操作processData(sqlCommandType, parameter);}return invocation.proceed();}private boolean isSkipInject(Class clazz) {return clazz.getAnnotation(Table.class) == null;}private void processData(SqlCommandType sqlCommandType, Object parameter) {Class<?> clazz = parameter.getClass();if (Collection.class.isAssignableFrom(clazz)) {Collection<?> collection = (Collection<?>) parameter;for (Object object : collection) {processData(sqlCommandType, object);}return;}if (isSkipInject(clazz)) {return;}List<EntityField> entityFieldList = getFields(clazz);MetaObject metaObject = MetaObjectUtil.forObject(parameter);for (EntityField field : entityFieldList) {if (sqlCommandType == SqlCommandType.INSERT) {if (field.isAnnotationPresent(CreatedDate.class)) {metaObject.setValue(field.getName(), LocalDateTime.now());}if (field.isAnnotationPresent(CreatedBy.class)) {String id = "1";metaObject.setValue(field.getName(), id);}if (field.isAnnotationPresent(CreatedName.class)) {metaObject.setValue(field.getName(), "hresh");}if (field.isAnnotationPresent(Version.class)) {metaObject.setValue(field.getName(), 0);}if (field.isAnnotationPresent(LogicDelete.class)) {metaObject.setValue(field.getName(), false);}}if (field.isAnnotationPresent(LastModifiedDate.class)) {metaObject.setValue(field.getName(), LocalDateTime.now());}if (field.isAnnotationPresent(LastModifiedBy.class)) {metaObject.setValue(field.getName(), "1");}if (field.isAnnotationPresent(LastModifiedName.class)) {metaObject.setValue(field.getName(), "hresh");}}}private List<EntityField> getFields(Class clazz) {return EntityHelper.getColumns(clazz).stream().map(EntityColumn::getEntityField).collect(Collectors.toList());}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {}
}

关于上述代码根据项目实际需求进行调整,来填充非关键数据。

那么该拦截器实现类是如何注册到 Spring 容器中的呢?还是全局搜索查看位置。

@Configuration
@ConditionalOnBean({SqlSessionFactory.class})
public class MybatisAutoConfiguration {@Autowiredprivate SqlSessionFactory sqlSessionFactory;@PostConstructpublic void init() {sqlSessionFactory.getConfiguration().addInterceptor(new AutoFillFieldInterceptor());}
}

最后在 resources 目录下创建META-INF目录下,在 META-INF 目录下创建 spring.factories 文件,文件内容如下:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.msdn.orm.hresh.common.mybatis.config.MybatisAutoConfiguration

批量操作功能

批量操作包括批量新增、修改、删除等功能。

虽然 Mybatis 提供了 IdListMapper<T, Long> InsertListMapper 这两个接口来实现批量操作,但功能有限,所以一般情况下我们会自定义批量操作类。

此时可以自定义一个通用 BaseMapper,如以下接口,再让编写的 mapper 继承这个 BaseMapper。

需要注意的是:自定义的通用 mapper,想要生效,必须要加上@RegisterMapper 注解。

@RegisterMapper
public interface ListMapper<T, PK> {/*** 批量插入,支持批量插入的数据库可以使用,** @param recordList* @return*/@InsertProvider(type = ListProvider.class, method = "dynamicSQL")int insertList(List<? extends T> recordList);/*** 批量更新** @return*/@UpdateProvider(type = ListProvider.class, method = "dynamicSQL")int updateBatchByPrimaryKeySelective(List<? extends T> recordList);/*** 根据主键字符串进行查询,类中只有存在一个带有@Id注解的字段** @param idList* @return*/@SelectProvider(type = ListProvider.class, method = "dynamicSQL")List<T> selectByIdList(@Param("idList") List<PK> idList);/*** 根据主键字符串进行删除,类中只有存在一个带有@Id注解的字段** @param idList* @return*/@DeleteProvider(type = ListProvider.class, method = "dynamicSQL")int deleteByIdList(@Param("idList") List<PK> idList);
}

关于批量操作的具体代码位于 ListProvider 文件中,因为代码比较多,这里只贴出批量新增的代码:

/*** 填充主键值** @param list*/public static void fillId(List<?> list, String fieldName) {for (Object object : list) {MetaObject metaObject = MetaObjectUtil.forObject(object);if (metaObject.getValue(fieldName) == null) {metaObject.setValue(fieldName, IdUtils.genId());}}}/*** 批量插入** @param ms*/public String insertList(MappedStatement ms) {final Class<?> entityClass = getEntityClass(ms);//开始拼sqlStringBuilder sql = new StringBuilder();List<EntityColumn> pkColumns = new ArrayList<>(EntityHelper.getPKColumns(entityClass));sql.append("<bind name=\"listNotEmptyCheck\" value=\"@tk.mybatis.mapper.util.OGNL@notEmptyCollectionCheck(list, '"+ ms.getId() + " 方法参数为空')\"/>");sql.append("<bind name=\"fillIdProcess\" value=\"@com.msdn.orm.hresh.common.mybatis.ListProvider@fillId(list, '"+ pkColumns.get(0).getProperty() + "')\"/>");sql.append(SqlHelper.insertIntoTable(entityClass, tableName(entityClass), "list[0]"));sql.append(SqlHelper.insertColumns(entityClass, false, false, false));sql.append(" VALUES ");sql.append("<foreach collection=\"list\" item=\"record\" separator=\",\" >");sql.append("<trim prefix=\"(\" suffix=\")\" suffixOverrides=\",\">");//获取全部列Set<EntityColumn> columnList = EntityHelper.getColumns(entityClass);//当某个列有主键策略时,不需要考虑它的属性是否为空,因为如果为空,一定会根据主键策略给他生成一个值for (EntityColumn column : columnList) {if (column.isInsertable()) {sql.append(column.getColumnHolder("record") + ",");}}sql.append("</trim>");sql.append("</foreach>");// 反射把MappedStatement中的设置主键名EntityHelper.setKeyProperties(EntityHelper.getPKColumns(entityClass), ms);return sql.toString();}

动态链式查询

虽然我们可以在 mapper.xml 文件中自定义 SQL 查询,但这样做有些麻烦,如果能够在代码中编写类似于 SQL 条件的 Code,对于开发人员来说,更加便捷,可读性也比较好。注意,特别麻烦的 SQL 语句还是要在 mapper.xml 文件中自定义。

这里借助 tk 的通用 mapper 实在 mybatis 中使用 Example 实现动态查询,大概有四种方式,可以参考本文。

其中方式三是:Example.builder 方式(其中 where 从句中内容可以拿出来进行动态 sql 拼接)

Example example = Example.builder(MybatisDemo.class).select("cabId","cabName").where(Sqls.custom().andEqualTo("count", 0).andLike("name", "%d%")).orderByDesc("count","name").build();
List<MybatisDemo> demos = mybatisDemoMapper.selectByExample(example);

可以看到上述实现方式,需要手动输入属性名,一旦数据库有变动或输入错误就会出错,不符合我们的期望。因此我们选择方式四。

//获得seekendsql
WeekendSqls<MybatisDemo> sqls = WeekendSqls.<MybatisDemo>custom();//可进行动态sql拼接
sqls = sqls.andEqualTo(MybatisDemo::getCount,0).andLike(MybatisDemo::getName,"%d%");//获得结果
List<MybatisDemo> demos = mybatisDemoMapper.selectByExample(Example.builder(MybatisDemo.class).where(sqls).orderByDesc("count","name").build());

在本项目中对于 Example 的使用有一个集成实现类,ExampleBuilder 类,该类有以下几个属性:

    private Class<T> entityClass;//实体类private Class<? extends Mapper<T>> mapperClass;//实体类对应的mapper接口类private WeekendSqls<T> weekendSqls = WeekendSqls.custom();//用于拼接sqlprivate LinkedHashMap<String, Boolean> orderList = new LinkedHashMap<>();//用于存放排序的字段private Map<String, Object> setterList = new HashMap<>();//存放需要更新的字段以及新值

定义的方法过多,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ghDvF00F-1668951219082)(https://www.hreshhao.com/wp-content/uploads/2022/11/微信截图_20201010113049.png)]

在 service 服务类中的查询方法定义如下,不仅可以实现分页查询,还可以排序。

@Override
public Page<BankAreaVO> queryPage(BankAreaQueryPageDTO dto) {PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(), dto.getPageSortInfo().parseSort());List<BankArea> bankAreaList = ExampleBuilder.create(BankAreaMapper.class).andEqualTo(dto.getEnableFlag() != null, BankArea::getEnableFlag, dto.getEnableFlag()).andEqualTo(StringUtils.isNotEmpty(dto.getCountry()), BankArea::getCountry, dto.getCountry()).andEqualTo(StringUtils.isNotEmpty(dto.getProvince()), BankArea::getProvince, dto.getProvince()).andLike(StringUtils.isNotEmpty(dto.getBankAreaCode()), BankArea::getBankAreaCode, "%" + dto.getBankAreaCode() + "%").andLike(StringUtils.isNotEmpty(dto.getBankAreaName()), BankArea::getBankAreaName, "%" + dto.getBankAreaName() + "%").orderByAsc(BankArea::getBankAreaCode).select();Page<BankArea> bankAreaPage = (Page<BankArea>) bankAreaList;return BeanUtils.copyProperties(bankAreaPage, BankAreaVO.class);
}

我们重点关注经常使用到的方法,比如 create 方法等。

public static <T, M extends Mapper<T>> ExampleBuilder<T> create(Class<M> clazz) {ExampleBuilder<T> exampleBuilder = new ExampleBuilder<>();exampleBuilder.mapperClass = clazz;//获取 mapper对应的实体类exampleBuilder.entityClass = (Class<T>) ((ParameterizedType) clazz.getGenericInterfaces()[0]).getActualTypeArguments()[0];return exampleBuilder;
}

然后将查询条件拼接起来,andEqualTo()处理等值判断,andLike()处理模糊查询,orderByAsc()处理字段排序。

public ExampleBuilder<T> andEqualTo(boolean condition, Fn<T, Object> fn, Object value) {if (condition) {weekendSqls.andEqualTo(Reflections.fnToFieldName(fn), value);}return this;
}public ExampleBuilder<T> andLike(boolean condition, Fn<T, Object> fn, String value) {if (condition) {weekendSqls.andLike(Reflections.fnToFieldName(fn), value);}return this;
}public ExampleBuilder<T> orderByAsc(Fn<T, Object> fn) {String fieldName = Reflections.fnToFieldName(fn);this.orderList.put(fieldName, true);return this;
}

最后执行 select 方法

public List<T> select() {Example example = this.build();return SpringUtils.getBean(mapperClass).selectByExample(example);
}public Example build() {Example.Builder builder = Example.builder(entityClass);//拼接到where条件后if (weekendSqls.getCriteria().getCriterions().size() > 0) {builder.where(weekendSqls);}//遍历字段排序列表,获取指定排序的结果集for (Map.Entry<String, Boolean> entry : this.orderList.entrySet()) {if (entry.getValue()) {builder.orderByAsc(entry.getKey());} else {builder.orderByDesc(entry.getKey());}}return builder.build();
}

在 select()方法中的 SpringUtils.getBean(mapperClass) 是为了获取已经注册到 Spring 上下文中的实例,我们可以简单查看一下 SpringUtils 类。

@Service
public class SpringUtils implements ApplicationContextAware {private static ApplicationContext applicationContext;public void setApplicationContext(ApplicationContext applicationContext)throws BeansException {SpringUtils.applicationContext = applicationContext;}public static <T> T getBean(Class<T> clazz) {return (T) applicationContext.getBean(clazz);}public static Object getBean(String name) throws BeansException {return applicationContext.getBean(name);}
}

至此,关于本项目中有价值的内容已经讲述完毕,因篇幅有限,未能展示所有代码。基于上述核心代码,我们只需要往项目中添加相关业务代码即可,接下来我们就可以运行之前写的脚本工具,根据数据库表信息快速生成模板代码。

一键式生成模版代码

运行 orm-generate 项目,在 swagger 上调用 /build 接口,调用参数如下:

{"database": "mysql_db","flat": true,"type": "mybatis","group": "hresh","host": "127.0.0.1","module": "orm","password": "root","port": 3306,"table": ["user","job"],"username": "root","tableStartIndex":"0"
}

先将代码下载下来,解压出来目录如下:

代码文件直接移到项目中就行了,稍微修改一下引用就好了。

功能实现

请求日志输出

比如说我们访问 /users/queryPage 接口,看看控制台输出情况:

Request Info      : {"classMethod":"com.msdn.orm.hresh.controller.UserController.queryPage","ip":"127.0.0.1","requestParams":{"dto":{"pageSortInfo":{"pageSize":5,"pageNum":1}}},"httpMethod":"GET","url":"http://localhost:8801/users/queryPage","result":{"code":"200","message":"操作成功","success":true},"methodDesc":"获取用户分页列表","timeCost":69}

可以看到,日志输出中包含前端传来的请求体,请求 API,返回结果,API 描述,API 耗时。

统一返回格式

比如说分页查询,返回结果如下:

{"data": {"total": 5,"pageCount": 1,"pageSize": 5,"pageNum": 1,"data": [{"id": null,"name": "clearLove","age": 28,"address": "中国上海"},{"id": null,"name": "ascii0","age": 21,"address": "中国广东"},{"id": null,"name": "ascii1","age": 21,"address": "中国广东"},{"id": null,"name": "ascii2","age": 21,"address": "中国广东"},{"id": null,"name": "clearLove2","age": 28,"address": "中国上海"}]},"code": "200","message": "操作成功","success": true
}

如果是新增请求,返回结果为:

{"data": null,"code": "200","message": "操作成功","success": true
}

此处 data 没有新增的数据,如果项目需要新增的实体信息,可以稍作修改。

异常处理

下面简单演示一下参数异常的情况,在 add user 时校验参数值是否为空。

  public int add(UserDTO dto) {if (StringUtils.isBlank(dto.getName())) {BusinessException.validateFailed("userName不能为空");}User user = userStruct.dtoToModel(dto);return userMapper.insertSelective(user);}

如果传递的 name 值为空,则返回结果为:

{"data": null,"code": "400","message": "userName不能为空","success": false
}

补全操作者信息

此处依赖于 Mybatis 拦截器,重点在 AutoFillFieldInterceptor 文件。

for (EntityField field : entityFieldList) {if (sqlCommandType == SqlCommandType.INSERT) {if (field.isAnnotationPresent(CreatedDate.class)) {metaObject.setValue(field.getName(), LocalDateTime.now());}if (field.isAnnotationPresent(CreatedBy.class)) {String id = "1";metaObject.setValue(field.getName(), id);}if (field.isAnnotationPresent(CreatedName.class)) {metaObject.setValue(field.getName(), "hresh");}if (field.isAnnotationPresent(Version.class)) {metaObject.setValue(field.getName(), 0);}if (field.isAnnotationPresent(LogicDelete.class)) {metaObject.setValue(field.getName(), false);}}if (field.isAnnotationPresent(LastModifiedDate.class)) {metaObject.setValue(field.getName(), LocalDateTime.now());}if (field.isAnnotationPresent(LastModifiedBy.class)) {metaObject.setValue(field.getName(), "1");}if (field.isAnnotationPresent(LastModifiedName.class)) {metaObject.setValue(field.getName(), "hresh");}
}

上述代码中关于新增者信息和修改者信息,暂时是写死的状态,实际项目中,可以根据 token 信息进行解析,然后来填充新增者和修改者信息。

批量操作

这里简单演示一下关于批量新增的代码

  @Overridepublic int batchAdd(UserDTO dto) {List<User> users = new ArrayList<>();for (int i = 0; i < 3; i++) {User user = User.builder().name("ascii" + i).age(21).address("中国广东").build();users.add(user);}return userMapper.insertList(users);}

注意:这里的 UserMapper 需要继承我们自定义的 ListMapper,如下所示:

public interface UserMapper extends Mapper<User>, ListMapper<User,Long> {}

执行效果如下:

分页查询

前端参数传递:

{"pageNum": 1,"pageSize": 5,"name": "ascii","orderInfos":[{"column": "name","asc": true}]
}

后端代码处理:

  public Page<UserVO> queryPage(UserQueryPageDTO dto) {PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),dto.getPageSortInfo().parseSort());List<User> users = ExampleBuilder.create(UserMapper.class).andLike(User::getName, dto.getName() + "%").orderByDesc(User::getName).select();Page<User> userPage = (Page<User>) users;return PageUtils.convert(userPage, UserVO.class);}

返回结果为:

{"data": {"total": 3,"pageCount": 1,"pageSize": 5,"pageNum": 1,"data": [{"name": "ascii0","age": 21,"address": "中国广东","jobVOS": null},{"name": "ascii1","age": 21,"address": "中国广东","jobVOS": null},{"name": "ascii2","age": 21,"address": "中国广东","jobVOS": null}]},"code": "200","message": "操作成功","success": true
}

动态查询

查询方法如下:

  public List<UserVO> queryList(UserDTO dto) {List<User> users = ExampleBuilder.create(UserMapper.class).andLike(User::getName, dto.getName() + "%").orderByDesc(User::getName).select();return BeanUtils.copyProperties(users, UserVO.class);}

执行结果如下:

{"data": [{"name": "ascii2","age": 21,"address": "中国广东"},{"name": "ascii1","age": 21,"address": "中国广东"},{"name": "ascii0","age": 21,"address": "中国广东"}],"code": "200","message": "操作成功","success": true
}

如果是分页查询,可以这样处理:

  public Page<UserVO> queryPage(UserQueryPageDTO dto) {PageHelper.startPage(dto.getPageSortInfo().getPageNum(), dto.getPageSortInfo().getPageSize(),dto.getPageSortInfo().parseSort());List<User> users = ExampleBuilder.create(UserMapper.class).andLike(User::getName, dto.getName() + "%").orderByDesc(User::getName).select();Page<User> userPage = (Page<User>) users;return PageUtils.convert(userPage, UserVO.class);}

查询结果为:

{"data": {"total": 3,"pageCount": 1,"pageSize": 5,"pageNum": 1,"data": [{"name": "ascii2","age": 21,"address": "中国广东"},{"name": "ascii1","age": 21,"address": "中国广东"},{"name": "ascii0","age": 21,"address": "中国广东"}]},"code": "200","message": "操作成功","success": true
}

一对多查询

比如说我们定义的 User 和 Job 类,存在着一对多的关系,所以查询 User 信息时,还需要返回关联的 Job 数据。

关于 Mybatis 一对多、多对一处理手段,可以参考我之前的文章。

本项目采用的是结果嵌套处理方式。

JobMapper.xml 内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.orm.hresh.mapper.JobMapper"><resultMap id="jobResultMap" type="com.msdn.orm.hresh.model.Job"><id column="id" property="id"/><result column="name" property="name"/><result column="user_id" property="userId"/><result column="address" property="address"/><result column="created_date" property="createdDate"/><result column="last_modified_date" property="lastModifiedDate"/><result column="del_flag" property="delFlag"/><result column="create_user_code" property="createUserCode"/><result column="create_user_name" property="createUserName"/><result column="last_modified_code" property="lastModifiedCode"/><result column="last_modified_name" property="lastModifiedName"/><result column="version" property="version"/></resultMap></mapper>

UserMapper.xml 文件

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.orm.hresh.mapper.UserMapper"><resultMap id="userResultMap" type="com.msdn.orm.hresh.model.User"><id column="id" property="id"/><result column="name" property="name"/><result column="age" property="age"/><result column="address" property="address"/><result column="created_date" property="createdDate"/><result column="last_modified_date" property="lastModifiedDate"/><result column="del_flag" property="delFlag"/><result column="create_user_code" property="createUserCode"/><result column="create_user_name" property="createUserName"/><result column="last_modified_code" property="lastModifiedCode"/><result column="last_modified_name" property="lastModifiedName"/><result column="version" property="version"/></resultMap><resultMap id="userVoResultMap" type="com.msdn.orm.hresh.model.User"extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap"><collection property="jobs" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"columnPrefix="job_"/></resultMap><select id="queryList" resultMap="userVoResultMap">SELECT u.*,j.name job_name,j.address job_addressFROMuser uLEFT JOIN job j ON u.id = j.user_id<where><if test="name!=null and name!=''">and u.name like concat('%',#{name},'%')</if><if test="address != null and address !=''">and u.address like concat('%',#{address},'%')</if></where></select>
</mapper>

关于上述 xml 配置,我们最终获取的是关于 User 的返回结果,还需要再转换为 UserVO 才返回给前端。如果服务层获取到返回结果后,不需要其他业务操作,可以直接获取 UserVO,反之,我们需要查询得到 User,处理完其他操作后,再转换为 UserVO。需要修改的

  <resultMap id="userVoResultMap" type="com.msdn.orm.hresh.vo.UserVO"extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap"><collection property="jobVOS" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"columnPrefix="job_"/></resultMap><resultMap id="userResultMap2" type="com.msdn.orm.hresh.model.User"extends="com.msdn.orm.hresh.mapper.UserMapper.userResultMap"><collection property="jobs" resultMap="com.msdn.orm.hresh.mapper.JobMapper.jobResultMap"columnPrefix="job_"/></resultMap>

userVoResultMap 对应 UserVO 返回结果,userResultMap2 对应 User 结果。

对应的 UserMapper 文件

public interface UserMapper extends Mapper<User>, ListMapper<User,Long> {List<User> queryList(UserDTO userDTO);
}

我们修改 UserService 中的查询方法如下:

  public List<UserVO> queryList(UserDTO dto) {//    List<User> users = ExampleBuilder.create(UserMapper.class)
//        .andLike(User::getName, dto.getName() + "%")
//        .orderByDesc(User::getName)
//        .select();List<User> users = userMapper.queryList(dto);return userStruct.modelToVO(users);}

同时在 application.yml 文件中打开 SQL 输出配置:

mybatis:mapper-locations: classpath:mapper/*Mapper.xmlconfiguration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

调用接口,可以发现控制台输出如下:

==>  Preparing: SELECT u.*, j.name job_name, j.address job_address FROM user u LEFT JOIN job j ON u.id = j.user_id WHERE u.name like concat('%',?,'%')
==> Parameters: ascii(String)
<==    Columns: id, name, age, address, created_date, last_modified_date, del_flag, create_user_code, create_user_name, last_modified_code, last_modified_name, version, job_name, job_address
<==        Row: 55dc89810e394306b66ab9567b568534, ascii0, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 程序员, 中国湖北
<==        Row: 55dc89810e394306b66ab9567b568534, ascii0, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 外卖员, 中国湖北
<==        Row: 93473b532494477b9d8d34b3165d216a, ascii1, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 外卖员, 中国湖北
<==        Row: 93473b532494477b9d8d34b3165d216a, ascii1, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, 厨师, 中国湖北
<==        Row: cd613ce660264dc18b15b3333a6421da, ascii2, 21, 中国广东, 2022-09-19 21:30:06, 2022-09-19 21:30:06, 0, 1, hresh, 1, hresh, 0, null, null
<==      Total: 5

返回结果为:

{"data": [{"name": "ascii0","age": 21,"address": "中国广东","jobVOS": [{"name": "程序员","address": "中国湖北"},{"name": "外卖员","address": "中国湖北"}]},{"name": "ascii1","age": 21,"address": "中国广东","jobVOS": [{"name": "外卖员","address": "中国湖北"},{"name": "厨师","address": "中国湖北"}]},{"name": "ascii2","age": 21,"address": "中国广东","jobVOS": []}],"code": "200","message": "操作成功","success": true
}

看到这里,你可能发现了这样一个问题,如果使用我们自定义的工具类 ExampleBuilder,是无法完成连表查询的,也无法实现懒加载查询。尝试修改过 ExampleBuilder,但未能实现类似于 left join 的查询,不过我在构建 Spring JPA 动态查询工具类的时候,实现了连表查询,可以关注后续文章的发布。

Swagger

启动项目后,访问 swagger,页面展示如下:

总结

以上便是本项目所包含的内容,关于基础代码,可以结合实际需要继续深入挖掘,可能也有不足的地方,或者我所不了解的基础代码,望各位大佬多多指教。

身为过来人,我也体验过 CRUD 的工作,对项目搭建知之甚少,而在工作中很少遇到从零开发一个项目的机会,这也是我所苦恼的。希望这篇文章能够对大家有所帮助,尤其是那些还未毕业的同学们,如果你们想实操一个项目,可以先去 Github 上找一个感兴趣的项目,然后复用本文章中提到的基础设施,亲自动手去完成一个项目。

感兴趣的朋友可以去我的 Github 下载相关代码,如果对你有所帮助,不妨 Star 一下,谢谢大家支持!

参考文献

写个日志请求切面,前后端甩锅更方便

maven之自定义插件

liquibase的changelog详解

SpringBoot集成Mybatis项目实操相关推荐

  1. 一个项目了解 SpringBoot 集成 MyBatis

    SpringBoot 集成 MyBatis 创建项目 1.引入依赖 mybatis-spring-boot-starter 依赖 完整的 pom.xml 2.配置文件 加入mybatis配置 完整的配 ...

  2. java整合mybatis,springboot集成mybatis

    # springboot集成mybatis springboot基础mybatis还是很简单的,比之前springmvc集成mybatis要少很多配置,只要大家按照步骤一步一步来,几分钟就 能实现.具 ...

  3. springboot集成mybatis源码分析-mybatis的mapper执行查询时的流程(三)

    springboot集成mybatis源码分析-mybatis的mapper执行查询时的流程(三) 例: package com.example.demo.service;import com.exa ...

  4. springboot集成mybatis源码分析-启动加载mybatis过程(二)

    springboot集成mybatis源码分析-启动加载mybatis过程(二) 1.springboot项目最核心的就是自动加载配置,该功能则依赖的是一个注解@SpringBootApplicati ...

  5. springboot集成mybatis源码分析(一)

    springboot集成mybatis源码分析(一) 本篇文章只是简单接受使用,具体源码解析请看后续文章 1.新建springboot项目,并导入mybatis的pom配置 配置数据库驱动和mybat ...

  6. SpringBoot集成Mybatis用法笔记

    今天给大家整理SpringBoot集成Mybatis用法笔记.希望对大家能有所帮助! 搭建一个SpringBoot基础项目. 具体可以参考SpringBoot:搭建第一个Web程序 引入相关依赖 &l ...

  7. Java 捕获 mybatis异常_3 springboot集成mybatis和全局异常捕获

    mybatis有两种方式,一种是基于XML,一种是基于注解 springboot集成mybatis 首先先创建表,这里都简化了 DROP TABLE IF EXISTS `user`; CREATE ...

  8. Springboot集成mybatis通用Mapper与分页插件PageHelper

    Springboot集成mybatis通用Mapper与分页插件PageHelper 插件介绍 通用 Mapper 是一个可以实现任意 MyBatis 通用方法的框架,项目提供了常规的增删改查操作以及 ...

  9. SpringBoot教程(十一) | SpringBoot集成Mybatis

    上一篇文章我们介绍了SpringBoot集成JdbcTemplate.简单体验了一下JdbcTemplate框架的用法,今天的内容比较重要,我们来介绍一下SpringBoot集成Mybatis的步骤. ...

最新文章

  1. 计算机技术大神,2017考研:计算机科学与技术学科大神给你的套路
  2. 历届试题 大臣的旅费(深搜 树的直径)
  3. Power BI连接MySQL 提示错误......未能加载文件或程序集......或它的某一个依赖项
  4. 入门RabbitMQ核心概念
  5. 再谈strncpy函数--值得一看的好文章
  6. 红橙Darren视频笔记 自定义RatingBar touch事件学习 dp转px listener监听
  7. “以毒攻毒”?阿里将上线“二哈”防骚扰电话应用程序
  8. PAT 乙级 1044. 火星数字(20) Java版
  9. Linux 命令(51)—— ipcs 命令
  10. UG NX 12 鼠标及快捷键的用法
  11. 打印流PrintWriter实现自动刷新和换行
  12. 人工智能基础入门——神经网络讲解
  13. 学习挖掘机和程序员哪个好
  14. 20211129编译RK3399的Android发生编译服务器的CPU看门狗软件死锁的问题
  15. 什么是数据库?什么是关系数据库?什么是非关系型数据库?
  16. Linux Shell 并行
  17. [转载]转录组测序分析中cufflinks的使用及问题
  18. 安装CentOS7虚拟机(超详细)
  19. 禁止Chrome浏览器自动更新 亲测可用
  20. 从程序员的尽头是业务说起

热门文章

  1. 网络营销方式有什么?
  2. 基于javaweb的仿天猫商城系统(java+jsp+springboot+ssm+mysql)
  3. 快递Api接口 微信公众号开发流程
  4. 上面一个星星下面一个r_《zhchshr》教案(人教版一年级上册)
  5. NOW直播——Flutter组件化开发方案
  6. 【Java刷题特辑第三期】——这些经典笔试题,你确定都做过吗?
  7. SpringBoot配置加载顺序
  8. Https基础以及如何配置Https
  9. FPGA中简单的握手协议
  10. 工业减压蒸馏工艺中大流量耐腐蚀快响应精密真空控制的解决方案