第3章 使用数据
- 使用Spring的JdbcTemplate;
- 创建Spring Data JDBC存储库;
- 使用Spring Data声明JPA存储库;
本章对Taco Cloud应用添加对数据库持久化的支持,关注JDBC和JPA。
3.1 使用JDBC读取和写入数据
Spring对JDBC的支持要归功于JdbcTemplate类。JdbcTemplate提供特殊方式使得开发人员对关系型数据执行SQL操作的时候能够避免使用JDBC常见的繁文缛节和样板式代码。
先
不适用JdbcTemplate查询数据库,案例是原生的,包含连接,准备状态,结果集以及遍历结果集,关闭连接和异常处理,不看也罢,略。
3.1.1 调整领域对象以适应持久化
数据库中的表都有一个ID,Ingredient类已有id字段,同时Taco和TacoOrder都需要增加id,创建日期和时间createdAt字段。
- Taco增加Long id,Date createdAt = new Date();
- TacoOrder增加Long id,Date placedAt;
构造器,getter和setter等方法也对应修改。
3.1.2 使用JdbcTemplate
自动(前面讲过)或手动将Spring Boot的JDBC starter和H2嵌入式数据库依赖添加到构建文件中:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope>
</dependency>
默认情况下,H2数据库名称是随机的,我们要想使用H2控制台连接数据库(Spring Boot DevTools会在http://localhost:8080/h2-console启用该功能),我们要设置一个确定的数据库名称,在配置文件application.properties中通过设置下面属性确定数据库名称:
#false表示不要生成随机数据库名
spring.datasource.generate-unique-name=false
spring.datasource.name=tacocloud
可将配置文件替换成application.yml,了解即可,更有层次性,后续想改自己改。
数据库URL会是:“jdbc:h2:men:tacocloud”,可将其设置到H2控制台连接的JDBC URL中。
定义JDBC存储库
对Ingredient对象进行增删改查操作。
IngredientRepository接口定义三个方法
package tacos.data;import java.util.Optional;import tacos.Ingredient;public interface IngredientRepository {Iterable<Ingredient> findAll();Optional<Ingredient> findById(String id);Ingredient save(Ingredient ingredient);}
编写IngredientRepository实现类,使用JdbcTemplate来查询数据库。
Spring定义的**构造型(stereotype)**注解:@Repository,@Controller,@Component,组件能扫描到它,将其初始化为上下文的Bean。
@Autowired会隐式通过该构造器的参数应用依赖的自动装配(只有一个构造器时)。
JdbcTemplate的query方法接受SQL语句以及Spring RowMapper的实现(用来将结果集每行数据映射为一个对象)。
用JdbcTemplate的时候,Java方法引用和lambda表达式很便利,能够显式替换RowMapper实现。可以自己使用显式RowMapper。
插入数据,用update方法,提供3个参数。
开始使用JdbcTemplate编写配料存储库
package tacos.data;import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;import tacos.Ingredient;
@Repository
public class JdbcIngredientRepository implements IngredientRepository {private JdbcTemplate jdbcTemplate;@Autowired //这里是自动注入,属于构造器注入public JdbcIngredientRepository(JdbcTemplate jdbcTemplate) {this.jdbcTemplate = jdbcTemplate;}@Overridepublic Iterable<Ingredient> findAll() {return jdbcTemplate.query("select id,name,type from Ingredient",this::mapRowToIngredient);//参考Java核心I中lamdba表达式简写}@Overridepublic Optional<Ingredient> findById(String id) {List<Ingredient> results = jdbcTemplate.query("select id,name,type from Ingredient where id = ?",this::mapRowToIngredient,id);return results.size() == 0 ? Optional.empty() :Optional.of(results.get(0));}@Overridepublic Ingredient save(Ingredient ingredient) {jdbcTemplate.update("insert into Ingredient (id,name,type) values (?,?,?)",ingredient.getId(),ingredient.getName(),ingredient.getType().toString());return ingredient;}//将结果集转为对象private Ingredient mapRowToIngredient(ResultSet row,int rowNum) throws SQLException {return new Ingredient(row.getString("id"),row.getString("name"),Ingredient.Type.valueOf(row.getString("type")));}}
编写完毕后,将其注入DesignTacoController,然后使用它来提供配料列表,不用硬编码了。
在控制器中注入和使用存储库
package tacos.web;import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;import tacos.Ingredient;
import tacos.Ingredient.Type;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.data.IngredientRepository;@Controller
@RequestMapping("/design")
@SessionAttributes("tacoOrder")
public class DesignTacoController {private static final Logger log = LoggerFactory.getLogger(DesignTacoController.class);private final IngredientRepository ingredientRepository;@Autowiredpublic DesignTacoController(IngredientRepository ingredientRepository) {this.ingredientRepository = ingredientRepository;}@ModelAttributepublic void addIngredientsToModel(Model model) {Iterable<Ingredient> ingredients = ingredientRepository.findAll();Type[] types = Ingredient.Type.values();for (Type type : types) {model.addAttribute(type.toString().toLowerCase(),filterByType(ingredients, type));}}@ModelAttribute(name = "tacoOrder")public TacoOrder order() {return new TacoOrder();}@ModelAttribute(name = "taco")public Taco taco() {return new Taco();}@GetMappingpublic String showDesignForm() {return "design";}@PostMappingpublic String processTaco(@Validated Taco taco,Errors errors,@ModelAttribute TacoOrder tacoOrder) {if(errors.hasErrors()) {return "design";}tacoOrder.addTaco(taco);log.info("处理 taco:{}",taco);return "redirect:/orders/current";}private Iterable<Ingredient> filterByType(Iterable<Ingredient> ingredients,Type type){return StreamSupport.stream(ingredients.spliterator(), true) .filter(x -> x.getType().equals(type)).collect(Collectors.toList());}}
转换器也可以改变了,通过findById进行简化。
package tacos;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;import tacos.data.IngredientRepository;@Component
public class IngredientByIdConverter implements Converter<String, Ingredient> {private IngredientRepository ingredientRepository;@Autowiredpublic IngredientByIdConverter(IngredientRepository ingredientRepository) {this.ingredientRepository = ingredientRepository;}@Overridepublic Ingredient convert(String id) {return ingredientRepository.findById(id).orElse(null);//参考Java核心技术II中optional内容}}
改变这些并启动应用之前,先要创建表并填充一些配料数据。
3.1.3 定义模式和预加载数据
- Taco_Order:保存订单的细节信息。
- Taco:保存taco设计相关的必要信息。
- Ingredient_Ref:将taco和与之相关的配料映射在一起,Taco中的每行数据都对应该表中的一行或多行数据。
- Ingredient:保存配料信息。
Taco无法再Taco_Order环境之外存在。
他两被视为同一 聚合(aggregate) 的成员,Taco_Order是聚合根(aggregate root)。
而Ingredient对象则是其聚合的唯一成员,会通过Ingredient_Ref建立与Taco的引用关系。
注意:看看《领域驱动设计》
创建表的SQL语句,schema.sql文件
create table if not exists Taco_Order(id identity,delivery_Name varchar(50) not null,delivery_Street varchar(50) not null,delivery_City varchar(50) not null,delivery_State varchar(2) not null,delivery_Zip varchar(10) not null,cc_number varchar(16) not null,cc_expiration varchar(5) not null,cc_cvv varchar(3) not null,placed_at timestamp not null
);
create table if not exists Taco(id identity,name varchar(50) not null,taco_order bigint not null,taco_order_key bigint not null,created_at timestamp not null
);
create table if not exists Ingredient_Ref(ingredient varchar(4) not null,taco bigint not null,taco_key bigint not null
);
create table if not exists Ingredient(id varchar(4) not null ,name varchar(25) not null,type varchar(10) not null
);
alter table Ingredient add constraint unique_ingredient_id UNIQUE (id);
alter table Taco add foreign key(taco_order) references Taco_Order(id);
alter table Ingredient_Ref add foreign key(ingredient) references Ingredient(id);
这些模式定义放在什么地方,SpringBoot回答了这个问题,如果应用的根路径下存在名为schema.sql的文件,应用启动时会基于数据库执行这个文件中的SQL。
我们将文件保存为名为schema.sql的文件并放到"src/main/resources"文件夹下。
我们还希望在数据库中预加载一些配料数据,SpringBoot还会再应用启动的时候执行根类路径下名为data.sql的文件,我们可将下面的插入语句作为数据库加载配料数据并将其保存在"src/main/resources/data.sql"文件中。
使用data.sql预加载数据库
delete from Ingredient_Ref;
delete from Taco;
delete from Taco_Order;delete from Ingredient;
insert into Ingredient(id,name,type) values ('FLTO','Flour Tortilla','WRAP');
insert into Ingredient(id,name,type) values ('COTO','Corn Tortilla','WRAP');
insert into Ingredient(id,name,type) values ('GRBF','Ground Beef','PROTEIN');
insert into Ingredient(id,name,type) values ('CARN','Carnitas','PROTEIN');
insert into Ingredient(id,name,type) values ('TMTO','Diced Tomatoes','VEGGIES');
insert into Ingredient(id,name,type) values ('LETC','Lettuce','VEGGIES');
insert into Ingredient(id,name,type) values ('CHED','Cheddar','CHEESE');
insert into Ingredient(id,name,type) values ('JACK','Monterrey Jack','CHEESE');
insert into Ingredient(id,name,type) values ('SLSA','Salsa','SAUCE');
insert into Ingredient(id,name,type) values ('SRCR','Sour Cream','SAUCE');
启动项目,查看功能,按原书来可能出错,自己需要微调!
3.1.4 插入数据
我们已经走马观花的了解了如何使用JdbcTemplate将数据写入数据库。
定义OrderRepository接口
package tacos.data;import tacos.TacoOrder;public interface OrderRepository {TacoOrder save(TacoOrder order);}
保存TacoOrder的时候必须保存与之关联的Taco对象,同时也要保存特殊的对象即taco和ingredient之间的关系IngredientRef。
package tacos;public class IngredientRef {private final String ingredient;public IngredientRef(String ingredient) {this.ingredient = ingredient;}public String getIngredient() {return ingredient;}@Overridepublic String toString() {return "IngredientRef [ingredient=" + ingredient + "]";}}
Taco_Order表的id属性是一个identity字段,数据库会自动生成这个值。新增保存后要返回的值中要包含ID,Spring中辅助类GeneratedKeyHolder帮助实现。
订单实现类
package tacos.data;import java.sql.Types;
import java.util.Arrays;
import java.util.Date;
import java.util.List;import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;import tacos.Ingredient;
import tacos.IngredientRef;
import tacos.Taco;
import tacos.TacoOrder;
@Repository
public class JdbcOrderRepository implements OrderRepository {private JdbcOperations jdbcOperations;public JdbcOrderRepository(JdbcOperations jdbcOperations) {this.jdbcOperations = jdbcOperations;}private String pscfSQL = "insert into Taco_Order"+ "(delivery_name,delivery_street,delivery_city,"+ "delivery_state,delivery_zip,cc_number,"+ "cc_expiration,cc_cvv,placed_at)"+ "values (?,?,?,?,?,?,?,?,?)";@Override@Transactionalpublic TacoOrder save(TacoOrder order) {PreparedStatementCreatorFactory pscf =new PreparedStatementCreatorFactory(pscfSQL,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.VARCHAR,Types.TIMESTAMP);//生成idpscf.setReturnGeneratedKeys(true);order.setPlacedAt(new Date());PreparedStatementCreator psc = pscf.newPreparedStatementCreator(Arrays.asList(order.getDeliveryName(),order.getDeliveryStreet(),order.getDeliveryCity(),order.getDeliveryState(),order.getDeliveryZip(),order.getCcNumber(),order.getCcExpiration(),order.getCcCVV(),order.getPlacedAt()));GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();jdbcOperations.update(psc,keyHolder);long orderId = keyHolder.getKey().longValue();order.setId(orderId);List<Taco> tacos = order.getTacos();int i = 0;for (Taco taco : tacos) {//保存tacosaveTaco(orderId,i++,taco);}return order;}private long saveTaco(Long orderId,int orderKey,Taco taco) {taco.setCreatedAt(new Date());PreparedStatementCreatorFactory pscf =new PreparedStatementCreatorFactory("insert into Taco"+"(name,created_at,taco_order,taco_order_key)"+"values (?,?,?,?)",Types.VARCHAR,Types.TIMESTAMP,Types.LONGVARCHAR,Types.LONGVARCHAR);pscf.setReturnGeneratedKeys(true);PreparedStatementCreator psc = pscf.newPreparedStatementCreator(Arrays.asList(taco.getName(),taco.getCreatedAt(),orderId,orderKey));GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();jdbcOperations.update(psc,keyHolder);long tacoId = keyHolder.getKey().longValue();taco.setId(tacoId);saveIngredientRefs(tacoId, taco.getIngredients());return tacoId;}private void saveIngredientRefs(long tacoId,List<Ingredient> ingredients) {int key = 0;for (Ingredient ingredient : ingredients) {jdbcOperations.update("insert into Ingredient_Ref(ingredient,taco,taco_key)"+"values (?,?,?)",ingredient.getId(),tacoId,key++);}}}
save方法中:
- 创建PreparedStatementCreatorFactory,描述出入以及插入字段的类型,设置稍后GeneratedKeys(true),生成ID。
- 通过上面一条创建PreparedStatementCreator,调用update来真正保存订单数据,API要求传GeneratedKeyHolder用来获取生成后的id,将这个值复制到TacoOrder的id属性上。
- 最后进行循环保存,关注一下@Transactional事务,订单中的所有taco要不全部成功,要不全部失败回滚,保持事务的一致性。
在订单控制器中注入订单仓库实现
package tacos;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;import tacos.data.OrderRepository;@Controller
@RequestMapping("/orders")
@SessionAttributes("tacoOrder")
public class OrderController {private static final Logger log = LoggerFactory.getLogger(OrderController.class);private OrderRepository orderRepository;public OrderController(OrderRepository orderRepository) {this.orderRepository = orderRepository;}@GetMapping("/current")public String orderForm() {return "orderForm";}@PostMappingpublic String processOrder(@Validated TacoOrder order, Errors errors,SessionStatus sessionStatus) {if(errors.hasErrors()) {return "orderForm";} TacoOrder returnOrder = orderRepository.save(order);log.info("Order submitted:{}",returnOrder);sessionStatus.setComplete();return "redirect:/";}
}
3.2 使用Spring Data JDBC
Spring Data是一个非常大的伞形项目,多个子项目对不同数据库类型进行持久化。
- Spring Data JDBC:对关系型数据库进行JDBC持久化。
- Spring Data JPA:对关系型数据库JPA持久化。
- Spring Data MongoDB:持久化Mongo文档数据库。
- Spring Data Neo4j:持久化到Neo4j图数据库
- Spring Data Redis:持久化到Redis键值存储。
- Spring Data Cassandra:持久化到Cassandra列存储数据库。
有趣的特性:基于存储库规范接口自动创建存储库。
3.2.1 添加Spring Data JDBC到构建文件中
添加Spring Data JDBC依赖,手动或自动。
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
可以移除starter-jdbc了。
3.2.2 定义存储库接口
我们需要细微改变两个仓库,以Spring Data JDBC方式使用它们。
Spring Data会在运行时自动生成存储库接口的实现,我们只需要拓展Repository接口就行。
修改IngredientRepository
package tacos.data;import java.util.Optional;import org.springframework.data.repository.Repository;import tacos.Ingredient;public interface IngredientRepository extends Repository<Ingredient, String> {Iterable<Ingredient> findAll();Optional<Ingredient> findById(String id);Ingredient save(Ingredient ingredient);}
可以看到Repository接口是参数化的,第1个参数是需要持久化的对象类型,第2个参数是持久化对象的ID字段,这里是String。
SpringData也为一些常见操作提供了CrudRepository基础接口,其中包含我们定义的三个方法,与其拓展Repository还不如拓展CrudRepository。
package tacos.data;import org.springframework.data.repository.CrudRepository;import tacos.Ingredient;public interface IngredientRepository extends CrudRepository<Ingredient, String> {}
方法可以都删除了。
类似的,将OrderRepository也拓展CrudRepository。
package tacos.data;import org.springframework.data.repository.CrudRepository;import tacos.TacoOrder;public interface OrderRepository extends CrudRepository<TacoOrder, Long> {}
关于SpringData,根本不需要编写任何实现,启动时会自动生成实现,意味着存储库已经就绪,直接注入控制器就可以了。两个实现也可以删了。
3.2.3 为实体类添加持久化注解
在TacoOrder类上添加@Table(可选,映射到Taco_Order表,也可以指定其他表名@Table(“taco_cloud_order”)),ID上添加@Id。
@Column声明deliveryName映射为指定名称。
其他类也类似。
3.2.4 使用CommandLineRunner预加载数据
SpringBoot提供两个非常有用的接口,用于应用启动时执行一定的逻辑,即CommandLineRunner和ApplicationRunner。
都是函数式接口,需要实现一个run方法,启动应用时,上下文所有实现了CommandLineRunner和ApplicationRunner的Bean都会执行其run()方法,执行时机是应用上下文和所有bean装配完毕之后,其他所有功能执行之前,这为将数据加载到数据库中提供了便利。
在配置类中很容易声明为bean,只需要在一个返回lambda表达式的方法上使用@Bean注解,请看如下案例:
package tacos.web;import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import tacos.Ingredient;
import tacos.Ingredient.Type;
import tacos.data.IngredientRepository;@Configuration
public class RunnerConfig {@Beanpublic CommandLineRunner dataLoader(IngredientRepository repo) {return args -> {repo.save(new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));repo.save(new Ingredient("COTO", "Corn Tortilla", Type.WRAP));repo.save(new Ingredient("GRBF", "Ground Beef", Type.PROTEIN));repo.save(new Ingredient("CARN", "Carnitas", Type.PROTEIN));repo.save(new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES));repo.save(new Ingredient("LETC", "Lettuce", Type.VEGGIES));repo.save(new Ingredient("CHED", "Cheddar", Type.CHEESE));repo.save(new Ingredient("JACK", "Monterrey Jack", Type.CHEESE));repo.save(new Ingredient("SLSA", "Salsa", Type.SAUCE));repo.save(new Ingredient("SRCR", "Sour Cream", Type.SAUCE));};}/** @Bean public ApplicationRunner dataLoader(IngredientRepository repo) { return* args -> { repo.save(new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));* repo.save(new Ingredient("COTO", "Corn Tortilla", Type.WRAP)); repo.save(new* Ingredient("GRBF", "Ground Beef", Type.PROTEIN)); repo.save(new* Ingredient("CARN", "Carnitas", Type.PROTEIN)); repo.save(new* Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES)); repo.save(new* Ingredient("LETC", "Lettuce", Type.VEGGIES)); repo.save(new* Ingredient("CHED", "Cheddar", Type.CHEESE)); repo.save(new Ingredient("JACK",* "Monterrey Jack", Type.CHEESE)); repo.save(new Ingredient("SLSA", "Salsa",* Type.SAUCE)); repo.save(new Ingredient("SRCR", "Sour Cream", Type.SAUCE)); };* }*/}
两个方法差不多,也就是arg参数不同,具体用到自己查吧。
这样的好处是可以不用SQL文件,而用存储库来创建持久化对象。
3.3 使用Spring Data JPA持久化数据
Java Persistence API(JPA)是另一个处理关系型数据库中数据的流行方案。
3.3.1 添加Spring Data JPA到项目中
手动或自动
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
JPA默认引用Hibernate依赖,想使用其他的需要替代它,用exclusion标签,自己查吧。
3.3.2 将领域对象标注为实体
解释一下注解意思:
- @Entity,实体类上必须加,识别为一个持久化对象。
- @Id,当前字段为id。
- @GeneratedValue(strategy = GenerationType.AUTO),id是自动生成的。
- @ManyToMany,多对多,表示每个Taco可以有多个Ingredient,每个Ingredient可以是多个Taco的组成部分。
- @OneToMany(cascade = CascadeType.ALL),所有taco都属于这个订单,删除此订单时,所有的关联taco也都会删除。
3.3.3 声明JPA存储库
CrudRepository同样也适用于JPA,与Spring Data JDBC定义的接口完全一样,此接口在众多Spring Data项目中广泛使用,无须关心底层的持久化机制是什么。
存储库不用改。
3.3.4 自定义JPA存储库
除了CrudRepository提供的基本CRUD操作之外,还需要获取投递到指定邮编(ZIP code)的订单,实际上,只需要添加如下的方法声明到OrderRepository中。
package tacos.data;import java.util.Date;
import java.util.List;import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;import tacos.TacoOrder;public interface OrderRepository extends CrudRepository<TacoOrder, Long> {List<TacoOrder> findByDeliveryZip(String deliveryZip);List<TacoOrder> readOrdersByDeliveryZipAndPlacedAtBetween(String deliveryZip, Date startDate, Date endDate);List<TacoOrder> findByDeliveryToAndDeliveryCityAllIgnoresCase(String deliveryTo, String deliveryCity);List<TacoOrder> findByDeliveryCityOrderByDeliveryTo(String city);@Query("select from Order o where o.deliveryCity = 'Seattle'")List<TacoOrder> readOrdersDeliveredInSeattle();
}
- Spring Data定义了一组小型领域特定语言(Domain-Specific Language,DSL),持久化的细节都是通过存储库方法的签名描述的。
- 存储库的方法由一个动词、一个可选的主题(subject)、关键词By,以及一个断言组成,findByDeliveryZip中,动词是find,断言是DeliveryZip,主题没有指定,暗含主题是TacoOrder。
- 更复杂的方法,readOrdersByDeliveryZipAndPlacedAtBetween。
通过@Query可以执行任何想要的查询。
登录H2看一下数据