免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Java项目实战:Utility工具类设计原则与核心实现详解

Java项目实战:Utility工具类设计原则与核心实现详解 1. 项目概述与核心价值最近在带团队做一个小型的Java房屋出租系统从需求分析到数据库设计再到核心业务逻辑的实现整个过程下来感触最深的一点就是业务代码写起来不难难的是如何让代码保持整洁、健壮和易于维护。尤其是在处理那些看似琐碎但又无处不在的“脏活累活”时比如用户输入的校验、日期的格式化、字符串的判空与拼接、集合的判空与遍历等等。如果把这些逻辑都散落在各个业务方法里代码很快就会变得臃肿不堪重复代码满天飞后期维护简直是噩梦。这时候一个设计良好的Utility工具类就显得至关重要了。它就像我们工具箱里的瑞士军刀把那些通用的、与具体业务无关的公共方法集中管理起来。这次在项目中我们参考了韩顺平老师在课程中强调的“高内聚、低耦合”思想并结合实际业务痛点封装了一套自己的Utility工具类。这套工具类不仅极大地提升了开发效率减少了重复代码更重要的是它让我们的业务逻辑层变得异常清晰所有的基础操作都交给了“专业的人”去做。接下来我就把这套在“Java房屋出租项目”实战中沉淀下来的Utility工具类设计思路、核心实现以及踩过的坑毫无保留地分享给大家。2. 工具类整体设计与架构思想2.1 为什么需要独立的工具类在房屋出租系统中我们频繁遇到一些通用场景。比如前端传过来的租金金额是字符串“1500.00”我们需要把它转换成BigDecimal进行精确计算再比如我们要校验用户输入的手机号格式是否正确或者我们需要将一个ListHouse按照月租金进行排序。如果每次都在Service或Controller里写try-catch或者一堆if-else代码的坏味道会非常重。核心设计思想遵循单一职责原则。工具类的职责就是提供通用的、静态的、无状态的方法。它不应该依赖任何Spring容器管理的Bean也不应该持有任何业务相关的状态。这样设计的好处是高复用性任何地方需要直接类名.方法()调用无需实例化。无副作用纯函数式设计同样的输入永远得到同样的输出线程安全。易于测试因为不依赖外部环境工具类的方法可以单独进行单元测试非常方便。2.2 工具类的分类与包结构规划我们并没有把所有工具方法都塞进一个巨大的Utils类里而是根据功能进行了垂直划分。这样避免了“上帝类”也使得职责更清晰。我们的工具类包结构大致如下com.fangdong.common ├── util │ ├── DateUtil.java // 日期时间处理 │ ├── StringUtil.java // 字符串处理 │ ├── ValidateUtil.java // 数据校验 │ ├── MoneyUtil.java // 金额计算BigDecimal相关 │ └── CollectionUtil.java // 集合操作 └── constant └── RegexPattern.java // 存放常用正则表达式常量这样划分的理由当开发者需要处理字符串时他会本能地去StringUtil里找方法需要校验数据时会去ValidateUtil。清晰的命名和分类能极大降低团队的理解和协作成本。RegexPattern类集中管理所有正则表达式避免了魔法数字散落各处修改时只需改一处。3. 核心工具类详解与实现要点3.1 字符串处理工具类 (StringUtil)在房屋出租系统中字符串处理是最高频的操作之一。房源描述、租客姓名、联系方式等都是字符串。核心方法实现public class StringUtil { private StringUtil() {} // 私有构造防止实例化 /** * 判断字符串是否为空null或空字符串 * 比 str null || str.isEmpty() 更直观 */ public static boolean isEmpty(CharSequence str) { return str null || str.length() 0; } /** * 判断字符串是否非空 */ public static boolean isNotEmpty(CharSequence str) { return !isEmpty(str); } /** * 判断字符串是否为空白null、空字符串、纯空格 * 用于校验用户输入比如姓名不能只输入空格 */ public static boolean isBlank(CharSequence str) { if (isEmpty(str)) { return true; } int strLen str.length(); for (int i 0; i strLen; i) { if (!Character.isWhitespace(str.charAt(i))) { return false; } } return true; } public static boolean isNotBlank(CharSequence str) { return !isBlank(str); } /** * 去除字符串首尾空白字符如果为null则返回null * 注意这里没有直接使用String.trim()因为trim()无法去除全角空格 * 在实际项目中我们引入了Apache Commons Lang3的StringUtils其strip方法更强大。 * 此处为演示实现一个简单版本。 */ public static String trim(String str) { return str null ? null : str.trim(); } /** * 安全地截断字符串避免IndexOutOfBoundsException * 常用于前端显示过长文本如房源标题只显示前20个字符... */ public static String safeSubstring(String str, int start, int end) { if (str null) { return null; } int strLen str.length(); if (start strLen) { return ; } if (end strLen) { end strLen; } if (start end) { return ; } return str.substring(start, end); } }实操心得isBlank和isEmpty要区分使用。在业务校验中比如“租客姓名”字段我们通常用isBlank因为只输入空格是没有意义的。对于更复杂的字符串处理如去除所有空白、比较忽略大小写等强烈建议直接引入org.apache.commons.lang3.StringUtils。自己造轮子容易考虑不周而Apache Commons Lang是久经考验的工具库。我们在项目中就是封装了它的一些方法并统一了异常处理逻辑。3.2 数据校验工具类 (ValidateUtil)数据校验是保证系统健壮性的第一道防线。前端校验是为了用户体验后端校验是为了数据安全。核心方法实现public class ValidateUtil { /** * 校验手机号格式简单版实际项目应根据最新号段更新正则 */ public static boolean isMobile(String mobile) { if (isBlank(mobile)) { return false; } String pattern RegexPattern.MOBILE_PATTERN; // ^1[3-9]\\d{9}$ return mobile.matches(pattern); } /** * 校验邮箱格式 */ public static boolean isEmail(String email) { if (isBlank(email)) { return false; } // 这是一个相对宽松的邮箱正则实际项目可使用更严谨的 String pattern RegexPattern.EMAIL_PATTERN; // ^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\\.[a-zA-Z]{2,}$ return email.matches(pattern); } /** * 校验身份证号格式简单版15位或18位 */ public static boolean isIdCard(String idCard) { if (isBlank(idCard)) { return false; } String pattern RegexPattern.ID_CARD_PATTERN; // (^\\d{15}$)|(^\\d{17}([0-9]|X)$) return idCard.matches(pattern); } /** * 断言对象不为空为空则抛出指定异常信息 * 用于快速失败Fail-Fast在Service层入口进行参数校验非常有用 */ public static void notNull(Object obj, String message) { if (obj null) { throw new IllegalArgumentException(message); } } /** * 断言字符串不为空白 */ public static void notBlank(String str, String message) { if (isBlank(str)) { throw new IllegalArgumentException(message); } } }对应的正则表达式常量类public class RegexPattern { public static final String MOBILE_PATTERN ^1[3-9]\\d{9}$; public static final String EMAIL_PATTERN ^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\\.[a-zA-Z]{2,}$; public static final String ID_CARD_PATTERN (^\\d{15}$)|(^\\d{17}([0-9]|X)$); // 可以继续添加其他正则如中文姓名、车牌号等 }注意事项正则表达式要维护手机号号段、身份证校验规则最后一位校验码可能会变化正则表达式需要定期审查和更新。我们将所有正则集中管理就是为了便于维护。校验粒度工具类只做格式校验。更复杂的业务校验如“手机号是否已注册”这属于业务规则应该放在Service层。异常选择ValidateUtil中我们抛出的IllegalArgumentException这是一个受检异常调用方必须处理。在Spring项目中我们通常会结合Validated和BindingResult进行更优雅的参数校验但工具类提供的这种基础校验在非Controller场景如工具方法内部依然非常有用。3.3 金额计算工具类 (MoneyUtil)金融相关系统最忌讳的就是使用double或float进行金额计算因为会有精度丢失问题。Java提供了BigDecimal来解决但它的API用起来比较繁琐。MoneyUtil就是对BigDecimal常用操作的封装。核心方法实现public class MoneyUtil { // 默认除法运算精度小数点后位数和舍入模式 private static final int DEFAULT_DIV_SCALE 2; private static final RoundingMode DEFAULT_ROUNDING_MODE RoundingMode.HALF_UP; // 四舍五入 /** * 将字符串安全地转换为BigDecimal转换失败返回BigDecimal.ZERO * 用于解析前端传来的金额字符串 */ public static BigDecimal toBigDecimal(String amountStr) { if (StringUtil.isBlank(amountStr)) { return BigDecimal.ZERO; } try { // 去除可能存在的货币符号和千位分隔符 String cleanStr amountStr.replaceAll([$,], ).trim(); return new BigDecimal(cleanStr); } catch (NumberFormatException e) { // 这里可以记录日志实际项目中可能希望抛出业务异常 return BigDecimal.ZERO; } } /** * 加法运算 (a b) */ public static BigDecimal add(BigDecimal a, BigDecimal b) { a nullToZero(a); b nullToZero(b); return a.add(b); } /** * 减法运算 (a - b) */ public static BigDecimal subtract(BigDecimal a, BigDecimal b) { a nullToZero(a); b nullToZero(b); return a.subtract(b); } /** * 乘法运算 (a * b) */ public static BigDecimal multiply(BigDecimal a, BigDecimal b) { a nullToZero(a); b nullToZero(b); return a.multiply(b); } /** * 除法运算 (a / b)使用默认精度和舍入模式 * 特别注意除数不能为0 */ public static BigDecimal divide(BigDecimal a, BigDecimal b) { return divide(a, b, DEFAULT_DIV_SCALE, DEFAULT_ROUNDING_MODE); } public static BigDecimal divide(BigDecimal a, BigDecimal b, int scale, RoundingMode roundingMode) { notNull(b, 除数不能为null); if (b.compareTo(BigDecimal.ZERO) 0) { throw new ArithmeticException(除数不能为0); } a nullToZero(a); return a.divide(b, scale, roundingMode); } /** * 比较两个金额是否相等比较值而非对象引用 */ public static boolean equals(BigDecimal a, BigDecimal b) { if (a null b null) return true; if (a null || b null) return false; return a.compareTo(b) 0; } /** * 格式化金额为字符串保留两位小数用于显示 */ public static String format(BigDecimal amount) { if (amount null) { return 0.00; } // 设置小数位数和舍入模式 amount amount.setScale(DEFAULT_DIV_SCALE, DEFAULT_ROUNDING_MODE); return amount.toString(); } private static BigDecimal nullToZero(BigDecimal decimal) { return decimal null ? BigDecimal.ZERO : decimal; } private static void notNull(Object obj, String msg) { if (obj null) { throw new IllegalArgumentException(msg); } } }踩坑记录与核心要点构造BigDecimal绝对不要使用new BigDecimal(double)因为double本身就不精确。一定要使用new BigDecimal(String)或BigDecimal.valueOf(double)。我们的toBigDecimal方法内部用的就是字符串构造器。除法的精度和舍入BigDecimal.divide(BigDecimal)在除不尽时会抛出ArithmeticException。必须使用重载方法指定精度scale和舍入模式RoundingMode。我们封装的方法默认提供了这个安全操作。比较操作判断两个BigDecimal是否相等不要用equals()因为equals()还会比较精度scale1.0和1.00用equals()比较是false。一定要用compareTo()方法返回0表示值相等。Null处理工具类要健壮必须处理好null值。我们约定在计算中将null视为BigDecimal.ZERO但在除法等特殊操作前会显式检查。3.4 日期时间工具类 (DateUtil)租房业务涉及签约日期、租期开始/结束日期、账单生成日期等日期处理必须准确无误。Java 8以后的java.time包是首选但如果项目用的是旧版JDK或遗留代码中有大量java.util.Date工具类需要做好兼容。核心方法实现基于Java 8public class DateUtil { // 常用格式化模式 public static final String DATE_PATTERN yyyy-MM-dd; public static final String DATE_TIME_PATTERN yyyy-MM-dd HH:mm:ss; public static final String TIME_PATTERN HH:mm:ss; private static final DateTimeFormatter DATE_FORMATTER DateTimeFormatter.ofPattern(DATE_PATTERN); private static final DateTimeFormatter DATE_TIME_FORMATTER DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); /** * 获取当前日期字符串 (yyyy-MM-dd) */ public static String getCurrentDateStr() { return LocalDate.now().format(DATE_FORMATTER); } /** * 获取当前日期时间字符串 (yyyy-MM-dd HH:mm:ss) */ public static String getCurrentDateTimeStr() { return LocalDateTime.now().format(DATE_TIME_FORMATTER); } /** * 将日期字符串解析为LocalDate */ public static LocalDate parseDate(String dateStr) { if (StringUtil.isBlank(dateStr)) { return null; } try { return LocalDate.parse(dateStr, DATE_FORMATTER); } catch (DateTimeParseException e) { throw new IllegalArgumentException(日期格式错误应为: DATE_PATTERN , 输入: dateStr, e); } } /** * 将日期时间字符串解析为LocalDateTime */ public static LocalDateTime parseDateTime(String dateTimeStr) { if (StringUtil.isBlank(dateTimeStr)) { return null; } try { return LocalDateTime.parse(dateTimeStr, DATE_TIME_FORMATTER); } catch (DateTimeParseException e) { throw new IllegalArgumentException(日期时间格式错误应为: DATE_TIME_PATTERN , 输入: dateTimeStr, e); } } /** * 计算两个日期之间的天数差 (endDate - startDate) * 用于计算租期天数 */ public static long daysBetween(LocalDate startDate, LocalDate endDate) { notNull(startDate, 开始日期不能为空); notNull(endDate, 结束日期不能为空); return ChronoUnit.DAYS.between(startDate, endDate); } /** * 为指定日期添加月份处理租期续约场景 */ public static LocalDate addMonths(LocalDate date, int monthsToAdd) { notNull(date, 日期不能为空); return date.plusMonths(monthsToAdd); } /** * 判断一个日期是否在某个区间内包含开始不包含结束 * 用于判断某天是否在租期内 */ public static boolean isDateInRange(LocalDate date, LocalDate rangeStart, LocalDate rangeEnd) { notNull(date, 待判断日期不能为空); // rangeEnd为null表示无结束限制 boolean afterOrOnStart date.isAfter(rangeStart) || date.isEqual(rangeStart); boolean beforeEnd rangeEnd null || date.isBefore(rangeEnd); return afterOrOnStart beforeEnd; } // 兼容旧版Date的方法如果项目需要 public static Date toUtilDate(LocalDateTime localDateTime) { return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); } public static LocalDateTime toLocalDateTime(Date date) { return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); } }经验分享时区问题在Web应用中前后端传递日期时间时最好统一使用ISO-8601格式的字符串如yyyy-MM-ddTHH:mm:ss.SSSZ或时间戳。如果使用LocalDateTime要明确它是不带时区的存储和传输时需要约定好隐含的时区通常是系统默认时区。对于跨时区应用应使用Instant或ZonedDateTime。格式化器线程安全DateTimeFormatter是线程安全的可以定义为static final常量重复使用避免重复创建的开销。旧代码兼容如果项目中存在大量java.util.Date和java.sql.Date编写兼容方法很有必要。但在新开发的模块中应坚持使用java.time。3.5 集合操作工具类 (CollectionUtil)在处理房源列表、租客列表时集合的判空、过滤、转换操作非常频繁。核心方法实现public class CollectionUtil { /** * 判断集合是否为空null或空集合 */ public static boolean isEmpty(Collection? coll) { return coll null || coll.isEmpty(); } public static boolean isNotEmpty(Collection? coll) { return !isEmpty(coll); } /** * 判断Map是否为空 */ public static boolean isEmpty(Map?, ? map) { return map null || map.isEmpty(); } public static boolean isNotEmpty(Map?, ? map) { return !isEmpty(map); } /** * 安全地获取集合的第一个元素如果为空则返回null */ public static T T getFirst(ListT list) { if (isEmpty(list)) { return null; } return list.get(0); } /** * 将集合转换为字符串用指定分隔符连接 * 例如将房源ID列表转换为“1,2,3”用于SQL查询 */ public static String join(Collection? coll, String separator) { if (isEmpty(coll)) { return ; } StringBuilder sb new StringBuilder(); for (Object obj : coll) { if (obj ! null) { if (sb.length() 0) { sb.append(separator); } sb.append(obj.toString()); } } return sb.toString(); } /** * 使用Java 8 Stream将一个对象列表转换为另一个对象列表 * 例如将ListHouse转换为ListHouseVO */ public static T, R ListR mapToList(CollectionT collection, FunctionT, R mapper) { if (isEmpty(collection)) { return new ArrayList(); } return collection.stream().map(mapper).collect(Collectors.toList()); } }使用建议对于更复杂的集合操作如求交集、并集、差集或者对Map的复杂操作直接使用Guava的com.google.common.collect.Iterables/Lists/Maps或Apache Commons Collections是更好的选择。我们的CollectionUtil只封装最基础、最通用的操作。mapToList方法展示了Java 8函数式编程的优雅它让集合转换代码变得非常简洁。这在DTO、VO、DO之间的转换场景中应用极广。4. 工具类在房屋出租项目中的实战应用光说不练假把式我们看看这些工具类在具体业务场景中是如何被调用的。场景一新增房源信息校验在HouseService.addHouse(HouseDTO houseDTO)方法中我们需要对传入的数据进行校验。public void addHouse(HouseDTO houseDTO) { // 1. 基础非空校验 ValidateUtil.notNull(houseDTO, 房源信息不能为空); ValidateUtil.notBlank(houseDTO.getTitle(), 房源标题不能为空); ValidateUtil.notBlank(houseDTO.getAddress(), 房源地址不能为空); // 2. 业务格式校验 if (!ValidateUtil.isMobile(houseDTO.getLandlordPhone())) { throw new BusinessException(房东手机号格式不正确); } // 租金校验必须大于0 BigDecimal monthlyRent MoneyUtil.toBigDecimal(houseDTO.getMonthlyRentStr()); if (monthlyRent.compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(月租金必须大于0); } houseDTO.setMonthlyRent(monthlyRent); // 转换后设置回DTO // 3. 日期校验 LocalDate availableDate DateUtil.parseDate(houseDTO.getAvailableDateStr()); if (availableDate null || availableDate.isBefore(LocalDate.now())) { throw new BusinessException(可入住日期格式错误或已过期); } houseDTO.setAvailableDate(availableDate); // 4. 后续业务逻辑... // convert DTO to Entity, save to database... }场景二计算租期内总租金在生成合同时需要根据月租金和租期月数计算总租金。public BigDecimal calculateTotalRent(BigDecimal monthlyRent, int leaseTermMonths) { if (monthlyRent null || leaseTermMonths 0) { return BigDecimal.ZERO; } // 使用MoneyUtil进行乘法计算安全且意图明确 return MoneyUtil.multiply(monthlyRent, new BigDecimal(leaseTermMonths)); }场景三批量处理房源列表在导出房源数据时需要处理一个房源列表。public ListHouseVO exportHouses(ListHouse houseList) { // 1. 使用CollectionUtil判空避免NPE if (CollectionUtil.isEmpty(houseList)) { return Collections.emptyList(); } // 2. 使用Stream和工具方法进行转换和过滤 ListHouseVO voList houseList.stream() .filter(house - house.getStatus() 1) // 只导出已上架的房源 .map(this::convertToVO) // 转换方法 .collect(Collectors.toList()); // 3. 对VO列表中的金额进行统一格式化用于前端显示 voList.forEach(vo - vo.setRentStr(MoneyUtil.format(vo.getMonthlyRent()))); return voList; }5. 常见问题、排查技巧与进阶思考5.1 工具类设计中的典型问题工具类是否应该被实例化问题工具类只有静态方法但有人可能不小心实例化它。解决为工具类添加一个私有构造器。这是标准做法。private StringUtil() {}方法过多工具类变得臃肿怎么办问题一个CommonUtil里有处理字符串、日期、加密、网络等各种方法。解决严格按照功能进行垂直拆分如StringUtil、DateUtil、EncryptUtil等。如果某个工具类确实非常庞大如DateUtil包含上百个方法可以考虑进一步按子功能拆分如DateCalcUtil、DateFormatUtil。依赖了Spring的Bean怎么办问题在工具类中想用Autowired注入一个RedisTemplate来缓存数据。解决这违背了工具类的设计初衷。工具类应该是无状态的、不依赖容器的。如果确实需要可以考虑两种方案方案A将这类功能重构为一个Spring管理的Service或Component而不是工具类。方案B使用静态成员变量并通过PostConstruct或在配置类中手动初始化不推荐增加了耦合度。首选方案A。性能考虑频繁创建对象问题在DateUtil的format方法里每次都new SimpleDateFormatSimpleDateFormat是非线程安全的且创建开销大。解决对于线程不安全的类如SimpleDateFormat可以使用ThreadLocal为每个线程创建独立的实例。对于线程安全的类如Java 8的DateTimeFormatter定义为static final常量。5.2 工具类的测试工具类是基础组件必须要有完善的单元测试。使用JUnit AssertJ可以写出非常清晰易懂的测试代码。class StringUtilTest { Test void testIsBlank() { assertThat(StringUtil.isBlank(null)).isTrue(); assertThat(StringUtil.isBlank()).isTrue(); assertThat(StringUtil.isBlank( )).isTrue(); assertThat(StringUtil.isBlank(abc)).isFalse(); assertThat(StringUtil.isBlank( abc )).isFalse(); } Test void testSafeSubstring() { assertThat(StringUtil.safeSubstring(hello world, 0, 5)).isEqualTo(hello); assertThat(StringUtil.safeSubstring(hello, 0, 10)).isEqualTo(hello); // 越界处理 assertThat(StringUtil.safeSubstring(null, 0, 5)).isNull(); } } class MoneyUtilTest { Test void testAdd() { BigDecimal result MoneyUtil.add(new BigDecimal(100.5), new BigDecimal(200.2)); assertThat(result).isEqualByComparingTo(300.7); // 使用compareTo比较 } Test void testDivideByZero() { assertThatThrownBy(() - MoneyUtil.divide(BigDecimal.ONE, BigDecimal.ZERO)) .isInstanceOf(ArithmeticException.class) .hasMessageContaining(除数不能为0); } }5.3 是自研还是引入第三方库这是一个永恒的话题。我们的原则是基础通用操作如字符串判空、集合判空如果逻辑非常简单像我们上面实现的isEmpty可以自研。但对于更复杂的操作如字符串差异比较、集合过滤转换强烈推荐使用Apache Commons Lang3和Guava。它们经过了无数项目的考验功能全面性能优异而且持续维护。特定领域操作如加密解密、HTTP客户端、JSON解析绝对不要自研。使用成熟的库如Jackson、Gson、HttpClient、BouncyCastle等。自研工具类的价值在于封装项目特定的、高度复用的业务逻辑以及对第三方库的统一适配和异常处理。例如项目中对金额的四舍五入规则有特殊要求或者需要对Jackson的日期序列化格式做全局统一配置这些适合放在自研工具类中。在房屋出租这个项目里我们最终的选择是核心的、简单的工具方法自己封装形成项目统一的工具层规范同时在pom.xml中引入commons-lang3和guava作为基础依赖在自研工具类的内部有时也会直接调用这些库的方法确保功能稳定可靠。
返回列表