Hibernate5 与 Spring Boot2 最佳性能实践(1)

1. 通过字节码增强实现属性延迟加载


默认情况下,实体的属性是立即加载的,即一次加载所有属性。你确定这是你想要的吗?


"描述:"即使目前没有这样的需求,了解可以延迟加载属性也很重要。通过 Hibernate 字节码插装或者 subentities 也可以实现。该特性对于存储了大量 `CLOB`、`BLOB`、`VARBINARY` 类型数据时非常有用。


> 译注:字节码增强(Bytecode enhancement)与字节码插装(Bytecode instrumentation)的区别。字节码增强分在线、离线两种模式。在线模式指在运行时执行,持久化类在加载时得到增强;离线模式指在编译后的步骤中进行增强。字节码插装,指在“运行时”向 Java 类加入字节码。实际上不是在运行时,而是在 Java 类的“加载”过程中完成。


技术要点


  • 在 Maven `pom.xml` 中激活 Hibernate 字节码插装(像下面这样使用 Maven 字节码增强插件)

  • 为需要延迟加载的列标记 `@Basic(fetch = FetchType.LAZY)`

  • 在 View 中禁用 Open Session


[示例代码][1]


[1]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootAttributeLazyLoadingBasic


2. 通过 Log4J 2 查看绑定参数


开发中,如果不能监测调用的SQL语句绑定的参数,很有可能造成潜在的性能损失(例如 N+1 问题)。


> 译注:“N+1 问题”即执行一次查询 N 条主数据后,由于关联引起的 N 次从数据查询,因此会带来了性能问题。一般来说,通过延迟加载可以部分缓解 N+1 带来的性能问题。


"更新:"如果项目中"已经"配置了 Log4J 2,可以采用以下方案。如果没有配置,建议使用 `TRACE`(感谢 Peter Wippermann 的建议)或 `log4jdbc`(感谢 Sergei Poznanski 的建议以及 [SO][2] 的答案)。这两种方案不需要取消默认 Spring Boot 日志功能。使用 `TRACE` 的例子参见[这里][3],`log4jdbc` 的示例参见[这里][4]。 


[2]:https://*.com/questions/45346905/how-to-log-sql-queries-their-parameters-and-results-with-log4jdbc-in-spring-boo/45346996#45346996

[3]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootLogTraceViewBindingParameters

[4]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootLog4JdbcViewBindingParameters


"基于 Log4J 2 方案:"最好的办法还是监视SQL语句绑定的参数,可以通过 Log4J 2 logger 设置。


技术要点


  • 在 Maven `pom.xml` 中移除默认 Spring Boot 日志依赖(参考上面的更新说明)

  • 在 Maven `pom.xml` 中加入 Log4j 2 依赖

  • 在 `log4j2.xml` 中添加以下配置:


```xml
<Logger name="org.hibernate.type.descriptor.sql" level="trace"/>
```


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][5]


[5]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootLog4j2ViewBindingParameters


3.如何通过 datasource-proxy 监视查询细节


如果无法保证批处理正常工作,很容易会遇到严重的性能损失。即使已经配置了批处理并且认为会在后台运行,还是有一些情况会造成批处理被禁用。为了确保这一点,可以使用 `hibernate.generate_statistics` 显示详细信息(包括批处理细节),也可以使用 datasource-proxy。


"描述:"通过 [datasource-proxy][6] 查看查询细节(包括查询类型、绑定参数、批处理大小等)。


[6]:https://github.com/ttddyy/datasource-proxy


技术要点


  • 在 Maven `pom.xml` 中加入 `datasource-proxy` 依赖

  • 为 `DataSource` bean 创建 Post Processor 进行拦截

  • 用 `ProxyFactory` 和 `MethodInterceptor` 实现包装 `DataSource` bean

 

示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码] [here][7]


[7]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootDataSourceProxy


4. 通过 saveAll(Iterable<S> entities) 在 MySQL(或其他 RDBMS)中执行批量插入


默认情况下,100次插入会生成100个 `INSERT` 语句,带来100个数据库行程开销。


"描述:"批处理机制对 `INSERT`、`UPDATE` 和 `DELETE` 进行分组,能够显著降低数据库行程数。批处理插入可以调用 `SimpleJpaRepository#saveAll(Iterable<S> entities)` 方法,下面是在 MySQL 中的应用步骤。


技术要点


  • 在 `application.properties` 中设置 `spring.jpa.properties.hibernate.jdbc.batch_size`

  • 在 `application.properties`中设置 `spring.jpa.properties.hibernate.generate_statistics`:检查批处理是否正常工作

  • 在 `application.properties` JDBC URL 中设置 `rewriteBatchedStatements=true`:针对 MySQL 优化

  • 在 `application.properties` JDBC URL 中设置 `cachePrepStmts=true`:启用缓存。启用 prepStmtCacheSize、prepStmtCacheSqlLimit 等参数前必须设置此参数

  • In `application.properties` JDBC URL 中设置 `useServerPrepStmts=true`:切换到服务端生成预处理语句,可能会带来显著性能提升

  • 在实体类中使用 [assigned generator][8]:MySQL `IDENTITY` 会禁用批处理

  • 在实体类中为 `Long` 属性添加 `@Version` 注解:不仅可以避免批处理生成额外的 `SELECT`,还能减少多个请求事务中丢失 update。使用 `merge()` 替代 `persist()` 时会生成额外的 `SELECT`。`saveAll()` 实际调用 `save()`,如果实体对象ID非空会被看作已有对象。这时调用 `merge()` 触发 Hibernate 生成 `SELECT` 检查数据库中是否存在相同标识

  • 注意:传入 `saveAll()` 的对象数量不要“覆盖“持久化上下文。通常情况下,`EntityManager` 会定期执行 flush 和 clear,但是 `saveAll()` 执行过程中不会。因此,如果 `saveAll()` 传入了大量数据,所有数据都会命中持久化上下文(1级缓存),并一直保持直到执行 flush 操作。这里的配置适用于规模较小的数据,对于大数据的情况请参考例5


[8]:https://vladmihalcea.com/how-to-combine-the-hibernate-assigned-generator-with-a-sequence-or-an-identity-column/


示例输出



[示例代码][9]


[9]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootBatchInsertsJpaRepository


5. 通过 EntityManager 在 MySQL(或其他 RDBMS)中执行批量插入


批处理可以提高性能,但是在执行 flush 前需要关注持久化上下文中的数据量。在内存中存储大量数据会导致性能下降,例4中的方法只适合数据量相对较少的情况。


"描述:"通过 `EntityManager` 在 MySQL(或其他 RDBMS)中执行批量插入。这种方法可以更好地控制持久化上下文(1级缓存) `flush()` 和 `clear()` 操作。Spring Boot 中 `saveAll(Iterable<S>entities)` 做不到这点。其它好处,可以调用 `persist()` 而不是 `merge()` 方法,Spring Boot `saveAll(Iterable< S>entities)` 与 `save(S entity)` 默认调用前者。


技术要点


  • 在 `application.properties` 中设置 `spring.jpa.properties.hibernate.jdbc.batch_size`

  • 在 `application.properties` 中设置 `spring.jpa.properties.hibernate.generate_statistics`:检查批处理是否正常工作

  • 在 `application.properties` JDBC URL 中设置  `rewriteBatchedStatements=true`:针对 MySQL 优化

  • 在 `application.properties` JDBC URL 中设置 `withcachePrepStmts=true`:启用缓存。启用 prepStmtCacheSize、prepStmtCacheSqlLimit 等参数前必须设置此参数

  • 在 `application.properties` JDBC URL 中设置 `withuseServerPrepStmts=true`:切换到服务端生成预处理语句,可能会带来显著性能提升

  • 在实体类中使用 [assigned generator][8]:MySQL `IDENTITY` 会禁用批处理

  • 在 DAO 中定期对持久化上下文执行 flush 和 clear,避免“覆盖“持久化上下文


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][10]


[10]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootBatchInsertsEntityManager


你可能也会对下面内容感兴趣


  • [6. 如何在 MySQL 中通过 JpaContext/EntityManager 执行批量插入][11]"

  • [7. 在 MySQL 中实现 Session 级批处理(Hibernate 5.2 或更高版本)][12]"


[11]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootBatchInsertsEntityManagerViaJpaContext

[12]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootBatchInsertsViaSession


8. 通过 Spring Data/EntityManager/Session 直接获取结果


从数据库获取数据的方式决定了应用的执行效率,要优化查询必须了解每种获取数据方法的特点。在了解实体类'主键'的情况下,*直接获取*是最简单且实用的办法。


"描述:"下面是使用 Spring Data、`EntityManager` 和 Hibernate `Session` 直接获取数据的示例:


技术要点


  • 通过 Spring Data 直接获取数据,调用 `findById()`

  • 通过 `EntityManager#find()` 直接获取数据

  • 通过 Hibernate `Session#get()` 直接获取数据


[示例代码][13]


[13]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootDirectFetching


9. 通过 Spring Data Projection 实现 DTO


获取超出需要的数据是导致性能下降的常见问题之一。不仅如此,得到实体后不做修改也是一样。


"描述:"通过 Spring Data Projection(DTO)从数据库只获取必须的数据。也可以查看例子25至32。


技术要点


  • 编写接口(projection),包含数据库所需数据表指定列的 getter 方法

  • 编写返回 `List<projection>` 的查询

  • 可能的话,要限制返回的行数(例如,通过 `LIMIT`)。这个例子中,使用了 Spring Data repository 的内置 query builder 机制


示例输出(选择前2列,只获取 "name" 和 "age")


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][14]


[14]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootDtoViaProjections


10. 如何在 MySQL 中存储 UTC 时区


在数据库中存储不同格式或指定格式的日期、时间和时间戳会带来日期转换问题。


"描述:" 这个例子展示了如何在 MySQL 中以 UTC 时区存储日期、时间和时间戳。对其他 RDBMS(例如 PostgreSQL),只要移除 `useLegacyDatetimeCode=false` 对应调整 JDBC URL 即可。


技术要点


  • `spring.jpa.properties.hibernate.jdbc.time_zone=UTC`

  • `spring.datasource.url=jdbc:mysql://localhost:3306/db_screenshot?useLegacyDatetimeCode=false`


[示例代码] [here][15]


> 译注:运行时修改示例 url 为 jdbc:mysql://localhost:3306/db_screenshot?createDatabaseIfNotExist=true&useLegacyDatetimeCode=false,设置参数 spring.jpa.hibernate.ddl-auto=create


[15]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootUTCTimezone


11. 通过 Proxy 得到父实体


执行的 SQL 越多,性能损失越大。尽可能减少执行的 SQL 数量非常重要,通过 Reference 是最易于使用的优化方法。


"描述:"`Proxy` 在子实体可以通过指向父实体的一个持久化引用表示时非常有用。这种情况下,执行`SELECT` 语句从数据库获得父实体会带来性能损失且没有意义。Hibernate 能够对未初始化的 `Proxy` 设置基础外键值。


技术要点


  • 底层依赖 `EntityManager#getReference()`

  • 在 Spring 中调用 `JpaRepository#getOne()`

  • 在这个示例中,使用了 Hibernate `load()` 方法

  • 示例中有 `Tournament` 和 `TennisPlayer` 两个实例,一个 tournament 包含多个 player(`@OneToMany`)

  • 通过 `Proxy` 获取 tournament 对象(不会触发 `SELECT`),接着创建一个 TennisPlayer 对象,把 `Proxy` 设为 player 的 tournament,最后保存 player(触发 `INSERT` 操作,在 tennis player 中插入 `tennis_player`)


示例输出


命令行只输出一条 `INSERT`,没有 `SELECT` 语句。


[示例代码][16]


[16]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootPopulatingChildViaProxy


12. N+1 问题


“N+1问题”可能造成严重的性能损失。减少损失的首要任务是定位问题。


N+1 本质上是一个延迟加载问题(预先加载也不例外)。缺乏对实际执行SQL进行监测,很可能会造成 N+1 问题,最好的解决办法是 JOIN+DTO(例36至例42)。


技术要点


  • 定义 `Category` 和 `Product` 两类实体,关系为 `@OneToMany`

  • 延迟加载 `Product`,不主动加载 `Category`(只生成1条查询)

  • 循环读取 `Product` 集合, 对每个产品获取 `Category`(生成N条查询)


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][17]


[17]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootSimulateNPlus1


13. 通过 HINT_PASS_DISTINCT_THROUGH 优化 Distinct SELECT


把 `SELECT DISTINCT` 传递给 RDBMS 会[影响性能][18]。


[18]:http://in.relation.to/2016/08/04/introducing-distinct-pass-through-query-hint/


"描述:" Hibernate 5.2.2 开始,可以通过 `HINT_PASS_DISTINCT_THROUGH` 优化 `SELECT DISTINCT`。不会把 `DISTINCT` 关键字传给 RDBMS,而是由 Hibernate 删除重复数据。


技术要点


  • 使用 `@QueryHints(value = @QueryHint(name = HINT_PASS_DISTINCT_THROUGH, value = "false"))`


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][19]


[19]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootHintPassDistinctThrough


14. 启用脏数据跟踪


Java 反射执行速度慢,通常被看作性能损失。


"描述:"Hibernate 5 之前,脏数据检查机制基于 Java Reflection API。自 Hibernate 5 开始,转而采用了**字节码增强**技术。后者的性能更好,实体数量较多时效果尤其明显。


技术要点


  • 在 `pom.xml` 中增加插件配置(例如,使用 Maven bytecode enhancement 插件)


示例输出



  • 字节码增强效果可以在 `User.class` 上[看到][20]


[示例代码][21]


[20]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/blob/master/HibernateSpringBootEnableDirtyTracking/Bytecode%20Enhancement%20User.class/User.java

[21]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootEnableDirtyTracking


15. 在实体和查询上使用 Java 8 Optional


把 Java 8 `Optional` 作为处理 `null` 的“银弹”可能弊大于利,最好的方式还是按照设计的意图使用。


"描述:"下面的示例展示了如何在实体和查询中正确使用 Java 8 `Optional`。


技术要点


  • 使用 Spring Data 内建查询方法返回 `Optional`(例如 `findById()`)

  • 自己编写查询方法返回 `Optional`

  • 在实体 getter 方法中使用 `Optional`

  • 可以使用 `data-mysql.sql` 脚本验证不同场景


[示例代码][22]


[22]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootEnableDirtyTracking


16. 如何正确建立 @OneToMany 双向关系


实现 `@OneToMany` 双向关系有几个陷阱,相信你也希望一开始就能实现正确。


"描述:"下面的示例应用展示了如何正确实现 `@OneToMany` 双向关联。


技术要点


  • "总是"建立父子级联

  • 对父亲标记 `mappedBy`

  • 对父亲使用 `orphanRemoval`,移除没有引用的子对象

  • 在父节点上使用 helper 方法实现关联同步

  • "总是"使用延迟加载

  • 使用业务主键或实体标识符,参考[这篇介绍][23]覆写 `equals()` 和 `hashCode()` 方法。


[示例代码][24]


[23]:https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-with-jpa-and-hibernate/

[24]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootOneToManyBidirectional


17. JPQL/HQL 查询数据


在不具备直接查询的情况下,可以考虑通过 JPQL/HQL 查询数据。


"描述:"下面的示例展示了如何通过 `JpaRepository`、`EntityManager` 和 `Session` 进行查询。


技术要点


  • 对 `JpaRepository` 使用 `@Query` 注解或者创建 Spring Data Query

  • 对 `EntityManager` 与 `Session` 使用 `createQuery()` 方法


[示例代码][25]


[25]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootQueryFetching


18. 避免在 MySQL 与 Hibernate 5 中使用 AUTO Generator 类型


在 MySQL 开发过程中,尽量避免使用 `TABLE` 生成器,最好[永远不要使用][26]。


[26]:https://vladmihalcea.com/why-you-should-never-use-the-table-identifier-generator-with-jpa-and-hibernate/


"描述:" 在使用 MySQL 和 Hibernate 5 开发时,`GenerationType.AUTO` 类型的生成器会调用 `TABLE` 生成器,造成严重的性能损失。可以通过 `GenerationType.IDENTITY` 调用 `IDENTITY` 生成器或者使用 *native* 生成器。


技术要点


  • 使用 `GenerationType.IDENTITY` 取代 `GenerationType.AUTO`

  • 使用[示例代码][27],调用  *native* 生成器


[27]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootAutoGeneratorType


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][28]


[28]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootAutoGeneratorType


19. 多余的 save() 调用


大家都喜欢使用 `save()`。由于 Hibernate 采用了脏数据检查机制避免多余调用,`save()` 对于托管实体并不适用。


"描述:" 下面的示例展示了对于托管实体调用 `save()` 方法是多余的。


技术要点


  • Hibernate 会为每个托管实体调用 `UPDATE` 语句,不需要显示调用 `save()` 方法

  • 多余的调用意味着性能损失(参见[这篇文章][29])


[示例代码][30]


[29]https://vladmihalcea.com/jpa-persist-and-merge/

[30]https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootRedundantSave


20. PostgreSQL (BIG)SERIAL 与批量插入


在 PostgreSQL 中,使用 `GenerationType.IDENTITY` 会禁用批量插入。


"描述:" `(BIG)SERIAL` 与 MySQL 的 `AUTO_INCREMENT` 功能“接近”。在这个示例中,我们通过 `GenerationType.SEQUENCE` 开启批量插入,同时通过 `hi/lo` 算法进行了优化。


技术要点


  • 使用 `GenerationType.SEQUENCE` 取代 `GenerationType.IDENTITY`

  • 通过 `hi/lo` 算法在一个数据库行程中完成多个标识符读取(还可以使用 Hibernate `pooled` 和 `pooled-lo` 标识符生成器,它们是 `hi/lo` 的改进版)


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][31]


[31]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootBatchingAndSerial


> 译注:示例中 `createDatabaseIfNotExist=true` 参数对 PostgreSQL 无效,需要手动创建 `db_users` 数据库。


21. JPA 继承之 Single Table


JPA 支持 `SINGLE_TABLE`、`JOINED` 和 `TABLE_PER_CLASS` 继承策略,有着各自优缺点。以 `SINGLE_TABLE` 为例,读写速度快但不支持对子类中的列设置 `NOT NULL`。


"描述:"下面的示例展示了 JPA Single Table 继承策略(`SINGLE_TABLE`)。


技术要点


  • 这是 JPA 默认的继承策略(`@Inheritance(strategy=InheritanceType.SINGLE_TABLE)`)

  • 所有继承结构中的类都会被映射到数据库中的单个表


示例输出(下面是四个实体得到的单个表)


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][32]


[32]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootSingleTableInheritance


22. 如何对 SQL 语句统计和断言


如果不对 SQL 语句进行统计和断言,很容易对后台执行的 SQL 语句失去控制,进而造成性能损失。 


"描述:"下面的示例展示了如何对后台 SQL 语句进行统计和断言。统计 SQL 非常有用,能够确保不会生成多余的 SQL(例如,可以对预期的语句数量断言检测 N+1 问题)。


技术要点


  • 在 Maven `pom.xml` 中添加 `datasource-proxy` 依赖和 Vlad Mihalcea 的 `db-util`

  • 新建 `ProxyDataSourceBuilderwithcountQuery()`

  • `SQLStatementCountValidator.reset()` 重置计数

  • 通过 `assertInsert{Update/Delete/Select}Count(long expectedNumberOfSql` 对 `INSERT`、`UPDATE`、`DELETE` 和 `SELECT` 进行断言


示例输出(期望的 SQL 语句数量与实际生成的数量不一致时抛出异常)


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][33]


[33]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootCountSQLStatements


23. 如何使用 JPA 回调


为实体绑定事件处理时,记得使用 JPA 内建回调,不要重新发明*。


"描述:"下面的示例展示了如何启用 JPA 回调(`Pre/PostPersist`、`Pre/PostUpdate`、`Pre/PostRemove` 和 `PostLoad`)。


技术要点


  • 在实体中编写回调方法并挑选合适的注解

  • Bean Class 中带注解的回调方法返回类型必须为 `void` 且不带参数


示例输出


Hibernate5 与 Spring Boot2 最佳性能实践(1)


[示例代码][34]


[34]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootJpaCallbacks


24. @OneToOne 与 @MapsId


双向 `@OneToOne` 效率不及单向 `@OneToOne`,后者与父表共享主键。


"描述:" 下面的示例展示了为何建议使用 `@OneToOne` 和 `@MapsId` 取代 `@OneToOne`。


技术要点


  • 在子实体上使用 `@MapsId`

  • 对于 `@OneToOne` 关联,基本上会与父表共享主键。


[示例代码][35]


[35]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootOneToOneMapsId


25. 通过 SqlResultSetMapping 设置 DTO


超出需要获取数据是不好的习惯。另一种常见的错误,没有打算修改实体对象却获取并存储到持久化上下文中,同样会导致性能问题。例25至例32展示了如何使用不同方法提取 DTO。


"描述:"下面的示例展示了如何通过 `SqlResultSetMapping` 和 `EntityManager` 使用 DTO 提取需要的数据。


技术要点


  • 使用 `SqlResultSetMapping` 和 `EntityManager`

  • 使用 Spring Data Projection 时,请检查例9中的注意事项


[示例代码][36]


[36]:https://github.com/AnghelLeonard/Hibernate-SpringBoot/tree/master/HibernateSpringBootDtoSqlResultSetMapping


上一篇:阿里巴巴Java方向面试题汇总(含答案)


下一篇:Spring框架(四):SSH整合