目录
- 前言
- 一、ValueObject
- 二、设计
- 2.1 接口
- 2.2 单一值ValueObject
- 2.3 单一字符串ValueObject
- 三、实现
- 3.1 示例
- 3.1.1 PhoneNumber
- 3.1.2 SocialCreditCode
- 四、使用
- 4.1 异常处理
- 4.2 Json 反/序列化
- 4.2.1 请求体
- 4.2.2 HTTP接口
- 4.2.3 用例
- 4.3 JPA/MyBatis
- 4.3.1 Converter或TypeHandler
- 4.3.2 Entity
- 4.3.3 Repository
- 4.3.4 用例
- 4.4 CACHE
- 4.4.1 LocalBasedCache
- 4.4.2 用例
前言
以前在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 {@JsonValueTYPE 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;@Overridepublic 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;@Overridepublic String toString() {return this.value.toString();}@Overridepublic 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);}@Overridepublic 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);}@JsonCreatorpublic static PhoneNumber of(String value) {try {return new PhoneNumber(value);} catch (NullValueObjectException ignore) {return null;}}@JsonCreatorpublic static PhoneNumber of(Long value) {return Optional.ofNullable(value).map(String::valueOf).map(PhoneNumber::of).orElse(null);}@Overridepublic 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);}@JsonCreatorpublic static SocialCreditCode of(String value){try {return new SocialCreditCode(value);}catch (NullValueObjectException ignore){return null;}}@Overridepublic 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/postRequestHTTP/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/postRequestHTTP/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=18123123123HTTP/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.jsonResponse code: 200; Time: 10ms (10 ms); Content length: 50 bytes (50 B)
GET phone格式错误
GET http://localhost:9988/valueobject/getRequest?phone=1812312313HTTP/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.jsonResponse 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> {@Overridepublic 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> {@Overridepublic PhoneNumber getResult(ResultSet rs, String columnName) throws SQLException {return PhoneNumber.of(rs.getString(columnName));}@Overridepublic PhoneNumber getResult(ResultSet rs, int columnIndex) throws SQLException {return PhoneNumber.of(rs.getString(columnIndex));}@Overridepublic 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() {// givenfinal String unifiedSocialCreditCode = "91510115MABRCTYM2W";final SocialCreditCode socialCreditCode = SocialCreditCode.of(unifiedSocialCreditCode);// whenfinal List<UnifiedSocialCreditCode> list = unifiedSocialCreditCodeRepository.findAllBySocialCreditCode(Lists.newArrayList(socialCreditCode));// thenassertThat(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();@Overridepublic boolean exist(SocialCreditCode socialCreditCode) {return CACHE.containsKey(socialCreditCode);}@Overridepublic 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;}});}@Overridepublic List<UserCacheModel> get(SocialCreditCode socialCreditCode) {return CACHE.getOrDefault(socialCreditCode, Collections.emptyList());}@Overridepublic void remove(SocialCreditCode socialCreditCode) {CACHE.remove(socialCreditCode);}
}
4.4.2 用例
@Testpublic void givenSocialCreditCode_whenCallPutAndExist_thenSuccess() {// givenfinal LocalBasedCache cache = new LocalBasedCache();final SocialCreditCode socialCreditCode = SocialCreditCode.of("915101007130091284");final SocialCreditCode socialCreditCode2 = SocialCreditCode.of("915101007130091284");final SocialCreditCode socialCreditCode3 = SocialCreditCode.of("915101007130091283");// whencache.put(socialCreditCode, new UserCacheModel(1L,"1"));// thenassertThat(cache.exist(socialCreditCode)).isTrue();assertThat(cache.exist(socialCreditCode2)).isTrue();assertThat(cache.exist(socialCreditCode3)).isFalse();}
测试结果