[DDD] ValueObject的一种设计落地及应用


前言

以前在InfoQ看到过这么一个讲座 Value-Objects-Dan-Bergh-Johnsson.

讲座的细节就不赘述了, 其中举例类似“电话号码”, “货币”在业务中的操作, 如果将这类有业务意义的字符串只是简单通过String/Integer等对象传递, 将丢失其业务意义, 最终编码, 测试都变得更繁琐. 同时程序员还需要在业务流程中时刻关心此类对象是否严格符合业务意义, 比如校验格式, 内容有效性等等. 实际工作看过来, 绝大多数人也都是这样做的.

如果使用ValueObject的设计思想, 设计一个包含“值”和其业务意义的对象, 例如“数量”一定非负之类的. 那么在实际使用中将使得校验, 编码, 测试, 甚至最基本的代码可读性都有明显提高.

本文介绍一种落地设计, 实现最常用的单一字符串值对象, 并参考Springboot环境, 实现接口自动化校验, DAO自动转换落库等等操作, 实现面向对象的编码.

Code Env: JDK21 + SpringBoot3+


一、ValueObject

值对象有两个主要特征:

  • 它们没有任何标识。
    • 没有唯一标识, 可以复用
  • 它们是不可变的。
    • Equals的比较是使用其“值”完成的

二、设计

本文仅对单一字符串值对象的设计作出说明, 因为此类值对象在实现接口, 或者落库时比较容易体会使用ValueObject的好处.

2.1 接口

仅分类, 因为不希望再手动调用校验, 这里就不设计校验的接口了

public interface ValueObject {}

定义单一值ValueObject

  • @JsonValue则提供了通过Jackson实现序列化的能力
    此时Jackson将直接序列化“值”而不是这个ValueObject对象
import com.fasterxml.jackson.annotation.JsonValue;

/**
 * @author hp
 */
public interface SingleValueObject<TYPE> extends ValueObject {

    @JsonValue
    TYPE value();
}

2.2 单一值ValueObject

实现ValueObject的基本特征

  • 值不可变, 在构造时需要提供值
  • equals, hashcode 通过其值完成, 而非对象本身.
  • @JsonAutoDetect 提供json序列化时获取非公共属性/方法的能力, 如果不提供公共getter, 则通过此注解获取值
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.hp.common.base.exception.NullValueObjectException;
import jakarta.annotation.PostConstruct;

import java.util.Objects;

/**
 * 配合jackson方便一些
 * <p>
 * 最好不要提供getter, 但是为了日志妥协一下
 *
 * @author hp
 * @see JsonAutoDetect;
 */
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PROTECTED_AND_PUBLIC)
public abstract class AbstractSingleValueObject<TYPE> implements SingleValueObject<TYPE> {

    protected final TYPE value;

    @Override
    public TYPE value() {
        return value;
    }

    protected AbstractSingleValueObject(TYPE value) throws NullValueObjectException {
        if (Objects.isNull(value)) {
            throw new NullValueObjectException();
        }
        this.value = value;
    }

    protected abstract void validate(TYPE value) throws IllegalArgumentException;

    @Override
    public String toString() {
        return this.value.toString();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        AbstractSingleValueObject<?> that = (AbstractSingleValueObject<?>) o;
        return Objects.equals(value, that.value);
    }

    @Override
    public int hashCode() {
        return Objects.hash(value);
    }
}

2.3 单一字符串ValueObject

空字符串在此场景下理解为无意义的输入, 此时考虑通过直接在构造期间抛出异常的方式中断构造过程, 并返回NULL, 以保证没有合法输入就不构造出值对象的目的.

import cn.hutool.core.util.StrUtil;
import com.hp.common.base.exception.NullValueObjectException;

/**
 * @author hp
 */
public abstract class AbstractStringBasedSingleValueObject extends AbstractSingleValueObject<String> {
    protected AbstractStringBasedSingleValueObject(String value) throws NullValueObjectException {
        super(value);
        if (StrUtil.isEmpty(value)) {
            throw new NullValueObjectException();
        }
        validate(value);
    }
}

三、实现

需要说明的是, 实现类不一定完全实现了此类值在现实生活中包含的所有方面, 可以根据业务场景做简单调整和取舍. 比如下文的电话号码示例就省略了区号的信息.

3.1 示例

  • 私有化构造, 仅通过静态方法创建对象
    • @JsonCreator提供了Jackson在反序列化时指定创建对象方法的入口, 这里指定使用静态方法
  • 当输入NULL或空字符串时, 业务上视为无意义的输入, 将不做实例化
  • 当输入非“空”字符串时, 在构造时将根据子类实现的规则进行校验, 并在校验失败时抛出IllegalArgumentException供捕获

3.1.1 PhoneNumber

import com.fasterxml.jackson.annotation.JsonCreator;
import com.google.common.base.Preconditions;
import com.hp.common.base.exception.NullValueObjectException;
import com.hp.common.base.valueobject.AbstractStringBasedSingleValueObject;
import com.hp.common.base.valueobject.Patterns;

import java.util.Optional;

/**
 * @author hp
 */
public final class PhoneNumber extends AbstractStringBasedSingleValueObject {

    private PhoneNumber(String phoneNumber) throws NullValueObjectException {
        super(phoneNumber);
    }

    @JsonCreator
    public static PhoneNumber of(String value) {
        try {
            return new PhoneNumber(value);
        } catch (NullValueObjectException ignore) {
            return null;
        }
    }

    @JsonCreator
    public static PhoneNumber of(Long value) {
        return Optional.ofNullable(value)
                .map(String::valueOf)
                .map(PhoneNumber::of)
                .orElse(null);
    }

    @Override
    public void validate(String value) throws IllegalArgumentException {
        Preconditions.checkArgument(Patterns.PHONE_PATTERN.asPredicate().test(value), "手机号码格式错误");
    }
}

3.1.2 SocialCreditCode

import com.fasterxml.jackson.annotation.JsonCreator;
import com.google.common.base.Preconditions;
import com.hp.common.base.exception.NullValueObjectException;
import com.hp.common.base.valueobject.AbstractStringBasedSingleValueObject;
import com.hp.common.base.valueobject.Patterns;

/**
 * @author hp
 */
public final class SocialCreditCode extends AbstractStringBasedSingleValueObject {

    private SocialCreditCode(String value) throws NullValueObjectException {
        super(value);
    }

    @JsonCreator
    public static SocialCreditCode of(String value){
        try {
            return new SocialCreditCode(value);
        }catch (NullValueObjectException ignore){
            return null;
        }
    }

    @Override
    public void validate(String value) throws IllegalArgumentException {
        Preconditions.checkArgument(Patterns.CREDIT_CODE_PATTERN.asPredicate().test(value), "统一社会信用代码格式错误");
    }
}

四、使用

4.1 异常处理

可以根据公司情况, 自定义参数校验失败的自定义异常. 这里用最简单的IllegalArgumentException作示例

package com.hp.valueobject.exception;

import com.hp.common.base.model.Returns;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * @author hp
 */
@Slf4j
@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(IllegalArgumentException.class)
    public Returns<?> handleIllegalArgumentsException(IllegalArgumentException e) {
        log.error("请求参数错误", e);
        return Returns.fail().message(e.getMessage());
    }
}

4.2 Json 反/序列化

最常见场景之一, RESTful接口参数的Json序列化场景

4.2.1 请求体

package com.hp.valueobject.request;

import com.hp.common.base.model.Request;
import com.hp.common.base.valueobject.contact.PhoneNumber;
import com.hp.common.base.valueobject.socialcreditcode.SocialCreditCode;
import lombok.Data;

/**
 * @author hp
 */
@Data
public class ValueObjectPostRequest implements Request {

    private PhoneNumber phone;
 
    private SocialCreditCode socialCreditCode;
    
}

4.2.2 HTTP接口

package com.hp.valueobject.controller;

import com.hp.common.base.model.Returns;
import com.hp.common.base.valueobject.contact.PhoneNumber;
import com.hp.valueobject.request.ValueObjectPostRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

/**
 * @author hp
 */
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("valueobject")
public class ValueObjectController {

    @PostMapping("postRequest")
    public Returns<?> postRequest(@RequestBody ValueObjectPostRequest request) {
        return Returns.success().data(request);
    }

    @GetMapping("getRequest")
    public Returns<?> getRequest(@RequestParam PhoneNumber phone) {
        return Returns.success().data(phone);
    }
}

4.2.3 用例

用例格式为Idea http client.

POST Request, phone正确, 信用代码空字符串无意义

# Request
POST http://localhost:9988/valueobject/postRequest
Content-Type: application/json
Content-Length: 54
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.9)
Accept-Encoding: br,deflate,gzip,x-gzip

{
  "phone": "18123123123",
  "socialCreditCode": ""
} 

# Response
POST http://localhost:9988/valueobject/postRequest

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 22 Mar 2024 06:17:13 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "code": 200,
  "message": "操作成功",
  "data": {
    "phone": "18123123123",
    "socialCreditCode": null
  }
}
Response code: 200; Time: 37ms (37 ms); Content length: 219 bytes (219 B)

POST phone 参数错误 10 位

POST http://localhost:9988/valueobject/postRequest
Content-Type: application/json
Content-Length: 27
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.9)
Accept-Encoding: br,deflate,gzip,x-gzip

{
  "phone": "1812323123"
}
###

POST http://localhost:9988/valueobject/postRequest

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 22 Mar 2024 06:12:38 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "code": 500,
  "message": "手机号码格式错误",
  "data": null
}

Response code: 200; Time: 118ms (118 ms); Content length: 45 bytes (45 B)

GET phone格式正确

GET http://localhost:9988/valueobject/getRequest?phone=18123123123

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 22 Mar 2024 06:21:27 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "code": 200,
  "message": "操作成功",
  "data": "18123123123"
}
Response file saved.
> 2024-03-22T142127.200.json

Response code: 200; Time: 10ms (10 ms); Content length: 50 bytes (50 B)

GET phone格式错误

GET http://localhost:9988/valueobject/getRequest?phone=1812312313

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 22 Mar 2024 06:22:23 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "code": 500,
  "message": "手机号码格式错误",
  "data": null
}
Response file saved.
> 2024-03-22T142223.200.json

Response code: 200; Time: 25ms (25 ms); Content length: 45 bytes (45 B)

4.3 JPA/MyBatis

4.3.1 Converter或TypeHandler

PhoneNumber示例

JPA converter

package com.hp.jpa.convertor;

import com.hp.common.base.valueobject.AbstractSingleValueObject;
import com.hp.common.base.valueobject.AbstractStringBasedSingleValueObject;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Optional;

@Converter
public abstract class AbstractStringBasedSingleValueObjectConverter<T extends AbstractStringBasedSingleValueObject> implements AttributeConverter<T, String> {
    public AbstractStringBasedSingleValueObjectConverter() {
    }

    public String convertToDatabaseColumn(T attribute) {
        return (String)Optional.ofNullable(attribute).map(AbstractSingleValueObject::value).orElse("");
    }
}

package com.hp.valueobject.converter;

import com.hp.common.base.valueobject.contact.PhoneNumber;
import com.hp.jpa.convertor.AbstractStringBasedSingleValueObjectConverter;
import jakarta.persistence.Converter;

/**
 * @author hp
 */
@Converter
public class PhoneNumberJPAConverter extends AbstractStringBasedSingleValueObjectConverter<PhoneNumber> {
    @Override
    public PhoneNumber convertToEntityAttribute(String dbData) {
        return PhoneNumber.of(dbData);
    }
}

Mybatis-plus typeHandler

package com.hp.mybatisplus.convertor;

import com.hp.common.base.valueobject.AbstractStringBasedSingleValueObject;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import org.apache.ibatis.type.JdbcType;

public abstract class AbstractStringBasedSingleValueObjectConverter<T extends AbstractStringBasedSingleValueObject> implements TypeHandlerCodeGenAdapter<T, String> {
    public AbstractStringBasedSingleValueObjectConverter() {
    }

    public void setParameter(PreparedStatement ps, int i, T t, JdbcType jdbcType) throws SQLException {
        ps.setString(i, (String)t.value());
    }
}
package com.hp.valueobject.converter;

import com.hp.common.base.valueobject.contact.PhoneNumber;
import com.hp.mybatisplus.convertor.AbstractStringBasedSingleValueObjectConverter;

import java.sql.CallableStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * @author hp
 */
public class PhoneNumberMybatisTypeHandler extends AbstractStringBasedSingleValueObjectConverter<PhoneNumber> {
    @Override
    public PhoneNumber getResult(ResultSet rs, String columnName) throws SQLException {
        return PhoneNumber.of(rs.getString(columnName));
    }

    @Override
    public PhoneNumber getResult(ResultSet rs, int columnIndex) throws SQLException {
        return PhoneNumber.of(rs.getString(columnIndex));
    }

    @Override
    public PhoneNumber getResult(CallableStatement cs, int columnIndex) throws SQLException {
        return PhoneNumber.of(cs.getString(columnIndex));
    }
}

4.3.2 Entity

@Entity
@Table(name = "unified_social_credit_code")
@Getter
@Setter
public class UnifiedSocialCreditCode {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Convert(converter = PhoneNumberConverter.class)
    private PhoneNumber username;

    @Convert(converter = SocialCreditCodeConverter.class)
    private SocialCreditCode socialCreditCode;

4.3.3 Repository

直接传递ValueObject类型参数即可, QueryDSL也可以正常使用
注: MyBatis省略, 其低版本无法在自定义查询中自动通过typeHandler提取值, 需要手动 ValueObject.value();

@Repository
public interface JpaBasedUnifiedSocialCreditCodeDao extends BaseRepository<UnifiedSocialCreditCode, Long> {
    List<UnifiedSocialCreditCode> findAllBySocialCreditCodeIn(Collection<SocialCreditCode> codes);
}

4.3.4 用例

JPA

@Test
public void givenUSCC_whenQueryInDB_thenReturnsNonnull() {
    // given
    final String unifiedSocialCreditCode = "91510115MABRCTYM2W";
    final SocialCreditCode socialCreditCode = SocialCreditCode.of(unifiedSocialCreditCode);

    // when
    final List<UnifiedSocialCreditCode> list = unifiedSocialCreditCodeRepository.findAllBySocialCreditCode(Lists.newArrayList(socialCreditCode));

    // then
    assertThat(list).isNotEmpty().size().isGreaterThanOrEqualTo(1);
    final UnifiedSocialCreditCode first = list.getFirst();
    assertThat(first.getSocialCreditCode()).isEqualTo(socialCreditCode);
    assertThat(first.getUsername()).isNotNull();
}

4.4 CACHE

缓存场景, 这里主要是针对服务内缓存的说明, 例如使用Redis等中间件时, 都需要序列化, 此时使用jackson序列化即可

4.4.1 LocalBasedCache

例如使用Map作为容器的场景, 因为在AbstractSingleValueObject中已经重写了hashCode和equals, 使得ValueObject可以直接作为键完成存储和比较

@Slf4j
@Component
public class LocalBasedCache implements USCCCache {

    private final static Map<SocialCreditCode, List<UserCacheModel>> CACHE = Maps.newConcurrentMap();

    @Override
    public boolean exist(SocialCreditCode socialCreditCode) {
        return CACHE.containsKey(socialCreditCode);
    }

    @Override
    public void put(SocialCreditCode socialCreditCode, UserCacheModel model) {
        CACHE.compute(socialCreditCode, (key, value) -> {
            if (Objects.isNull(value)) {
                return Lists.newArrayList(model);
            } else {
                value.add(model);
                return value;
            }
        });
    }

    @Override
    public List<UserCacheModel> get(SocialCreditCode socialCreditCode) {
        return CACHE.getOrDefault(socialCreditCode, Collections.emptyList());
    }

    @Override
    public void remove(SocialCreditCode socialCreditCode) {
        CACHE.remove(socialCreditCode);
    }
}

4.4.2 用例

 @Test
 public void givenSocialCreditCode_whenCallPutAndExist_thenSuccess() {
     // given
     final LocalBasedCache cache = new LocalBasedCache();
     final SocialCreditCode socialCreditCode = SocialCreditCode.of("915101007130091284");
     final SocialCreditCode socialCreditCode2 = SocialCreditCode.of("915101007130091284");
     final SocialCreditCode socialCreditCode3 = SocialCreditCode.of("915101007130091283");

     // when
     cache.put(socialCreditCode, new UserCacheModel(1L,"1"));

     // then
     assertThat(cache.exist(socialCreditCode)).isTrue();
     assertThat(cache.exist(socialCreditCode2)).isTrue();
     assertThat(cache.exist(socialCreditCode3)).isFalse();
 }

测试结果
在这里插入图片描述

最近更新

  1. TCP协议是安全的吗?

    2024-03-29 09:06:03       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-03-29 09:06:03       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-03-29 09:06:03       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-03-29 09:06:03       18 阅读

热门阅读

  1. HTTP

    HTTP

    2024-03-29 09:06:03      15 阅读
  2. 云硬盘扩容后将空间增加到原有分区的解决方案

    2024-03-29 09:06:03       18 阅读
  3. 【Spring】27 UrlResource:访问各种资源的通用工具

    2024-03-29 09:06:03       18 阅读
  4. 如何查看自己服务器的SSL证书?

    2024-03-29 09:06:03       18 阅读
  5. Vue.js:构建高效且灵活的Web应用的利器

    2024-03-29 09:06:03       18 阅读
  6. 显示器分辨率

    2024-03-29 09:06:03       19 阅读
  7. 「PHP系列」PHP echo/print语句、数据类型详解

    2024-03-29 09:06:03       22 阅读