更多编程技术文章,请查阅IOKKS - 专业编程技术分享平台
有许多情况下,应用程序需要灵活多变,能够在运行时生成动态报告。
本文旨在通过利用 PostgreSQL 数据库支持的临时配置参数,提供一种实现这一目标的方法。
根据 PostgreSQL 文档,在 7.3 版本开始,可以使用 set_config(name, value, is_local) 函数设置配置参数。稍后,可以使用 current_setting(name) 函数读取先前设置的参数值,必要时进行转换并使用。如果前一个函数的第三个参数为 true,则更改的设置仅适用于当前事务。
这正是这里所需要的——提供一个可以作为原子操作的一部分使用的运行时参数值的方法。
设置
示例应用程序构建如下:
- Java 21
- Spring Boot 版本 3.1.15
- PostgreSQL 驱动程序版本 42.6.0。
- Liquibase 4.20.0
- Maven 3.6.3
在应用程序级别,Maven 项目配置为使用 Spring Data JPA 和 Liquibase 依赖项。
领域由产品表示,其价格以各种货币表示。为了在不同货币之间进行转换,存在货币汇率。目标是能够以某种货币的汇率读取所有产品及其价格,以及某一天的汇率。
概念验证
为了开始建模,连接到数据库后首先应创建一个新模式。
create schema pgsetting;
有三个实体:Product、Currency 和 CurrencyExchange。
@Entity
@Table(name = "product")
public class Product {
@Id
@Column(name = "id")
private Long id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "price", nullable = false)
private Double price;
@ManyToOne
@JoinColumn(name = "currency_id")
private Currency currency;
...
}
@Entity
@Table(name = "currency")
public class Currency {
@Id
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "name", nullable = false)
private String name;
...
}
@Entity
@Table(name = "currency_exchange")
public class CurrencyExchange {
@Id
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "date", nullable = false)
private LocalDate date;
@ManyToOne
@JoinColumn(name = "from_currency_id", nullable = false)
private Currency from;
@ManyToOne
@JoinColumn(name = "to_currency_id", nullable = false)
private Currency to;
@Column(name = "value", nullable = false)
private Double value;
...
}
每个实体都有相应的 CrudRepository。
@Repository
public interface ProductRepository extends CrudRepository<Product, Long> { }
@Repository
public interface CurrencyRepository extends CrudRepository<Currency, Long> { }
@Repository
public interface CurrencyExchangeRepository extends CrudRepository<CurrencyExchange, Long> { }
数据源通常在 [application.properties](https://github.com/horatiucd/pg-setting/blob/master/src/main/resources/application.properties) 文件中配置,其中包括记录了用于初始化模式的三个表和它们之间关系的 Liquibase 变更日志文件的路径。
有关详细信息,可以查看应用程序属性和 db/changelog/schema-init.xml 文件。
根变更日志文件为:
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<include file="/db/changelog/schema-init.xml"/>
</databaseChangeLog>
应用程序启动时,变更集按声明顺序执行。到目前为止,一切都很简单,没有什么特别之处——一个简单的 Spring Boot 应用程序,其数据库变更由 Liquibase 管理。
创建动态报告
假设当前应用程序定义了两种货币——RON 和 EUR,以及两种以不同货币记录价格的产品。
货币
+--+----+
|id|name|
+--+----+
|1 |RON |
|2 |EUR |
+--+----+
产品
+--+-------------------+-----+-----------+
|id|name |price|currency_id|
+--+-------------------+-----+-----------+
|1 |Swatch Moonlight v1|100 |2 |
|2 |Winter Sky |1000 |1 |
+--+-------------------+-----+-----------+
2023 年 11 月 15 日的货币汇率
+--+----------+----------------+--------------+-----+
|id|date |from_currency_id|to_currency_id|value|
+--+----------+----------------+--------------+-----+
|1 |2023-11-15|2 |1 |5 |
|2 |2023-11-15|2 |2 |1 |
|3 |2023-11-15|1 |2 |0.2 |
|4 |2023-11-15|1 |1 |1 |
+--+----------+----------------+--------------+-----+
目标结果是一个产品报告,其中所有价格以 EUR 表示,使用 2023 年 11 月 15 日的汇率。这意味着需要转换第二个产品的价格。
为了简化设计,先前设定的目标被分解为更小的部分,然后逐一实现。从概念上讲,应首先获取产品,然后转换它们的价格(如果需要)。
- 获取产品。
- 使用请求的货币汇率转换价格。
前者很简单。Spring Data Repository 方法很容易允许获取产品——List<Product> findAll()。
后者可以通过查询实现转换。
SELECT p.id,
p.name,
p.price * e.value price,
e.to_currency_id currency_id,
e.date
FROM product p
LEFT JOIN currency_exchange e on p.currency_id = e.from_currency_id and
e.to_currency_id = 2 and
e.date = '2023-11-15'
为了将两者合并,完成了以下操作:
- 定义一个视图,用于上述查询——product_view
它在 product-view.sql 文件中定义,并作为可重复的 Liquibase 变更集的幂等操作添加,每当更改时运行。
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<include file="/db/changelog/schema-init.xml"/>
<changeSet id="repeatable" author="horatiucd" runOnChange="true">
<sqlFile dbms="postgresql" path="db/changelog/product-view.sql"/>
</changeSet>
</databaseChangeLog>
- 定义一个新实体 — ProductView — 作为领域的一部分,并与相应的存储库一起使用。
@Entity
@Immutable
public class ProductView {
@Id
private Long id;
private String name;
private Double price;
private LocalDate date;
@ManyToOne
@JoinColumn(name = "currency_id")
private Currency currency;
...
}
@Repository
public interface ProductViewRepository extends org.springframework.data.repository.Repository<ProductView, Long> {
List<ProductView> findAll();
}
现在应用程序能够构建所需的报告,但只能为硬编码的货币和汇率。
为了在运行时传递这两个值,同一事务中执行了以下操作:
- 将两个参数值设置为配置参数 — SELECT set_config(:name, :value, true)
- 使用存储库方法获取 ProductView 实体
此外,修改了 product_view,以读取作为当前事务的一部分设置的配置参数,并相应地选择数据。
SELECT p.id,
p.name,
p.price * e.value price,
e.date,
e.to_currency_id currency_id
FROM product p
LEFT JOIN currency_exchange e on p.currency_id = e.from_currency_id and
e.to_currency_id = current_setting('pgsetting.CurrencyId')::int and
e.date = current_setting('pgsetting.CurrencyDate')::date;
current_setting('pgsetting.CurrencyId') 和 current_setting('pgsetting.CurrencyDate') 调用读取先前设置的参数,然后进行转换并使用。
实现需要一些额外的调整。
ProductViewRepository 增加了一个允许设置配置参数的方法。
@Repository
public interface ProductViewRepository extends org.springframework.data.repository.Repository<ProductView, Long> {
List<ProductView> findAll();
@Query(value = "SELECT set_config(:name, :value, true)")
void setConfigParam(String name, String value);
}
最后一个参数始终设置为 true,因此该值仅在当前事务期间保留。
此外,定义了一个 ProductService,用于清楚地标记事务中涉及的所有操作。
@Service
public class ProductService {
private final ProductViewRepository productViewRepository;
public ProductService(ProductViewRepository productViewRepository) {
this.productViewRepository = productViewRepository;
}
@Transactional
public List<ProductView> getProducts(Currency currency, LocalDate date) {
productViewRepository.setConfigParam("pgsetting.CurrencyId",
String.valueOf(currency.getId()));
productViewRepository.setConfigParam("pgsetting.CurrencyDate",
DateTimeFormatter.ofPattern("yyyy-MM-dd").format(date));
return productViewRepository.findAll();
}
}
参数的名称与 product_view 定义中使用的名称相同。
为了验证实现,设置了两个测试。
@SpringBootTest
class Product1Test {
@Autowired
private CurrencyRepository currencyRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private CurrencyExchangeRepository rateRepository;
@Autowired
private ProductService productService;
private Currency ron, eur;
private Product watch, painting;
private CurrencyExchange eurToRon, ronToEur;
private LocalDate date;
@BeforeEach
public void setup() {
ron = new Currency(1L, "RON");
eur = new Currency(2L, "EUR");
currencyRepository.saveAll(List.of(ron, eur));
watch = new Product(1L, "Swatch Moonlight v1", 100.0d, eur);
painting = new Product(2L, "Winter Sky", 1000.0d, ron);
productRepository.saveAll(List.of(watch, painting));
date = LocalDate.now();
eurToRon = new CurrencyExchange(1L, date, eur, ron, 5.0d);
CurrencyExchange eurToEur = new CurrencyExchange(2L, date, eur, eur, 1.0d);
ronToEur = new CurrencyExchange(3L, date, ron, eur, .2d);
CurrencyExchange ronToRon = new CurrencyExchange(4L, date, ron, ron, 1.0d);
rateRepository.saveAll(List.of(eurToRon, eurToEur, ronToEur, ronToRon));
}
}
前者获取以 EUR 记录价格,使用记录的汇率。
@Test
void prices_in_eur() {
List<ProductView> products = productService.getProducts(eur, date);
Assertions.assertEquals(2, products.size());
Assertions.assertTrue(products.stream()
.allMatch(product -> product.getCurrency().getId().equals(eur.getId())));
Assertions.assertTrue(products.stream()
.allMatch(product -> product.getDate().equals(date)));
Assertions.assertEquals(watch.getPrice(),
products.get(0).getPrice());
Assertions.assertEquals(painting.getPrice() * ronToEur.getValue(),
products.get(1).getPrice());
}
调用时,product_view 为:
+--+-------------------+-----+-----------+----------+
|id|name |price|currency_id|date |
+--+-------------------+-----+-----------+----------+
|1 |Swatch Moonlight v1|100 |2 |2023-11-15|
|2 |Winter Sky |200 |2 |2023-11-15|
+--+-------------------+-----+-----------+----------+
后者使用相同的汇率,获取以 RON 价格的产品。
@Test
void prices_in_ron() {
List<ProductView> products = productService.getProducts(ron, date);
Assertions.assertEquals(2, products.size());
Assertions.assertTrue(products.stream()
.allMatch(product -> product.getCurrency().getId().equals(ron.getId())));
Assertions.assertTrue(products.stream()
.allMatch(product -> product.getDate().equals(date)));
Assertions.assertEquals(watch.getPrice() * eurToRon.getValue(),
products.get(0).getPrice());
Assertions.assertEquals(painting.getPrice(),
products.get(1).getPrice());
}
调用时,product_view 为:
+--+-------------------+-----+-----------+----------+
|id|name |price|currency_id|date |
+--+-------------------+-----+-----------+----------+
|1 |Swatch Moonlight v1|500 |1 |2023-11-15|
|2 |Winter Sky |1000 |1 |2023-11-15|
+--+-------------------+-----+-----------+----------+
示例代码
可在 此处 找到。