六边形架构给我带来了什么
前瞻
我们公司的项目是使用Java语言Springboot框架开发的,由于项目诞生较早没经过设计所以一直是以最简单的 Controller,Service,Repository,Entity
的架构跑到了如今,恰逢美国对中国的技术封锁日益加剧,国家推出了中国信创产业发展白皮书,所以一些技术需要进行国产化,国内也涌出众多国产化的产品,如: TongWeb, OpenGauss,kingBase,Oceanbase 等。这篇文章不对这些国产化产品进行评价。只是由于信创的要求,大量客户要求我们厂家也需要支持国产化的组件。而一个优秀的软件架构可以省下非常多的工作量。
当前的形势
没错,对于老代码的屎山,高耦合就是唯一的特点。底层使用的postgresql数据库。以前我也发过一篇文章
信创兼容kingBase数据库,kingBase是基于postgres9.6开发的所以没什么难度,但是这次我们要兼容Oceanbase,这个数据库是基于Mysql开发的,由于mysql和postgres在语法层面的不同和不同的支持类型,同时兼容几乎是不可能的。所以我们需要改变我们目前的软件的设计架构,也就是六边形架构。
什么是六边形架构
六边形架构(Hexagonal Architecture),又称为端口和适配器架构(Ports and Adapters Architecture),是一种软件架构风格,旨在实现松耦合、可测试和可扩展的应用程序。它强调将业务逻辑与外部依赖(例如数据库、UI、外部服务等)解耦,以便更容易进行替换、测试和演化。
六边形架构的核心思想是将应用程序划分为内部核心(Core)和外部适配器(Adapters)两个主要部分:
- 内部核心:内部核心是应用程序的主要业务逻辑和领域模型的集中部分。它包含业务实体、值对象、领域服务和业务规则等,以实现特定的业务需求。内部核心是独立于外部环境的,不依赖于具体的技术实现或外部系统。
- 外部适配器:外部适配器是将内部核心与外部依赖(例如数据库、UI、外部服务等)连接起来的部分。它负责将外部数据转换为内部核心所需的格式,并将内部核心的输出适配为外部依赖所需的格式。外部适配器还负责处理与外部环境的交互,例如数据访问、UI呈现、外部服务调用等。
当然万变不离其宗,假如我们能够真正理解和做到 高内聚低耦合
,那么我相信不管什么架构都可以对突然的需求和业务进行有条不紊的应对。
新的项目结构
我不是一个理论学家,方法论讲完了,就开始来一个demo吧
src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── hazelcastspb
│ │ ├── HazelcastSpbApplication.java
│ │ ├── app
│ │ │ └── rest
│ │ │ └── InterfaceController.java
│ │ ├── domain
│ │ │ ├── Interface.java
│ │ │ ├── repository
│ │ │ │ └── InterfaceRepository.java
│ │ │ └── service
│ │ │ ├── DomainInterfaceService.java
│ │ │ └── InterfaceService.java
│ │ └── infra
│ │ ├── config
│ │ │ ├── BeanConfiguration.java
│ │ │ └── HazelcastClientConfig.java
│ │ └── repository
│ │ ├── config
│ │ │ └── EntityConfiguration.java
│ │ ├── mysql
│ │ │ ├── InterfaceEntity.java
│ │ │ ├── MySQLInterfaceRepository.java
│ │ │ └── SpringDataInterfaceRepository.java
│ │ └── postgresql
│ │ ├── InterfaceEntity.java
│ │ ├── PSQLInterfaceRepository.java
│ │ └── SpringDataInterfaceRepository.java
可以看到项目分成了应用层和领域层和基础架构层,基础架构层负责底层的数据库交互操作,领域层则基于基础架构层提供的数据进行业务逻辑计算,这个demo比较简单,所以这里就不赘述DDD部分的内容。
数据源切换
我使用的是Spring data JPA, 数据源的切换我们需要解决如下问题
为什么会有两个InterfaceEntity?
因为我的 Interface
对象的某些字段在不同数据库中的类型不同。当然如果都一样可以使用一个·Entity ·。
package com.example.hazelcastspb.domain;
//略
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@MappedSuperclass
public class Interface implements Serializable {
@Id
@GeneratedValue(generator = "jpa-uuid")
@Column(name = "id")
protected UUID id;
@Column(name = "urn")
protected String urn;
@Column(name = "device")
protected String device;
@Column(name = "name")
protected String name;
@Column(name = "alias")
protected String alias;
@Column(name = "description")
protected String description;
@Column(name = "mtu")
protected int mtu;
@Column(name = "mac_address")
protected String macAddress;
@Column(name = "vlan_id")
protected String vlanId;
@Column(name = "vsys")
protected String vsys;
@Column(name = "vrf")
protected String vrf;
@Column(name = "zone")
protected String zone;
@Column(name = "text")
protected String text;
@Transient
private Set<String> ips;
@Transient
private Map<String, Object> extra;
@Transient
protected Boolean enabled;
}
在domain包下的Interface类,绝大部分的字段Postgres和Mysql都是一致的,除了 ips
,extra
,enabled
,所以我将这三个字段加上了 @Transient
注解。
mysql包下的 InterfaceEntity
package com.example.hazelcastspb.infra.repository.mysql;
//略
/**
* @author fangcong
* @version 0.0.1
* @since Created by work on 2023-06-16 11:28
**/
@Entity(name = "mysql_interface")
@Table(name = "interface")
@TypeDef(name = "json", typeClass = JsonStringType.class)
public class InterfaceEntity extends Interface {
@Column(name = "enabled", columnDefinition = "TINYINT(1)")
private Boolean enabled;
@Type(type = "json")
@Column(name = "ips", columnDefinition = "json")
private Set<String> ips;
@Type(type = "json")
@Column(name = "extra", columnDefinition = "json")
private Map<String, Object> extra;
}
postgresql包下的 InterfaceEntity
package com.example.hazelcastspb.infra.repository.postgresql;
//略
/**
* @author fangcong
* @version 0.0.1
* @since Created by work on 2023-06-16 11:28
**/
@Entity(name = "pg_interface")
@Table(name = "interface")
@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class)
public class InterfaceEntity extends Interface {
@Column(name = "enabled")
private Boolean enabled;
@Type(type = "jsonb")
@Column(name = "ips", columnDefinition = "jsonb")
private Set<String> ips;
@Type(type = "jsonb")
@Column(name = "extra", columnDefinition = "jsonb")
private Map<String, Object> extra;
}
可以注意到,布尔值和json类型
在两种数据库中的展现形式是不一样的。你们更喜欢哪种呢?我是 postgres
的粉丝。
两个InterfaceEntity怎么动态加载?
如果你懂JPA的话,就知道实体也就是加上了 @Entity
的类的加载是不能像Spring的bean一样通过 @Conditional
这样的方式来实现动态加载的,你必须指定好当前数据源要加载的实体的包。所以我们可以通过当前数据源加载的驱动类来判断。关于实体工厂类这部分在另一篇文章里有提到 Springboot配置多数据源
直接贴代码:
package com.example.hazelcastspb.infra.repository.config;
//略
/**
* @author fangcong
* @version 0.0.1
* @since Created by work on 2023-06-16 15:01
**/
@Configuration
public class EntityConfiguration implements ApplicationContextAware {
public static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext context) {
applicationContext = context;
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
@Bean(name = "entityManager")
public EntityManager entityManager(EntityManagerFactoryBuilder builder,DataSource DataSource) {
return Objects.requireNonNull(entityManagerFactoryBean(builder, DataSource).getObject()).createEntityManager();
}
@Bean("entityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(EntityManagerFactoryBuilder builder, DataSource dataSource) {
var ds = (HikariDataSource) dataSource;
final String driverClassName = ds.getDriverClassName();
if (driverClassName.equals("org.postgresql.Driver")) {
return builder.dataSource(dataSource)
.properties(properties())
.packages("com.example.hazelcastspb.infra.repository.postgresql")
.build();
} else {
return builder.dataSource(dataSource)
.properties(properties())
.packages("com.example.hazelcastspb.infra.repository.mysql")
.build();
}
}
private Map<String, String> properties() {
Map<String, String> jpaProperties = new HashMap<>(16);
jpaProperties.put("hibernate.hbm2ddl.auto", "update");
jpaProperties.put("hibernate.show_sql", "true");
return jpaProperties;
}
}
动态加载Bean
通过 spring
的 @ConditionalOnClass
来进行 bean
的动态加载。
package com.example.hazelcastspb.infra.repository.mysql;
//略
/**
* @author fangcong
* @version 0.0.1
* @since Created by work on 2023-06-16 11:51
**/
@Repository
@ConditionalOnClass(name = {"com.mysql.cj.jdbc.Driver"})
public interface SpringDataInterfaceRepository extends JpaRepository<InterfaceEntity, UUID> {
List<InterfaceEntity> findByDevice(String device);
InterfaceEntity findByName(String name);
}
package com.example.hazelcastspb.infra.repository.postgresql;
//略
/**
* @author fangcong
* @version 0.0.1
* @since Created by work on 2023-06-16 11:51
**/
@Repository
@ConditionalOnClass(name = {"org.postgresql.Driver"})
public interface SpringDataInterfaceRepository extends JpaRepository<InterfaceEntity, UUID> {
List<InterfaceEntity> findByDevice(String device);
InterfaceEntity findByName(String name);
}
最终返回一个 InterfaceService
的 bean
提供给领域层使用
package com.example.hazelcastspb.infra.config;
//略
@Configuration
public class BeanConfiguration implements ApplicationContextAware {
public static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext context) {
applicationContext = context;
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
@Bean
@Primary
@ConditionalOnClass(name = {"com.mysql.cj.jdbc.Driver"})
InterfaceRepository initMysqlRepository(com.example.hazelcastspb.infra.repository.mysql.SpringDataInterfaceRepository repository) {
return new MySQLInterfaceRepository(repository);
}
@Bean
@ConditionalOnClass(name = {"org.postgresql.Driver"})
InterfaceRepository initPgRepository(SpringDataInterfaceRepository repository) {
return new PSQLInterfaceRepository(repository);
}
@Bean
InterfaceService interfaceService(InterfaceRepository repository) {
return new DomainInterfaceService(repository);
}
}
结尾
这样的话,通过分层和面向领域的接口开发降低了对于数据库的耦合度。当然这个例子还可以进一步优化。
- 我们使用的是JPA,对于大部分的增删改查都有
Hibernate
帮我们做,每支持一种数据库就多加一个SpringDataInterfaceRepository
是不是有点傻? - 简单的实体完全可以通用一个,该怎么修改?
大家可以通过评论区或者私信跟我进行讨论。