用领域驱动实现供应链系统商品录入和出入库设计

背景介绍

在现在比较常见的软件架构中 SSM 架构在一些项目中应用的非常广泛,但是仔细想想这种架构的开发方式实际上并没有使用面向对象的思路进行编程,对象只是一个数据的载体,对应着数据库表中的一条条记录。比如操作一个 User 对象。我们要修改用户的密码,现在怎么操作呢?

创建一个 UserService 在 Service 里面新建一个 changePass 的方法,然后查询用户信息,更改密码。习惯了这种开发方式之后,实际上并没有发现这种开发方式有什么问题。但是如果按照面向对象的思维方式,更改密码应该是哪个对象的行为呢,其实应该是 User 。因为用户拥有密码,它的行为应该由它自己进行控制。领域驱动设计正是强调这种设计。对象的行为应该由对象自己进行封装,而不是由第三方对象来进行维护(如 UserService )。

关于领域驱动本文不做过多的介绍,领域驱动是一个大的课题,美团技术发表过一篇文章进行了介绍《 领域驱动设计(DDD)在美团点评业务系统的实践》。本文将通过一个业务模块的功能实现来说明领域驱动设计的好处。

业务介绍

一家设备厂商,需要生产智能电子产品,电子产品的部件需要进行采购,公司有自己的仓库,需要对采购的零部件进行出入库统计,电子零部件在库房里可以组装,组装之后再进行对外售卖。公司现在需要一个供应链系统来对公司的所有的零部件进行跟踪和管理。

零部件的跟踪是通过商品条码来进行跟踪的,零部件组装之后会有一个新的条码,或者是原来某个商品的条码。

仓库可能有多个,仓库之间可以进行调货。

京东和天猫商城背后的支撑平台实际就是一个大的供应链系统。对商品从进货到卖给消费者都有完整的跟踪链条。

业务分析及技术简介

如果按照 MVC 的开发方式,在开发中实际需要完整的业务逻辑。因为这部分的业务逻辑是在 Service 层进行编写的。如果业务复杂并且后期业务逻辑变化快的时候,Service 层的代码会变的越来越难以维护。

项目模块和技术使用简介

本文将使用 Springboot SpringCloud 作为开发基础框架,使用 Spring Data Jpa 作为数据存储框架,另外引入 QueryDsl 来补充 Spring Data Jpa 在查询上的不足。通过 Spring Data Jpa 支持的聚合根事件进行业务间的解耦。

模块介绍

gh-pom-parent 模块用于管理所有的 jar 包。

gh-common 模块用于项目间通用功能的封装。

gh-code-gen 一个封装好的代码生成器模块 。

gh-erp-service 模块用于开发服务端的代码。

gh-service-clients 模块用于服务端生成客户端代码(比如 feign 客户端调用代码,okhttp 客户端调用服务端代码)。

关键封装点

common 模块中封装了创建 JPA 实体类需要继承的父类。

@MappedSuperclass

@Data

public abstract class AbstractEntity extends AggregateRoot implements Serializable {

    @Id

    @GeneratedValue(strategy = GenerationType.IDENTITY)

    @Setter(AccessLevel.PROTECTED)

    @Column(name = "id")

    private Long id;

    @Column(name = "created_at", nullable = false, updatable = false)

    @Convert(converter = InstantLongConverter.class)

    @Setter(AccessLevel.PRIVATE)

    private Instant createdAt;

    @Column(name = "updated_at", nullable = false)

    @Convert(converter = InstantLongConverter.class)

    @Setter(AccessLevel.PRIVATE)

    private Instant updatedAt;

    @Version

    @Column(name = "version")

    @Setter(AccessLevel.PRIVATE)

    private Integer version;

    @PrePersist

    public void prePersist(){

        this.setCreatedAt(Instant.now());

        this.setUpdatedAt(Instant.now());

    }

    @PreUpdate

    public void preUpdate(){

        this.setUpdatedAt(Instant.now());

    }

    public String toString() {

        return this.getClass().getSimpleName() + "-" + getId();

    }

}

这个类为每个实体的父类,这样子类就不用单独写 Id、CreateTime 、UpdateTime、Version 等属性了。另外 JPA 还支持自动审计的功能 @EnableJpaAuditing,这个在某些场景下不是很通用,如果有需要可以封装在自己编写的父类上。

service 层解耦代码封装

public abstract class AbstractService implements IService{

    private final Logger logger;

    protected AbstractService(Logger logger) {

        this.logger = logger;

    }

    protected <ID, T extends AggregateRoot> Creator<ID, T> creatorFor(BaseRepository<T,ID> repository){

        return new Creator<ID, T>(repository);

    }

    protected <ID, T extends AggregateRoot> Updater<ID, T> updaterFor(BaseRepository<T,ID> repository){

        return new Updater<ID, T>(repository);

    }

    protected class Creator<ID, T extends AggregateRoot>{

        private final BaseRepository<T,ID> repository;

        private Supplier<T> instanceFun;

        private Consumer<T> updater = a->{};

        private Consumer<T> successFun = a -> logger.info("success to save ");

        private BiConsumer<T, Exception> errorFun = (a, e) -> {

            logger.error("failed to save {}.", a, e);

            if(BusinessException.class.isAssignableFrom(e.getClass())){

                throw (BusinessException)e;

            }

            throw new BusinessException(CodeEnum.SaveError);

        };

        Creator(BaseRepository<T,ID> repository) {

            Preconditions.checkArgument(repository != null);

            this.repository = repository;

        }

        public Creator<ID, T> instance(Supplier<T> instanceFun){

            Preconditions.checkArgument(instanceFun != null);

            this.instanceFun = instanceFun;

            return this;

        }

        public Creator<ID, T> update(Consumer<T> updater){

            Preconditions.checkArgument(updater != null);

            this.updater = this.updater.andThen(updater);

            return this;

        }

        public Creator<ID, T> onSuccess(Consumer<T> onSuccessFun){

            Preconditions.checkArgument(onSuccessFun != null);

            this.successFun = onSuccessFun.andThen(this.successFun);

            return this;

        }

        public Creator<ID, T> onError(BiConsumer<T, Exception> errorFun){

            Preconditions.checkArgument(errorFun != null);

            this.errorFun = errorFun.andThen(this.errorFun);

            return this;

        }

        public T call(){

            Preconditions.checkArgument(this.instanceFun != null, "instance fun can not be null");

            Preconditions.checkArgument(this.repository != null, "repository can not be null");

            T a = null;

            try{

                a = this.instanceFun.get();

                this.updater.accept(a);

                this.repository.save(a);

                this.successFun.accept(a);

            }catch (Exception e){

                this.errorFun.accept(a, e);

            }

            return a;

        }

    }

    protected class Updater<ID, T extends AggregateRoot> {

        private final BaseRepository<T,ID> repository;

        private ID id;

        private Supplier<Optional<T>> loader;

        private Consumer<ID> onNotExistFun = id-> {throw new BusinessException(CodeEnum.NotFindError);};

        private Consumer<T> updater = a->{};

        private Consumer<Data> successFun = a -> logger.info("success to update {}", a.getId());

        private BiConsumer<Data, Exception> errorFun = (a, e) -> {

            logger.error("failed to update {}.{}", a, e);

            if(BusinessException.class.isAssignableFrom(e.getClass())){

                throw (BusinessException)e;

            }

            throw new BusinessException(CodeEnum.UpdateError);

        };

        Updater(BaseRepository<T,ID> repository) {

            this.repository = repository;

        }

        public Updater<ID, T> id(ID id){

            Preconditions.checkArgument(id != null);

            this.id = id;

            return this;

        }

        public Updater<ID, T> loader(Supplier<Optional<T>> loader){

            Preconditions.checkArgument(loader != null);

            this.loader = loader;

            return this;

        }

        public Updater<ID, T> update(Consumer<T> updater){

            Preconditions.checkArgument(updater != null);

            this.updater = updater.andThen(this.updater);

            return this;

        }

        public Updater<ID, T> onSuccess(Consumer<Data> onSuccessFun){

            Preconditions.checkArgument(onSuccessFun != null);

            this.successFun = onSuccessFun.andThen(this.successFun);

            return this;

        }

        public Updater<ID, T> onError(BiConsumer<Data, Exception> errorFun){

            Preconditions.checkArgument(errorFun != null);

            this.errorFun = errorFun.andThen(this.errorFun);

            return this;

        }

        public Updater<ID, T> onNotExist(Consumer<ID> onNotExistFun){

            Preconditions.checkArgument(onNotExistFun != null);

            this.onNotExistFun = onNotExistFun.andThen(this.onNotExistFun);

            return this;

        }

        public T call(){

            Preconditions.checkArgument(this.repository != null, "repository can not be null");

            Preconditions.checkArgument((this.loader != null || this.id != null), "id and loader can not both be null");

            T a = null;

            try {

                if (id != null && loader != null){

                    throw new RuntimeException("id and loader can both set");

                }

                if (id != null){

                    this.loader = ()->this.repository.findById(id);

                }

                Optional<T> aOptional = this.loader.get();

                if (!aOptional.isPresent()){

                    this.onNotExistFun.accept(id);

                }

                a = aOptional.get();

                updater.accept(a);

                this.repository.save(a);

                this.successFun.accept(new Data(id, a));

            }catch (Exception e){

                this.errorFun.accept(new Data(id, a), e);

            }

            return a;

        }

        @Value

        public class Data{

            private final ID id;

            private final T entity;

        }

    }

}

这个类利用 Java8 函数式编程将对象和行为进行分离。Service 继承这个类后可以使用链式编程并且将原来 Service 层业务逻辑代码抽离到实体类中进行编写。

平台通用枚举封装

package cn.geekhalo.common.constants;

public interface BaseEnum<T extends Enum<T> & BaseEnum<T>> {

    Integer getCode();

    String getName();

    static<T extends Enum<T> & BaseEnum<T> > T parseByCode(Class<T> cls, Integer code){

        for (T t : cls.getEnumConstants()){

            if (t.getCode().intValue() == code.intValue()){

                return t;

            }

        }

        return null;

    }

}

在一些项目中比较多的是写一个常量类,但是对于一些错误信息,我们往往需要给前端返回错误信息和错误码,这里我们使用枚举进行了封装。另外配合一个工具类可以方便的给前端返回下拉常量列表。

package cn.geekhalo.common.utils;

import cn.geekhalo.common.constants.BaseEnum;

import cn.geekhalo.common.constants.EnumDict;

import java.util.EnumSet;

import java.util.List;

import java.util.stream.Collectors;

public class EnumDictUtils {

    private EnumDictUtils(){}

    public static <T extends Enum<T> & BaseEnum<T>> List<EnumDict> getEnumDicts(Class<T> cls){

      return EnumSet.allOf(cls).stream().map(et -> new EnumDict(et.getName(), et.getCode())).collect(Collectors.toList());

    }

}

事件解耦

最新的 Spring Data 中 提供了一个抽象聚合根

//

// Source code recreated from a .class file by IntelliJ IDEA

// (powered by Fernflower decompiler)

//

package org.springframework.data.domain;

import java.util.ArrayList;

import java.util.Collection;

import java.util.Collections;

import java.util.List;

import org.springframework.data.annotation.Transient;

import org.springframework.util.Assert;

public class AbstractAggregateRoot<A extends AbstractAggregateRoot<A>> {

    @Transient

    private final transient List<Object> domainEvents = new ArrayList();

    public AbstractAggregateRoot() {

    }

    protected <T> T registerEvent(T event) {

        Assert.notNull(event, "Domain event must not be null!");

        this.domainEvents.add(event);

        return event;

    }

    @AfterDomainEventPublication

    protected void clearDomainEvents() {

        this.domainEvents.clear();

    }

    @DomainEvents

    protected Collection<Object> domainEvents() {

        return Collections.unmodifiableList(this.domainEvents);

    }

    protected final A andEventsFrom(A aggregate) {

        Assert.notNull(aggregate, "Aggregate must not be null!");

        this.domainEvents.addAll(aggregate.domainEvents());

        return this;

    }

    protected final A andEvent(Object event) {

        this.registerEvent(event);

        return this;

    }

}

继承了这个类后,在实体中可以注册事件,当调用 JPA 的 save 方法后,会将事件发出。并且还可以通过 @TransactionalEventListener 进行事务的集成。再通过

TransactionPhase.BEFORE_COMMIT

TransactionPhase.AFTER_COMMIT

TransactionPhase.AFTER_ROLLBACK

TransactionPhase.AFTER_COMPLETION

这四种配置监听器执行的时机。

数据校验封装

数据安全校验在系统中必不可少,比较方便的方法是通过注解进行校验,Hibernate Validator 在一些校验上还有一些不方便之处。本项目中引入一个比较好用的校验框架进行封装。Jodd-vtor 写一个通用校验类。

public class BaseValidator {

    public static <T> Validate  verifyDto(T dto){

        Vtor vtor = Vtor.create();

        vtor.validate(dto);

        List<Violation> vlist = vtor.getViolations();

        DefaultValidate validate = new DefaultValidate();

        if(CollectionUtils.isEmpty(vlist)){

            validate.setPass(true);

        }else {

            validate.setPass(false);

            validate.setErrorList(vlist.stream().map(vr -> new ErrorBody(vr.getCheck().getName(),vr.getCheck().getMessage())).collect(Collectors.toList()));

        }

        return validate;

    }

}

一些平台不支持的手机号,和一些其他定制的校验通过扩展来进行封装。可以查看 cn.geekhalo.common.validator 包下。

QueryDsl 集成

QueryDsl 使用面向对象的方式进行 Sql 语句的编写。通过编译期生成 Q 类 。

在 Maven 中配置如下

<plugin>

    <groupId>com.mysema.maven</groupId>

    <artifactId>apt-maven-plugin</artifactId>

    <version>1.1.3</version>

    <executions>

        <execution>

            <goals>

                <goal>process</goal>

            </goals>

            <configuration>

                <outputDirectory>target/generated-sources/java</outputDirectory>

                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>

            </configuration>

        </execution>

    </executions>

</plugin>

再导入项目依赖的包即可使用。具体用法可以在源码中进行查看。

引入 QueryDsl 有一个问题就是如果我们的父类在别的包里面,那么这个插件是默认不会找到父类并生成父类的 Q 类的,所以要在项目中加上一个 package-info 类。

@QueryEntities(value = {AbstractEntity.class})

package cn.geekhalo.erp.config;

import cn.geekhalo.common.ddd.support.AbstractEntity;

import com.querydsl.core.annotations.QueryEntities;

想用 JPA 使用 QueryDsl 提供的查询 方法则类需要继承一个接口 QuerydslPredicateExecutor

用法如下:

package cn.geekhalo.erp.repository.product;

import cn.geekhalo.common.ddd.support.BaseRepository;

import cn.geekhalo.erp.domain.product.Product;

import org.springframework.data.querydsl.QuerydslPredicateExecutor;

public interface ProductRepository extends BaseRepository<Product,Long>, QuerydslPredicateExecutor<Product> {

}

代码生成器简介

随着越来越多的框架支持编译期自动生成代码(比如 QueryDsl )。了解并熟练使用这门技术对开发有极大的帮助。本文开发的自动代码生成器主要引入以下两个 jar 包。关于代码实现可以查看源码,这里只是一个最初版的简单应用,文章最后会有其他介绍。

<dependency>

    <groupId>com.squareup</groupId>

    <artifactId>javapoet</artifactId>

    <version>1.8.0</version>

</dependency>

<dependency>

    <groupId>com.google.auto.service</groupId>

    <artifactId>auto-service</artifactId>

    <version>1.0-rc2</version>

</dependency>

这门技术叫 APT (Annotation Processing Tool),可以去 Google 搜索并深入研究该技术的使用。

本文主要使用自定义的注解 @GenCreator @GenUpdater 生成 BaseCreator BaseUpdater 。然后在 Domain 类进行数据的组装。如果不太理解可以简单的理解为 Bean 属性的复制。将 Dto 的属性复制到 Po 中,然后再进行 Entity 的保存。

JPA Entity 对象间关系的优化

熟悉 Hibernate 的同学应该知道,Hibernate 可以通过配置对象的关系,通过@OneToMany @OneToOne @ManyToOne 来进行对象映射。但是无论是懒加载和外键的设置都会有很多性能问题,在本文中不再使用关系进行配置,对象间没有任何外键的约束,如果对象间有关系,那么通过 ID 进行引用。也就是互联网中比较常见的无外键建表方式。

商品的录入设计(面向对象的设计)

电商系统大家应该都比较熟悉,在天猫和京东购物去浏览商品,我们可以看到不同的产品分类,当查看某个商品的时候,不同的商品会有不同的属性描述,同一个商品会有不同的型号,商品的录入就是要实现这样的一个功能。

在上面的描述中出现了电商系统中两个常见的概念 SPU 和 SKU,SPU 是标准化产品单元,区分品种,SKU 是库存量单位。举个例子苹果手机叫SPU。它是一个大的产品分类,16g,红色苹果手机就叫 SKU,它是库存量单位,出库和销售都是针对 SKU 来说的。

在面向对象的思维中,我们首先要考虑对象和对象之间的关系。从上面的需求中我们能抽象出来以下几个对象:产品,规格,产品属性,产品属性值,规格属性值,规格子项,产品分类等。

产品和规格是一对多的关系。产品和属性是一个对多的关系,属性和属性值是一对多的关系,规格和规格子项是一对多的关系,产品和产品分类是多对一的关系。

分析清楚了对象之间的关系,在领域驱动中比较关键的一步,就是要考虑每个抽象出来的对象本身有什么行为,也就是对应对象有什么方法。也就是说对象的行为只能对象本身去操作,外部对象只能调用对象提供的方法,外部对象不能直接操作对象。

首先面对这些对象,我们可以先不要考虑业务究竟要执行哪些动作,我们要先分析,这个对象有什么行为,首先抽象出来一个产品对象:

@Data

@Entity

@Table(name = "tb_product")

@ToString(callSuper = true)

@GenVO

public class Product extends AbstractEntity {

    @Column(name = "product_name")

    private String productName;

    @Column(name = "product_code")

    private String productCode;

    @Column(name = "category_id")

    private Long categoryId;

    @Column(name = "type_id")

    private Long productTypeId;

    @Column(name = "price")

    @Description(value = "指导价格")

    private BigDecimal price;

    @Convert(converter = ValidStatusConverter.class)

    @Column(name = "valid_status")

    @Description(value = "上下架状态")

    @GenCreatorIngore

    @GenUpdaterIgnore

    private ValidStatus validStatus;

    @Column(name = "serialize_type")

    @Description(value = "序列化类型")

    @Convert(converter = SerializeTypeConverter.class)

    private SerializeType serializeType;

    @Description(value = "保修时间--销售日期为准+天数")

    @Column(name = "valid_days")

    private Integer validDays;

    public void init(){

        setValidStatus(ValidStatus.VALID);

    }

    public void createProduct(CreateProductDto dto){

        setPrice(dto.getPrice());

        setValidDays(dto.getValidDays());

        setProductName(dto.getProductName());

        setSerializeType(dto.getSerializeType());

        setCategoryId(dto.getCategoryId());

        setProductTypeId(dto.getTypeId());

        setProductCode(dto.getProductCode());

    }

    public void invalid(){

        if(Objects.equals(ValidStatus.INVALID,getValidStatus())){

            throw new BusinessException(ErrorMsg.StatusHasInvalid);

        }

        setValidStatus(ValidStatus.INVALID);

    }

    public void valid(){

        if(Objects.equals(ValidStatus.VALID,getValidStatus())){

            throw new BusinessException(ErrorMsg.StatusHasValid);

        }

        setValidStatus(ValidStatus.VALID);

    }

}

在这个类中,可以看到,我们的实体类不再像之前 Entity 的用法一样了,这里面有创建逻辑,和禁用,启用逻辑了。那么 Service 层的代码现在这样编写。比如禁用和启用:

@Override

public void validProduct(Long id) {

    updaterFor(productRepository)

            .id(id)

            .update(product -> product.valid())

            .call();

}

@Override

public void invalidProduct(Long id) {

    updaterFor(productRepository)

            .id(id)

            .update(product -> product.invalid())

            .call();

}

这样做的好处就是 Product 的 validStatus 的更改只能通过 Product 实体里面定义的方法进行更改,这样便于统一维护。如果更改点散落在 service 中,那么将来有改动的话,则需要找到更改的 Service ,而实际情况是你不知道 Service 在哪里进行了更改。

产品有属性对象

@Data

@Entity

@Table(name = "tb_product_attribute")

@GenVO

public class ProductAttribute extends AbstractEntity {

    @Column(name = "product_id")

    @Setter

    private Long productId;

    @Setter(AccessLevel.PROTECTED)

    @Column(name = "attr_name")

    @Description(value = "属性名称")

    private String attrName;

    @Column(name = "attr_code")

    @Description(value = "属性编码")

    private String attrCode;

    @Column(name = "attr_desc")

    @Description(value = "属性描述")

    private String attrDesc;

    @Column(name = "sort_num")

    private Integer sortNum;

    @Column(name = "web_control_type")

    @Description(value = "前端控件类型")

    @Convert(converter = WebControlTypeConverter.class)

    private WebControlType controlType;

    public void createAttr(Long productId, AttrBean bean){

        setSortNum(0);

        setProductId(productId);

        setControlType(bean.getControlType());

        setAttrCode(bean.getAttrCode());

        setAttrDesc(bean.getAttrDesc());

        setAttrName(bean.getAttrName());

    }

}

因为没有其他业务操作,所有该类只有一个保存方法。该类通过 productId 关联产品类。

@Column(name = "product_id")

    @Setter

    private Long productId;

再抽象一个属性值类

@Table(name = "tb_product_attribute_value")

@Data

@Entity

@GenVO

public class ProductAttributeValue extends AbstractEntity {

    @Column(name = "product_attr_id")

    private Long productAttrId;

    @Description(value = "属性的值")

    private String attrValue;

    public ProductAttributeValue(Long productAttrId, String attrValue){

        this.productAttrId = productAttrId;

        this.attrValue = attrValue;

    }

}

属性值类通过 productAttrId 关联属性类

对象间的关系明确后,通过聚合根 Product 进行属性和属性值的维护,方法如下:

@Override

@Transactional

public void create(CreateProductDto dto) {

    Optional<ProductCategory> category = categoryRepository.findById(dto.getCategoryId());

    if(!category.isPresent()){

        throw new BusinessException(CodeEnum.NotFindError);

    }

    Optional<ProductType> productType = productTypeRepository.findById(dto.getTypeId());

    if(!productType.isPresent()){

        throw new BusinessException(CodeEnum.NotFindError);

    }

    Product createProduct = creatorFor(productRepository)

            .instance(() -> new Product())

            .update(product -> product.init())

            .update(product -> product.createProduct(dto))

            .call();

    logger.info("创建产品成功");

    logger.info("添加属性开始");

    List<AttrBean> list = dto.getAttrList();

    list.stream().forEach(

            ab -> {

                ProductAttribute productAttribute = creatorFor(attributeRepository)

                        .instance(() -> new ProductAttribute())

                        .update(attr -> attr.createAttr(createProduct.getId(),ab))

                        .call();

                ab.getAttrValues().forEach(av -> {

                    creatorFor(attributeValueRepository)

                            .instance(() -> new ProductAttributeValue(productAttribute.getId(),av))

                            .call();

                });

            }

    );

    logger.info("添加属性结束");

}

其中 Dto 是前台传递的参数。这样就完成的产品的添加。

规格的添加

规格是产品的库存单元,规格有明确的价格,库存,并且有上架和下架的方法。定义对象如下:

@Table(name = "tb_product_specification")

@Data

@Entity

@GenVO

public class ProductSpecification extends AbstractEntity {

    @Column(name = "product_id")

    private Long productId;

    @Description(value = "排序值")

    @Column(name = "sort_num")

    private Integer sortNum = 0;

    @Description(value = "规格唯一编码")

    @Column(name = "spec_code")

    private String specCode;

    @Column(name = "product_spec_name")

    private String productSpecName;

    @Column(name = "price")

    private BigDecimal price;

    @Column(name = "warn_stock")

    private Integer warnStock;

    @Column(name = "valid_status")

    @Convert(converter = ValidStatusConverter.class)

    private ValidStatus validStatus;

    @Column(name = "sale_status")

    @Convert(converter = ValidStatusConverter.class)

    private ValidStatus onlineStatus;

    @Column(name = "time_interval")

    @Description(value = "授权时间")

    private Integer timeInterval;

    public void doCreate(ProductSpecificationInitDto dto){

        setTimeInterval(Objects.isNull(dto.getTimeInterval())?0:dto.getTimeInterval());

        setOnlineStatus(ValidStatus.INVALID);

        setValidStatus(ValidStatus.INVALID);

        setProductId(dto.getProductId());

//        List<SerializeAttr> serializeAttrList = dto.getAttrs().stream().map(spec -> new SerializeAttr(spec.getAttrName(),spec.getAttrValue())).collect(Collectors.toList());

//        setAttrJson(JSON.toJSONString(serializeAttrList));

        setSortNum(1);

        setSpecCode(dto.getSpecCode());

        setProductSpecName(dto.getProductSpecName());

        setPrice(dto.getPrice());

        setWarnStock(dto.getWarnStock());

    }

    public void invalid(){

        if(Objects.equals(ValidStatus.INVALID,getValidStatus())){

            throw new BusinessException(ErrorMsg.StatusHasInvalid);

        }

        setValidStatus(ValidStatus.INVALID);

    }

    public void valid(){

        if(Objects.equals(ValidStatus.VALID,getValidStatus())){

            throw new BusinessException(ErrorMsg.StatusHasValid);

        }

        setValidStatus(ValidStatus.VALID);

    }

    public void onLine(){

        if(Objects.equals(ValidStatus.VALID,getOnlineStatus())){

            throw new BusinessException(ErrorMsg.StatusHasValid);

        }

        setOnlineStatus(ValidStatus.VALID);

    }

    public void offLine(){

        if(Objects.equals(ValidStatus.INVALID,getOnlineStatus())){

            throw new BusinessException(ErrorMsg.StatusHasInvalid);

        }

        setOnlineStatus(ValidStatus.INVALID);

    }

    @Value

    private static class SerializeAttr{

        private String k;

        private String v;

    }

    @Override

    public boolean equals(Object o) {

        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) return false;

        if (!super.equals(o)) return false;

        ProductSpecification that = (ProductSpecification) o;

        return Objects.equals(sortNum, that.sortNum) &&

                Objects.equals(specCode, that.specCode) &&

                Objects.equals(productSpecName, that.productSpecName);

    }

    @Override

    public int hashCode() {

        return Objects.hash(super.hashCode(), sortNum, specCode, productSpecName);

    }

}

里面定义了规格的所有行为。创建规格方法如下:

//添加规格

@Override

public ProductSpecification initSpecification(ProductSpecificationInitDto dto) {

    Optional<ProductSpecification> specification = specificationRepository.findBySpecCode(dto.getSpecCode());

    if(specification.isPresent()){

        throw new BusinessException(ErrorMsg.SpecCodeIsExist);

    }

    Optional<Product> product = productRepository.findById(dto.getProductId());

    if(!product.isPresent()){

        throw new BusinessException(CodeEnum.NotFindError);

    }

    ProductSpecification createSpec =  creatorFor(specificationRepository)

            .instance(() -> new ProductSpecification())

            .update(spec -> spec.doCreate(dto))

            .call();

    log.info("创建规格结束");

    dto.getAttrs().forEach(attrWrapper -> {

        attrWrapper.getAttrList().stream().forEach(attrValues -> {

            creatorFor(attrRepository)

                    .instance(() -> new SpecificationAttr())

                    .update(atr -> atr.create(createSpec.getId(),attrWrapper.getAttrId(),attrValues.getAttrValueId(),attrWrapper.getAttrName(),attrValues.getAttrValue(),attrWrapper.getAttrCode()))

                    .call();

        });

    });

    log.info("创建规格值结束");

    return createSpec;

}

通过以上的功能,我们可以看到,原来的 Service 方法里面不再有业务动作,只有查询校验操作,所有的业务动作都是在实体本身中编写的,这样 Service 层的代码将变的很单一。因为代码的实现比较多,可以直接看代码。这里就不附上所有的源码了。

商品出入库的设计

商品录入比较简单,如果业务比较复杂的话,那么我们该如何抽象业务对象,并且采用领域驱动进行设计呢。商品的出入库设计就是一个典型案例。

供应链系统的核心是什么呢?其实就是商品的跟踪,商品在仓库之间的流转。最终的目的是跟踪任何一个商品的生命周期。

假如我们没有产品图和原型图,我们只有这样的一个业务需求,我们该如何抽象出核心域呢。首先我们可以从生活上去想象,我们是一个仓库的管理员,我们需要按照老板的要求统计我们仓库内商品的明细。假如现在来了一批货我们该如何把这些货物记录下来呢,从实际来讲我们需要把条码一个个的记录下来,然后标注上这是入库。并且呢还需要一个助手把货物放入货架上,负责给货物分好批次并记录每种货物的库存量。突然有一天老板说,你得把每件货物的去向给我记录下来,这实际就对应着一个需求的增加,如果按照原来在 Service 层的写法,那么我们就需要更改我们的 Service 方法,实际上这种需求的增加我们可以通过一种方式进行解耦。答案就是事件。

事件是业务解耦的利器,如果我们分析下商品出入库的本质,实际上商品的出入库对应着两个事件,商品的出库和入库,如果我们把这两个事件进行优雅的处理,那么我们的核心业务(商品的跟踪)不就很容易的解决了吗。

有人可能要说了,仓库入库会有一个出入库单,那里面有总数量和总金额的统计,那通过单一的产品事件怎么来实现呢,其实这是两个概念,在领域设计时很容易混淆,在实际的操作中,往往有一个批次的概念。这里我们通过给商品打一个标识,说明一些商品是一个批次的,那么在处理事件时根据批次,则可以进行数量和总金额的统计。

商品的设计如下:

@Entity

@Data

@Table(name = "tb_goods")

@GenCreator

@GenUpdater

@GenVO

@ToString(callSuper = true)

public class Goods extends AbstractEntity {

    @Description(value = "条码")

    @Column(name = "bar_code")

    private String barCode;

    @Convert(converter = GoodsTypeConverter.class)

    @Column(name = "goods_type")

    private GoodsType goodsType;

    @Column(name = "valid_status")

    @Convert(converter = ValidStatusConverter.class)

    private ValidStatus validStatus;

    @Column(name="parent_id")

    @Setter(AccessLevel.PROTECTED)

    private Long parentId;

    @Column(name = "store_id")

    private Long storeId;

    @Column(name = "product_specification_id")

    private Long specificationId;

    @Column(name = "batch_id")

    @Description(value = "批次号-》代表一个批次")

    private Long batchId;

    @Column(name = "operate_user")

    private String operateUser;

    @Column(name = "provider_name")

    private String providerName;

    private void goodsIn(GoodsCreateDto object,Long storeId, Long specificationId,Long batchId){

        BizExceptionUtils.checkEmpty(object.getBarCode(), ErrorMsg.BarCodeNotNull);

        setValidStatus(ValidStatus.VALID);

        setBarCode(object.getBarCode());

        setOperateUser(object.getOperateUser());

        setStoreId(storeId);

        setProviderName(object.getProviderName());

        setBatchId(batchId);

        setSpecificationId(specificationId);

    }

    private void goodsOut(Long batchId,String operateUser){

        if(Objects.equals(ValidStatus.INVALID,getValidStatus())){

            throw new BusinessException(ErrorMsg.GoodsNotAvailable);

        }

        setBatchId(batchId);

        setOperateUser(operateUser);

        setValidStatus(ValidStatus.INVALID);

    }

    //商品零部件入库

    public void goodsBasicInStore(GoodsCreateDto object,Long storeId, Long specificationId,Long batchId){

        goodsIn(object,storeId,specificationId,batchId);

        setGoodsType(GoodsType.GOODS_BASIC);

        registerEvent(new GoodsEvents.GoodsInEvent(this,object.getOperateTime(),object.getProviderName(), InOutOperationType.IN_PURCHASE));

    }

    public void goodsTransferInStore(GoodsCreateDto object,Long storeId, Long specificationId,Long batchId,GoodsType type){

        goodsIn(object,storeId,specificationId,batchId);

        setGoodsType(type);

        registerEvent(new GoodsEvents.GoodsInEvent(this,object.getOperateTime(),object.getProviderName(),InOutOperationType.IN_TRANSFER));

    }

    public void goodsBuyInStore(GoodsCreateDto object,Long storeId, Long specificationId,Long batchId,GoodsType type){

        goodsIn(object,storeId,specificationId,batchId);

        setGoodsType(type);

        registerEvent(new GoodsEvents.GoodsInEvent(this,object.getOperateTime(),object.getProviderName(),InOutOperationType.IN_PURCHASE));

    }

    public void goodsTransferOutStore(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this,Instant.now().toEpochMilli(),InOutOperationType.OUT_TRANSFER));

    }

    //商品生产入库

    public void goodsMakeInStore(Long storeId,Long specificationId,GoodsCreateDto dto,Long batchId){

        goodsIn(dto,storeId,specificationId,batchId);

        setGoodsType(GoodsType.GOODS_MAKE);

        registerEvent(new GoodsEvents.GoodsInEvent(this,dto.getOperateTime(),dto.getProviderName(),InOutOperationType.IN_MAKE));

    }

    //商品配货入库

    public void goodsAssembleInStore(Long storeId,Long specificationId,GoodsCreateDto dto,Long batchId){

        goodsIn(dto,storeId,specificationId,batchId);

        setGoodsType(GoodsType.GOODS_ASSEMBLE);

        registerEvent(new GoodsEvents.GoodsInEvent(this,dto.getOperateTime(),dto.getProviderName(),InOutOperationType.IN_ASSEMBLE));

    }

    public void goodsBadInStore(Long storeId,Long specificationId,GoodsCreateDto dto,Long batchId){

        goodsIn(dto,storeId,specificationId,batchId);

        setGoodsType(GoodsType.GOODS_BAD);

        registerEvent(new GoodsEvents.GoodsInEvent(this,dto.getOperateTime(),dto.getProviderName(),InOutOperationType.IN_BAD));

    }

    public void goodsAssembleUsed(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this,Instant.now().toEpochMilli(),InOutOperationType.OUT_ASSEMBLE));

    }

    public void goodsMakeUsed(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this, Instant.now().toEpochMilli(),InOutOperationType.OUT_MAKE_USED));

    }

    public void goodsSaleToAgent(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this, Instant.now().toEpochMilli(),InOutOperationType.OUT_SALE_TO_AGENT));

    }

    public void goodSaleToUser(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this, Instant.now().toEpochMilli(),InOutOperationType.OUT_SALE_TO_USER));

    }

    public void goodsTransferToOtherStore(Long batchId,String operateUser){

        goodsOut(batchId,operateUser);

        registerEvent(new GoodsEvents.GoodsOutEvent(this, Instant.now().toEpochMilli(),InOutOperationType.OUT_TRANSFER));

    }

    public void parentId(Long pid){

        setParentId(pid);

    }

//    //将生产件置为配货使用

//    public void  goodsStatusToAssembleUsed(){

//        setValidStatus(ValidStatus.INVALID);

//        setGoodsStatus(GoodsStatus.ASSEMBLE_USED);

//    }

    @Value

    public static class GoodsCreateDto{

        private String barCode;

        private String operateUser;

        private Long operateTime;

        private String providerName;

    }

}

可以看到,抽象出来概念后,我们的商品核心动作只有两个:出库和入库。但是出入库类型根据业务动作,通过事件发出。在事件处理器中做相应的业务逻辑。

@Component

@Slf4j

public class GoodsEventProcessor {

    @Autowired

    private IStoreInOutRecordService storeInOutRecordService;

    @Autowired

    private IGoodsTraceLogService goodsTraceLogService;

    @Autowired

    private IGoodsService goodsService;

    @Autowired

    private IStoreSpecificationSummaryService storeSpecificationSummaryService;

    @Autowired

    private IProductSpecificationService specificationService;

    //handle GoodsCreateEvent -->分别监听这种方式适合后期快速切换到mq ===================================

    //零部件入库

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

    public void handleGoodsCreateEventForRecord(GoodsEvents.GoodsInEvent event){

        Goods goods = goodsService.findById(event.getGoods().getId()).get();

        ProductSpecification specification = specificationService.findById(goods.getSpecificationId()).get();

        CreateStoreRecordDto dto = CreateStoreRecordDto.builder()

                .batchId(goods.getBatchId())

                .directionType(InOutDirectionType.IN)

                .operationType(event.getInOutOperationType())

                .goodsId(goods.getId())

                .operateUser(goods.getOperateUser())

                .operateTime(event.getOperateTime())

                .providerName(goods.getProviderName())

                .price(specification.getPrice())

                .remark(event.getInOutOperationType().name())

                .build();

        storeInOutRecordService.addRecord(dto,goods.getSpecificationId(),goods.getStoreId());

    }

    //产品录入库后,保存入库日志

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

    public void handleGoodsCreateEventForLog(GoodsEvents.GoodsInEvent event){

        Goods goods = goodsService.findById(event.getGoods().getId()).get();

        Map<String,Object> map = Maps.newHashMap();

        map.put("provider",event.getProviderName());

        handleGoodsLog(goods, TraceType.IN_STORE,event.getInOutOperationType(),map);

    }

    //产品录入库后,添加库存信息

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

    public void handleGoodsCreateEventForSpecification(GoodsEvents.GoodsInEvent event){

        Goods goods = goodsService.findById(event.getGoods().getId()).get();

        handleSpecificationAdd(goods);

    }

    //handle GoodOutEvent ==========================================================================

    //商品被使用出库更新规格的数量

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

    public void handleGoodsOutEventForRecord(GoodsEvents.GoodsOutEvent event){

        Goods goods = goodsService.findById(event.getGoods().getId()).get();

        CreateStoreRecordDto dto = CreateStoreRecordDto.builder()

                .batchId(goods.getBatchId())

                .directionType(InOutDirectionType.OUT)

                .operationType(event.getInOutOperationType())

                .goodsId(goods.getId())

                .remark(event.getInOutOperationType().name())

                .build();

        //产品被组装使用后添加库存明细

        storeInOutRecordService.addRecord(dto,goods.getSpecificationId(),goods.getStoreId());

        Map<String,Object> map = Maps.newHashMap();

        handleGoodsLog(goods,TraceType.OUT_STORE,event.getInOutOperationType(),map);

        handleSpecificationSub(goods);

    }

    //公用方法模块++++++++++++++++++++++++++++++++++++

    private void handleSpecificationAdd(Goods goods){

        Optional<ProductSpecification> specification = specificationService.findById(goods.getSpecificationId());

        InitSpecificationDto dto = InitSpecificationDto.builder()

                .specificationId(goods.getSpecificationId())

                .specificationName(specification.get().getProductSpecName())

                .stock(1)

                .totalPrice(specification.get().getPrice())

                .storeId(goods.getStoreId())

                .build();

        Optional<StoreSpecificationSummary> summary = storeSpecificationSummaryService.findByStoreAndSpecificationId(goods.getStoreId(),goods.getSpecificationId());

        if(summary.isPresent()){

            storeSpecificationSummaryService.addStockAndPrice(summary.get().getId(),1,specification.get().getPrice());

        }else {

            storeSpecificationSummaryService.createStoreSpecificationSummary(dto);

        }

    }

    private void handleSpecificationSub(Goods goods){

        Optional<StoreSpecificationSummary> summary = storeSpecificationSummaryService.findByStoreAndSpecificationId(goods.getStoreId(),goods.getSpecificationId());

        Optional<ProductSpecification> specification = specificationService.findById(goods.getSpecificationId());

        storeSpecificationSummaryService.subStockAndPrice(summary.get().getId(),1,specification.get().getPrice());

    }

    private void handleGoodsLog(Goods goods, TraceType traceType, InOutOperationType type, Map<String, Object> map){

        GoodsTraceLogCreator creator = new GoodsTraceLogCreator();

        creator.barcode(goods.getBarCode());

        creator.flowWater(goods.getBatchId());

        creator.operationType(type);

        creator.goodsId(goods.getId());

        creator.storeId(goods.getStoreId());

        creator.logDesc(JSON.toJSONString(map));

        creator.traceType(traceType);

        goodsTraceLogService.createTraceLog(creator);

}

}

商品的设计完成后,针对每一次的出入库事件进行相应的业务操作,这样也就跟踪了商品的整个生命周期。针对出入库记录设计对象如下:

@Data

@Entity

@Table(name = "yd_store_in_out_record")

@ToString(callSuper = true)

@GenVO

public class StoreInOutRecord extends AbstractEntity {

    @Description(value = "操作类型")

    @Convert(converter = InOutOperationTypeConverter.class)

    @Column(name = "operation_type")

    private InOutOperationType operationType;

    @Description(value = "")

    @Column(name = "direction_type")

    @Convert(converter = InOutDirectionTypeConverter.class)

    private InOutDirectionType directionType;

    @Description(value = "总价")

    @Column(name = "total_price")

    private BigDecimal totalPrice;

    @Description(value = "数量")

    @Column(name = "count")

    private Integer count;

    @Description(value = "备注")

    @Column(name = "remark")

    private String remark;

    @Column(name = "spec_id")

    private Long specificationId;

    @Column(name = "water_flow_number")

    private Long waterFlowNumber;

    @Column(name = "goods_batch_id")

    private Long goodsBatchId;

    @Column(name = "store_id")

    private Long storeId;

    @Column(name = "operate_user")

    private String operateUser;

    @Column(name = "operate_time")

    private Long operateTime;

    @Column(name = "provider_name")

    private String providerName;

    //添加入库记录--> 注册入库事件

    public void addRecord(CreateStoreRecordDto dto, Long specificationId, Long storeId){

        setDirectionType(dto.getDirectionType());

        setOperationType(dto.getOperationType());

        setRemark(dto.getRemark());

        setStoreId(storeId);

        setSpecificationId(specificationId);

        setWaterFlowNumber(WaterFlowUtils.nextWaterFlow());

        setCount(1);

        setGoodsBatchId(dto.getBatchId());

        setTotalPrice(dto.getPrice());

        setProviderName(dto.getProviderName());

        setOperateTime(dto.getOperateTime());

        if(StringUtils.isNotEmpty(dto.getOperateUser())){

            setOperateUser(dto.getOperateUser());

        }else {

            setOperateUser(ErpConstants.SYSTEM);

        }

    }

    //更新价格和数量(根据批次)

    public void updatePriceAndCount(BigDecimal price, Integer count){

        setTotalPrice(NumberUtil.add(getTotalPrice(),price));

        setCount(getCount() + count);

    }

    public String toString() {

        return this.getClass().getSimpleName() + "-" + getId();

    }

    @Override

    public boolean equals(Object o) {

        if (this == o) return true;

        if (o == null || getClass() != o.getClass()) return false;

        if (!super.equals(o)) return false;

        StoreInOutRecord that = (StoreInOutRecord) o;

        return Objects.equals(totalPrice, that.totalPrice) &&

                Objects.equals(count, that.count) &&

                Objects.equals(remark, that.remark) &&

                Objects.equals(waterFlowNumber, that.waterFlowNumber) &&

                Objects.equals(goodsBatchId, that.goodsBatchId);

    }

    @Override

    public int hashCode() {

        return Objects.hash(super.hashCode(), totalPrice, count, remark, waterFlowNumber, goodsBatchId);

    }

}

因为我们监听的是每一个商品的出入库动作,所以我们可以监听商品的各种维度信息。包括商品的状态跟踪,商品的规格信息统计。我们都可以通过事件的方式来进行解耦。具体代码可以查看源代码。

项目如何启动

下载后首先 install gh-common 模块

install gh-code-gen 模块

compile gh-erp-service 模块

run ErpApplication 后访问 http://localhost:8888/swagger-ui.html#/ 即可。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,841评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,415评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,904评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,051评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,055评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,255评论 1 278
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,729评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,377评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,517评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,420评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,467评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,144评论 3 317
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,735评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,812评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,029评论 1 256
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,528评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,126评论 2 341

推荐阅读更多精彩内容