负责对前端展示(web,wireless,wap)的路由和适配,对于传统B/S系统而言,adapter就相当于MVC中的controller;
主要负责获取输入,组装上下文,参数校验,调用领域层做业务处理,如果需要的话,发送消息通知等。层次是开放的,应用层也可以绕过领域层,直接访问基础实施层;
主要是封装了核心业务逻辑,并通过领域服务(Domain Service)和领域对象(Domain Entity)的方法对App层提供业务实体和业务逻辑计算。领域是应用的核心,不依赖任何其他层次;
主要负责技术细节问题的处理,比如数据库的CRUD、搜索引擎、文件系统、分布式服务的RPC等。此外,领域防腐的重任也落在这里,外部依赖需要通过gateway的转义处理,才能被上面的App层和Domain层使用。
统一对外提供服务接口,微服务调用等
领域反映到代码里就是模型,模型是对领域某个方面的抽象,并且可以用来解决相关域的问题,
Domain Model 的基础单元,分为实体和值对象两种。实体和值对象,二者是领域模型中非常重要的基础领域对象(Domain Object,DO)。
有唯一标志的核心领域对象(有ID,通过ID识别是否为同一个对象),且这个标志在整个软件生命周期中都不会发生变化。可类比和数据库打交道的Entity实体,不同的是DDD中这些实体会包含与该实体相关的业务逻辑,它是操作行为的载体。也就是说DO包含了业务字段和业务方法,要求强内聚且低耦合,实体的充血模型不包含持久化逻辑
依附于实体存在,通过对象属性来识别的对象,它将一些相关的实体属性打包在一起处理,形成一个新的对象。不关心唯一性(无ID,通过全字段的equals方法识别是否为同一对象,不提供set方法,若更新直接替换整体对象),具有校验逻辑、等值判断逻辑,只关心值的类,强调内聚
组织复杂的业务逻辑,多个实体和值对象一起协同工作,这个协同的组织就是聚合。聚合是数据修改和持久化的基本单元,同一个聚合内要保证事务的一致性,所以在设计的时候要保证聚合的设计拆分到最小化以保证效率和性能。每个聚合内有一个聚合根,多个实体、值对象和领域服务等领域对象。聚合是领域对象的显式分组,我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。
聚合有两个核心要素:
这个边界 根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。根实体对象有组成聚合所有对象的引用,但是外部对象只能引用根对象实体。只有聚合根才能使用仓储库直接查询,其它的只能通过相关的聚合访问。如果根实体被删除,聚合内部的其它对象也将被删除。聚合支持了领域模型的行为和不变性,同时充当一致性和事务性边界。聚合在领域模型里是一个逻辑边界,它本身没有业务逻辑实现相关的代码。聚合的业务逻辑是由聚合内的聚合根、实体、值对象和领域服务等来实现的。跨多个实体的领域逻辑通过领域服务来实现。比如,有的业务场景需要同一个聚合的A和B两个实体来共同完成,我们就可以将这段业务逻辑用领域服务组合A和B两个实体来完成。聚合根的作用是保证内部的实体的一致性,对外只需要对聚合根进行操作。
聚合表达了对象的关联关系,例如一个网购订单Order至少包含了客户信息和一个或多个订单项,那么这个聚合就可以进行如下建模:
//Order为聚合根
public class Order {
private String orderId;
//OrderItem为 实体 订单项
private List<OrderItem> items;
private Customer customer;
public Order(String orderId, Customer customer) {
this.orderId = orderId;
this.customer = customer;
this.items = new ArrayList<>();
}
public void addItem(OrderItem item) {
if (item == null) {
throw new IllegalArgumentException("Order item cannot be null");
}
this.items.add(item);
}
public void removeItem(OrderItem item) {
this.items.remove(item);
}
public double getTotalAmount() {
return items.stream().mapToDouble(OrderItem::getAmount).sum();
}
// Getters and setters
}
服务提供的操作是它提供给使用它的客户端,并突出领域对象的关系。(服务的目的是向上层提供接口)
所有的service只负责协调并委派业务逻辑给领域对象进行处理,其本身并未真正实现业务逻辑,绝大部分的业务逻辑都由领域对象承载和实现了。细分为领域服务和应用服务。
领域中的一些概念,如果是*名词,适合建模为对象的一般归类到实体对象或值对象。如果是动词*,比如一些操作、一些动作,代表的是一种行为,如果是和实体或值对象密切相关的,也可以合并到某个实体或者值对象中。但是,有些操作不属于实体或者值对象本身,或会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作,这时就需要创建领域服务来提供这些操作。简单理解:就是跨多个领域对象的业务方法
当一些逻辑不属于某个实体时,可以把这些逻辑单独拿出来放到领域服务中。可以使用领域服务的情况:
应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。
领域服务和应用服务的不同:
跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务流程通过应用服务来实现。
要点:Application Service 是业务流程的封装,不处理业务逻辑,如何判断一段代码到底是业务流程还是逻辑:
(1)不要有if/else分支逻辑
通常情况下,如果有分支逻辑的,都代表一些业务判断,那么,应该将逻辑封装到DomainService或者Entity里。但并非绝对。类似中断条件判断则不属于次
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
(2)不要有任何计算
将所有与业务字段相关的加减乘除等计算逻辑封装到实体里
(3)一些数据的转化可以交给其他对象来做
比如DTO Assembler,将对象间转化的逻辑抽取和剥离在单独的类中,降低ApplicationService的复杂度。使用mapstruct框架接口
一般ApplicationService的常见职能如下:
依赖倒置原则:Repository的接口是在Domain层,但是实现类是在Infrastructure层,Infrastructure层作为南向网关向上实现领域接口,向下对接基础设施功能,剥离领域依赖耦合,领域防腐层。
防腐层(Anti-Corruption),简单说,就是应用不要直接依赖外域的信息,要把外域的信息转换成自己领域上下文(Context)的实体再去使用,从而实现本域和外部依赖的解耦。
在该架构中,我们把AC这个概念进行了泛化,将数据库、搜索引擎等数据存储都列为外部依赖的范畴。利用依赖倒置,统一使用gateway来实现业务领域和外部依赖的解耦。
领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,感兴趣的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。
领域事件的引入主要是为了更有效地追踪实体状态的改变,并且在状态改变时,通过事件消息的传递来实现领域模型对象之间的协同工作。事件命名有一定的规范:**名称 + 动词过去式 + event(ContextRefreshedEvent)**每个领域事件都有一个时间戳,表示事件发生的时间,领域事件可以选择持久化到数据库中。通过事件机制,不同的服务或模块之间可以实现低耦合的通信,促进系统的可扩展性和维护性保持系统灵活性。
对于简单的crud方法,在每个分层中有统一的规范命名。
方法名称,对应层次 | adapter层 | app层 | repo接口和infr层 | mapper层 |
---|---|---|---|---|
查询方法 | getxxx | searchxxx | findxxx | selectxxx |
删除方法 | removexxx | erasexxx | purgexxx | deletexxx |
新增方法 | addxxx | createxxx | savexxx | insertxxx |
更新方法 | editxxx | modifyxxx | changexxx | updatexxx |
其中由于domain层承担的是业务核心逻辑,而非普通crud,所以不存在改约束。特别的一点是,对于所有的分页获取数据的方法,统一命名为pageListXxx。示例controller基础接口如下:
public interface BaseController<T> {
/**
* 分页获取数据
*
* @param t
* @return
*/
XquantResponse<PageDTO<T>> pageList(T t);
/**
* 单条查询
* 查询方法命名 adapter层 getxxx, app层 searchxxx,(domain层 query)domain层承担的是业务核心逻辑,而非普通crud,repository接口和infrastructure层 findxxx,mapper层 selectxxx
*
* @param id 主键值
* @return
*/
XquantResponse<T> getById(Long id);
/**
* 新增或修改单条数据(一般情况下可以将add和edit方法合并为saveOrUpdate方法)
*
* @param t 参数
* @return
* @see #addOne(T)
* @see #editOne(T)
*/
XquantResponse<Boolean> saveOrUpdateOne(T t);
/**
* 单条删除
* 删除方法命名 adapter层 remove, app层 erase, repository接口和infrastructure层 purge, mapper层 delete
*
* @param id 主键值
* @return
*/
XquantResponse<Boolean> removeOne(Long id);
/**
* 新增单条数据
* 新增方法命名 adapter层 add, app层 create, repository接口和infrastructure层 save, mapper层 insert
*
* @param t 参数
* @return
* @see #saveOrUpdateOne(T)
*/
default XquantResponse<Boolean> addOne(T t) {
return null;
}
/**
* 编辑更新数据
* 更新方法命名 adapter层 edit, app层 modify, repository接口和infrastructure层 change, mapper层 update
*
* @param t 参数
* @return
* @see #saveOrUpdateOne(T)
*/
default XquantResponse<Boolean> editOne(T t) {
return null;
}
/**
* 列表查询 不分页
*
* @param t 查询参数
* @return
*/
default XquantResponse<List<T>> getList(T t) {
return null;
}
}
例如要实现一个银行转账业务功能,可按照如下步骤进行构建
提取核心域,我们需要做的第一件事就是提取关键词。分析该业务,转账的核心功能就是把A账户的钱转到B账户名下,其中涉及到了2个关键词钱和账户,然后对关键词进行抽象拓展,即形成领域模型。
模型抽象需要做的事是将钱和账户变得通用化,以应对可预见的业务变化,例如钱在生活中大部分情况下我们都直接等同为金额,10元 100元这样。那么在代码中钱这个概念可能就直接设计为BigDecimal类型。这里存在一个隐藏的缺陷是,金额实际上只是钱的一个属性,钱实际上至少还包含一个明显的属性是 币种类型,是人民币还是港币。而我们的领域是充血模型的,要求其具备高内聚的特性,所以关于钱的一些校验方法,以及与钱相关的方法我们都内聚在一个Money对象中。经过一轮抽象风暴,钱这个关键词比起生活中的钱的概念变得更具有抽象性,而反应在代码中,其变的更具象化,我们为钱的初步建模如下所示:
@Data
@AllArgsConstructor(onConstructor = @__(@JsonCreator))
@NoArgsConstructor
public class Money {
/**
* 金额
*/
private long cent;
/**
* 币种
*/
private Currency currency;
/**
* 金额相减
*
* @param money
* @return
*/
public Money subtract(Money money) {
return new Money(this.cent - money.cent, this.currency);
}
/**
* 金额相加
*
* @param money
* @return
*/
public Money add(Money money) {
return new Money(this.cent + money.cent, this.currency);
}
/**
* 金额利率
*
* @param money
* @return
*/
public BigDecimal multiply(BigDecimal money) {
return BigDecimal.valueOf(this.cent).multiply(money);
}
其中包含了与money相关的各个属性和方法,而且money在这里作为被设计为值对象的时候,其中的方法都只操作自己具备的属性,也就是说方法的入参和出参不会有除Money外的其他领域模型对象,这样在高内聚的同时,与其他领域极大降低了耦合度。如果需要涉及多个领域模型的业务操作,在领域服务中处理。并且领域中只做内存计算,不会有存储层的方法调用。
money对象在这里并不关心唯一性,在设计账户领域模型的时候,他将作为值对象依附于账户存在。我们对账户建模如下:
@Data
public class Account {
private Long id;
/**
* 可用余额
*/
private Money available;
/**
* 每日限额
*/
private Money dailyLimit;
public Currency getCurrency() {
return this.available.getCurrency();
}
/**
* 转入
*
* @param money
*/
public void deposit(Money money) {
if (!this.getCurrency().equals(money.getCurrency())) {
throw new XquantBaseException("金额异常");
}
this.available = this.available.add(money);
}
/**
* 转出
*
* @param money
*/
public void withdraw(Money money) {
if (this.available.compareTo(money) < 0) {
throw new XquantBaseException("金额异常");
}
if (this.dailyLimit.compareTo(money) < 0) {
throw new XquantBaseException("金额异常");
}
this.available = this.available.subtract(money);
}
}
这样,Money和Account都内聚了自己强相关的业务逻辑,包括与之相关的基本校验
当一些业务逻辑涉及到多个领域对象时,使用领域服务来完成。领域服务的包(domainservice)处于domain层。例如转帐的领域服务:
@Service
public class AccountTransferServiceImpl implements AccountTransferService {
@Autowired
private ExchangeRateService exchangeRateService;
@Override
public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
//ExchangeRate exchangeRate1 = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetAccount.getCurrency());
//省略部分代码
Money sourceMoney = exchangeRate.exchange(targetMoney);
//转入
sourceAccount.deposit(sourceMoney);
//转出
targetAccount.withdraw(targetMoney);
}
}
其中Account也是领域对象,ExchangeRate是值对象。
当我们要对外暴露接口服务功能,对领域对象或者服务进行编排和串联的时候,就需要组织应用服务了,应用服务位于app层中,同时推荐将持久化操作和事务操作都放置在这一层次。应用层可以注入基础设施层的许多服务,例如持久化,消息中间件等服务。简单的crud方法也在这一层调用存储层并对外提供接口:
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private AuditMessageProducer auditMessageProducer;
@Autowired
private ExchangeRateService exchangeRateService;
@Autowired
private AccountTransferService accountTransferService;
@Transactional
@Override
public XquantResponse<Boolean> transfer(AccountDTO accountDTO) {
String targetAccountNumber = accountDTO.getTargetAccountNumber();
BigDecimal targetAmount = accountDTO.getAmount();
// 参数校验
Money targetMoney = new Money(targetAmount.longValue(), new Currency("CNY"));
// 读数据
Account sourceAccount = accountRepository.findById(accountDTO.getId());
Account targetAccount = accountRepository.findById(Long.valueOf(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 保存数据 todo 纯粹的业务逻辑和数据分离,聚合数据库操作到一个事务方法
accountRepository.saveAccount(sourceAccount);
accountRepository.saveAccount(targetAccount);
// 发送审计消息
AuditMessage message = new AuditMessage(1L, sourceAccount, targetAccount, targetMoney, new Date());
auditMessageProducer.send(message);
return XquantResponse.success(true);
}
@Override
public XquantResponse<Boolean> saveOrUpdateAccount(AccountDTO accountDTO) {
Account account = AccountApBuilder.INSTANCE.toDomain(accountDTO);
Boolean aBoolean = accountRepository.saveOrUpdateAccount(account);
return XquantResponse.success(aBoolean);
}
@Override
public XquantResponse<PageDTO<AccountDTO>> pageListAccount(AccountDTO accountDTO) {
Account account = AccountApBuilder.INSTANCE.toDomain(accountDTO);
PageDTO<Account> accountPageDTO = accountRepository.pageListAccount(account, accountDTO.getCurrPage(), accountDTO.getPageSize());
PageDTO<AccountDTO> result = AccountApBuilder.INSTANCE.toPageList(accountPageDTO);
return XquantResponse.success(result);
}
@Override
public XquantResponse<AccountDTO> searchAccountById(Long id) {
Account byId = accountRepository.findById(id);
return XquantResponse.success(AccountApBuilder.INSTANCE.doToDTO(byId));
}
@Override
public XquantResponse<Boolean> eraseAccountById(Long id) {
Boolean aBoolean = accountRepository.purgeAccountById(id);
return XquantResponse.success(aBoolean);
}
@Override
public XquantResponse<Boolean> createAccount(AccountDTO accountDTO) {
Account account = AccountApBuilder.INSTANCE.toDomain(accountDTO);
Boolean aBoolean = accountRepository.changeAccount(account);
return XquantResponse.success(aBoolean);
}
@Override
public XquantResponse<Boolean> modifyAccount(AccountDTO accountDTO) {
Account account = AccountApBuilder.INSTANCE.toDomain(accountDTO);
Boolean aBoolean = accountRepository.changeAccount(account);
return XquantResponse.success(aBoolean);
}
}
基础设施层承担了领域防腐ACL的重任,将外部的三方设施与领域模型分离,使用依赖倒置原则让业务细节和技术细节解耦。例如我们在domain层定义仓储服务接口:
/**
* @Author yongliang.xiong
* @Date 2024/11/8 14:40
* @Description 数据存储的依赖反转,在南向网关中,我们只定义接口,解耦业务代码和存储代码。实现和ACL交由inf层
*/
public interface AccountRepository {
/**
* 根据id获取账户
*
* @param id
* @return
*/
Account findById(Long id);
/**
* 保存账户信息
*
* @param account
* @return
*/
Account saveAccount(Account account);
/**
* 保存或更新账户信息
*
* @param account
* @return
*/
Boolean saveOrUpdateAccount(Account account);
/**
* 分页查询账户列表
*
* @param account
* @param currPage 当前页码
* @param pageSize 每页数据量
* @return
*/
PageDTO<Account> pageListAccount(Account account, long currPage, long pageSize);
/**
* 根据id查询账户
*
* @param id
* @return
*/
Account searchAccountById(Long id);
/**
* 根据id删除账户
*
* @param id
* @return
*/
Boolean purgeAccountById(Long id);
/**
* 修改账户
*
* @param account
* @return
*/
Boolean changeAccount(Account account);
}
然后在infrastructure层实现对应接口:
@Service
public class AccountRepositoryImpl extends ServiceImpl<AccountMapper, AccountPO> implements AccountRepository {
@Resource
private AccountMapper accountMapper;
@Resource
private AccountBuilder accountBuilder;
@Override
public Account findById(Long id) {
AccountPO accountPO = accountMapper.selectById(id);
return AccountBuilder.INSTANCE.toDomain(accountPO);
//AccountPO byId = this.getById(id);
}
@Override
public Account saveAccount(Account account) {
AccountPO accountPO = AccountBuilder.INSTANCE.toPO(account);
this.save(accountPO);
return account;
}
@Override
public Boolean saveOrUpdateAccount(Account account) {
AccountPO accountPO = AccountBuilder.INSTANCE.toPO(account);
return this.saveOrUpdate(accountPO);
}
@Override
public PageDTO<Account> pageListAccount(Account account, long currPage, long pageSize) {
Page<AccountPO> dataPage = new Page<>(currPage, pageSize);
AccountPO accountPO = AccountBuilder.INSTANCE.toPO(account);
List<AccountPO> accountPOIPage = accountMapper.selectPageAccount(dataPage, accountPO);
dataPage.setRecords(accountPOIPage);
return AccountBuilder.INSTANCE.toPageList(dataPage);
//List records = AccountBuilder.INSTANCE.toDomainList(accountPOIPage);
// return new PageDTO(records, dataPage.getTotal(), dataPage.getSize(), dataPage.getCurrent();
}
@Override
public Account searchAccountById(Long id) {
AccountPO byId = this.getById(id);
return AccountBuilder.INSTANCE.toDomain(byId);
}
@Override
public Boolean purgeAccountById(Long id) {
return this.removeById(id);
}
@Override
public Boolean changeAccount(Account account) {
AccountPO accountPO = AccountBuilder.INSTANCE.toPO(account);
return this.updateById(accountPO);
}
}
防腐层除了依赖反转之外,还要注意DO,DTO和PO(infrastructure层的持久化对象)对象的转换,要将外界的变动信息隔离在领域层之外,就需要在infr层进行对象转化,这是一个繁琐但是也是必要的处理,好在我们可以通过mapstruct来处理:
@Mapper(componentModel = "spring")
public interface AccountBuilder {
AccountBuilder INSTANCE = Mappers.getMapper(AccountBuilder.class);
/**
* 将领域对象转换为PO
*
* @param account
* @return
*/
@Mappings({
@Mapping(source = "available.cent", target = "availableCent"),
@Mapping(source = "available.currency.currencyCode", target = "currency"),
@Mapping(source = "available.cent", target = "dailyLimit")
})
AccountPO toPO(Account account);
/**
* 将PO转换为领域对象
*
* @param accountPO
* @return
*/
@Mappings({
@Mapping(target = "available.cent", source = "availableCent"),
@Mapping(target = "available.currency.currencyCode", source = "currency"),
@Mapping(target = "dailyLimit.cent", source = "dailyLimit")
})
Account toDomain(AccountPO accountPO);
/**
* 将分页对象转换为分页列表
*
* @param dataPage
* @return
*/
default PageDTO<Account> toPageList(Page<AccountPO> dataPage) {
List<AccountPO> content = dataPage.getRecords();
List<Account> mappedContent = content.stream()
.map(this::toDomain).collect(Collectors.toList());
return new PageDTO(mappedContent, dataPage.getTotal(), dataPage.getSize(), dataPage.getCurrent());
}
}
类似依赖的消息中间件,第三方接口调用等操作都是如此,需要在基础设施层做处理,隔离易变性。
至此围绕钱和账户,我们构建了对应的领域模型,并提取了领域服务和应用服务,在基础设施层实现了领域防腐。至此服务对adapter和client层已经处于可用状态。但是需要意识到的一点是:业务和需求是会持续变化的,良好的程序也是渐进式演化的,领域驱动设计也不例外,优秀的框架设计和架构并不追求固定不变。我们总是以开闭原则为核心,保持可拓展性和灵活性。因此,领域模型的设计也会随着业务的变化而改进,就如同money模型,我们现在这个建模是传统意义上的钱,也就是纸币,现在除了纸币还有数字货币,虚拟货币等,如果业务升级到需要囊括这些新型货币,不可避免要重建模型。
外抛的异常不能过于宽泛,RuntimeException就过于宽泛。应该使用XquantBaseException或则其子类,自定义异常直接继承XquantBaseException。例如:
public void deposit(Money money) {
if (!this.getCurrency().equals(money.getCurrency())) {
//外抛的异常不能过于宽泛,例如 throws RuntimeException
throw new XquantBaseException("金额异常");
}
this.available = this.available.add(money);
}
同理捕获异常时也应尽量避免过于宽泛的捕获处理。在catch语句中不应该使用printStackTrace打印异常,应该使用日志组件来记录error:
public String test() {
try {
return "s.getName()";
} catch (Exception e) {
//错误使用
e.printStackTrace();
//应该使用日志记录
log.error("异常日志!", e);
}
return "";
}
包括但不限于 Vector、Stack、Hashtable 和 StringBuffer这类线程相对安全的工具类。当明确不会出现线程安全问题时,使用未作同步处理的工具类List、Deque、Map、StringBuilder代替以获取更高性能。例如StringBuffer,如果对象未发生线程逃逸,那么就使用StringBuilder代替。例如以下方法是未逃逸不需要使用StringBuffer的:
public void buildString() {
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" ");
sb.append("World");
String result = sb.toString();
System.out.println(result);
}
被标注@Deprecated的方法和类,都是不稳定的,将会被移除或替换,标注的注解会提供{@link #newMethod()}来提供替代方案,应该使用该替代方案来替换。同时在提交的代码中和发布包中不能出现System.out.println()语句来打印日志。在catch语句中不能出现Throwable::printStackTrace()。
对于各种用于获取结果的方法如getXxx(常见如Map::get),在获取其结果后,使用该结果前应该使用Optional::ofNullable进行非空检查以避免NPE异常:
public void processUser(Long id) {
User user = getUserById(id);
Optional<User> optionalUser = Optional.ofNullable(user);
optionalUser.ifPresent(u -> {
//存在时逻辑处理
});
//或者是中断条件
if (optional.isPresent()) {
//todo
}
}