Java方法设计原则与实践:从Effective Java到团队案例

京东云开发者
• 阅读 2

作者:京东物流 京东物流

背景

本文通过阅读《Effective Java》、《Clean Code》、《京东JAVA代码规范》等代码质量书籍,结合团队日常代码实践案例进行整理,抛砖引玉、分享一些在编写高质量代码方面的见解和经验。这些书籍提供了丰富的理论知识,而团队的实际案例则展示了这些原则在实际开发中的应用。希望通过这篇文章,能够帮助大家更好地理解和运用这些编程最佳实践,提高代码质量和开发效率。

什么是一个好的方法

在 Java 中,方法是类的一部分,定义了类的行为。方法通常包含方法头和方法体。方法头包括访问修饰符、返回类型、方法名和参数列表,而方法体包含实现方法功能的代码。

方法的基本结构 [访问修饰符] [返回类型] 方法名 { // 方法体 // 实现方法功能的代码 }

如果一个方法在满足业务需求本身的基础上,职责单一,清晰明了,重点是团队其他成员可以简单看懂及维护,这就是一个好的方法。 如果只有自己看得懂,其他人看不太懂,则不是一个好的方法。具体原则细节从以下【入参】【方法体】【出参】维度详细描述

一、入参

1)入参不要太多

理想情况下,方法的参数应尽量少。最佳情况是没有参数,其次是一个参数,再次是两个或三个参数,尽量避免超过四个参数。参数越多,方法通常越复杂。从测试的角度来看,编写各种参数组合的单元测试场景也会变得复杂。

设定四个或更少的参数,因为大多数程序员记不住更长的参数列表。同类型的参数尤其有害,如果不小心弄反了参数的顺序,程序可以正常编译和运行,但结果可能不正确,这极易导致错误。

如果方法确实需要多个参数,这通常意味着这些参数应该封装为一个类,通过创建参数对象来减少参数的数量。

错误案例:重量/体积 同类型参数顺序错误导致问题

// 错误的方法定义,参数过多且容易混淆
public void calculateShippingCost(double weight, double volume, double length,
                             double width, double height, String destination) {
    // 假设这里有计算运费的逻辑
}

// 这里将重量和体积的顺序弄反了
service.calculateShippingCost(30.0, 50.0, 10.0, 5.0, 3.0, "New York");
// 实际上应该是:
service.calculateShippingCost(50.0, 30.0, 10.0, 5.0, 3.0, "New York");

✅正确案例:在这个示例中,由于重量和体积的顺序弄反,计算出来的运费会有误。为了避免这种错误,可以将这些参数封装成一个类:

public class ShippingDetails {
    private double weight;
    private double volume;
    private double length;
    private double width;
    private double height;
    private String destination;
    // 构造方法、getter和setter省略
}
// 使用参数对象来简化方法签名
public void calculateShippingCost(ShippingDetails details) {
    // 假设这里有计算运费的逻辑
}

通过将参数封装成一个类,可以有效减少方法的参数数量,避免参数顺序错误的问题,提高代码的可读性和可维护性。



2)谨慎使用可变参数

可变参数数量,它接受0个或者N个指定类型的参数。可变参数的原理是根据调用位置传入的参数数量,先创建一个数组,然后将参数放入这个数组中,最后将数值传递给该方法。



注意:在对性能要求很高的情况下,使用可变参数要特别小心,每次调用可变参数方法都会导致一次数组的分配和初始化。

❌错误案例:循环中调用可变参数方法

public class Logger {
    // 可变参数方法
    public void log(String level, String... messages) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ");
        for (String message : messages) {
            sb.append(message).append(" ");
        }
        System.out.println(sb.toString());
    }
}

// 模拟高频调用
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", "Message", "number", String.valueOf(i));
}

在这个案例中,log方法每次调用都会创建一个新的数组来保存可变参数messages。在高频调用的场景下,这种数组分配和初始化的开销会显著影响性能。



✅优化案例:避免可变参数带来的性能开销 我们使用了List来传递日志消息。虽然在每次调用时仍然会创建一个List对象,但相比于每次创建一个数组,这种方式的性能开销更小,特别是在高频调用的场景下。

public class Logger {
  // 使用List代替可变参数
    public void log(String level, List<String> messages) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ");
        for (String message : messages) {
            sb.append(message).append(" ");
        }
        System.out.println(sb.toString());
    }
}

// 模拟高频调用
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", List.of("Message", "number", String.valueOf(i)));
}

✅进一步优化:使用StringBuilder直接拼接 在这种情况下,我们完全避免了数组或集合的创建,直接通过StringBuilder拼接字符串,从而最大限度地减少了性能开销。

public class Logger {
   // 使用StringBuilder直接拼接
    public void log(String level, String message1, String message2, String message3) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ")
          .append(message1).append(" ")
          .append(message2).append(" ")
          .append(message3).append(" ");
        System.out.println(sb.toString());
    }
}

// 模拟高频调用
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", "Message", "number", String.valueOf(i));
}



如果无法承受上面的性能开销,但又需要可变参数的便利性,可以有一种兼容的做法,假设方法95%的调用参数不超过3个,那么我们可以声明该方法的5个重载版本,分别包含(0,1,2,3)个参数和一个(3,可变参数),这样只有最后一个方法才需要付出创建数组的开销,而这只占用5%的调用。

✅案例:org.slf4j.Logger 每个日志级别都有多个重载的方法,支持不同数量的参数,通过这些方法,SLF4J 提供了灵活且高效的日志记录接口,可以适应各种不同的日志记录需求。

package org.slf4j;
public interface Logger {
    public boolean isInfoEnabled();
    public void info(String msg);
    public void info(String format, Object arg);
    public void info(String format, Object arg1, Object arg2);
    public void info(String format, Object... arguments);
    public void info(String msg, Throwable t);
}

3)校验参数的有效性

大部分方法都会对入参的值有一定限制,比如String字符串长度,类型转换,对象不能为null,订单运单唯一性,批量接口List个数限制等。首先我们应该在API中详细描述入参的各种限制条件,并且在方法体的入口进行校验检查,以强制实施这些限制。

对参数的检查,原则是尽早检查,否则整个链路被检测的可能性降低,并且一旦检测到,定位起源头比较复杂。反过来思考,如果不在开头进行检查,则可能发生如下情况:方法在接下来链路处理过程中抛出错误的结果,但方法可能是正常返回,比如接口返回正常,但数据库保持的时候,由于字段越界导致保存数据库异常。



参数校验 应该反应到 技术指标还是业务指标?

技术指标:个人理解入参非法不应该体现到UMP技术可用率指标,因为这是API正常的一种体现,如果入参非法不合理,返回上游对应的错误码CODE,本身的技术可用率正常。 业务指标:但方法对应的业务指标可以反映入参非法的情况。例如,可以记录非法入参的次数,以便分析和改进整个链路的业务逻辑。



✅案例:链路校验一致 比如某个入参,从上游到整个链路下游,包括方法内部链路,最终到数据库存储,校验规则是一致的。在下面这个例子中,userName的长度限制在方法入口和数据库存储过程中保持一致,确保链路校验一致。

public class UserService {

    // 用户信息保存方法
    public void saveUser(String userName) {
        // 参数校验
        if (userName == null || userName.length() > 20) {
            throw new IllegalArgumentException("User name cannot be null and must be less than 20 characters");
        }

        // 假设数据库字段长度限制为 20
        saveToDatabase(userName);
    }

    private void saveToDatabase(String userName) {
        // 数据库保存逻辑
        // ...
    }
}

❌错误案例:链路校验规则不一致 零售C端/B端用户可以填写20个字符串,整个链路校验也是20,但底层数据库是varchar(10)

   // 假设数据库字段长度限制为 10
    private void saveToDatabase(String userName) {
        // 数据库保存逻辑
        // ...
    }

探讨:链路重复校验

比如物流链路运单合法性校验,N个系统都进行校验是否有必要?是否应该只在入口处校验,其他链路保持信任机制?



二、方法体

1)方法要短小

方法的第一规则是短小,正如行业很多代码规约,比如阿里规约方法总行数不超过80行,京东代码规范中方法体的行数不能多于70行,否则降低编码效率,不方便阅读和理解。

其实个人理解不用太关注多少行,核心是方法的职责要单一,分清楚方法主干和分支,,看方法里的代码是否还可以再抽取一个方法,分清代码个性和共性,把共性的代码抽取方法,用于复用,让方法主干更清晰。



2)无副作用

在Java 编程语言中,术语“副作用”(side effects) 指的是一个函数或表达式在计算结果以外对程序状态(如修改全局变量、改变输入参数的值、进行I/O 操作等)产生的影响。



副作用案例: 如下filterBusinessType方法的主要作用是返回一个业务类型int类型的值,但它也修改了传入的response对象的A值作为一个副作用。在外面链路使用了A属性值做逻辑判断 副作用问题:在filterBusinessType方法中如果是在response之前return了数据,从方法角度看不出问题,但整个链路会出现问题。

public int filterBusinessType( Request request,Response response) {
 if(...){
  return ... 
  }
   boolean flag = isXXX(request, response); 
} 

正如上面说的方法职责单一,只做一件事,但副作用就是一个谎言,方法还会做其他隐藏起来的事情,我们需要理解副作用的存在,并采取合适的策略来管理和控制它们。

如何规避这种现象

为了避免这种情况,可以采用以下几种策略:

1.分离关注点: 可以将获取业务类型和响应设置分离成两个不同的方法。这样,调用者就可以清晰地看到每个方法的职责。

public int filterBusinessType(String logPrefix,Request request){
    // 过滤逻辑...
    int businessType=...;
    return businessType;
}
public void setResponseData(int filterResult,Response response){
    // 根据过滤结果设置响应数据...
    response.setFilteredData(...);
}

1.返回复合对象(上下文context) : 如果业务类型结果和响应数据是紧密相关的,可以考虑创建一个包含这两个信息的复合对象,并将其作为方法的返回值。

public FilterResultAndResponse filterBusinessType(String logPrefix,Request request){
    // 过滤逻辑...
    int result=...;
    Response response=new Response();
    response.setFilteredData(...);
    return new FilterResultAndResponse(result, response);
}

class FilterResultAndResponse{
    private int filterResult;
    private Response response;

    public FilterResultAndResponse(int filterResult,Response response){
        this.filterResult = filterResult;
        this.response = response;
    }

    // Getters and setters for filterResult and response}

3)控制语句(if/else/while/for等)

不要在条件判断中执行复杂的语句,将复杂逻辑判断的结果赋值给一个有意义的布尔变量,以提高可读性。团队中也存在很多if语句内的逻辑相当复杂,阅读者需要分析条件表达式的最终结果,才能明确什么样的条件执行什么样的语句。复杂逻辑表达式,与、或、取反混合运算,甚至各种方法纵深调用,理解成本非常高。如果赋值一个非常好理解的布尔变量名字,则是件令人爽心悦目的事情



错误案例:if/else if语句中条件逻辑复杂,并且还存在!取反混合运算,导致这段代码理解成本比较高

boolean flagA = isKaWhiteFlag(logPrefix, request);
boolean flagB = PlatformTypeEnum.JD_STATION.getValue() == request.getPlatformType();
boolean flagC = KaPromiseUccSwitch.isPopJDDeliverySwitch(request.getDict(),request.getStoreId()) 
                           && (PlatformTypeEnum.JD_STATION.getValue() == request.getPlatformType())
                           && (DeliveryTypeEnum.JD_DELIVERY.getType() == request.getDeliveryType());
if (!flagC && flagA) {
......
}else if (!flagB && !flagC && 
          StringUtils.isNotBlank(request.getProductCode()) 
         && kaPromiseSwitch.isKaStoreRouterDs(logPrefix.getLogPrefix(), request.getDict(), request.getStoreId(), request.getCalculateTime(),request.getDeptNo())){
......
}else{
......
}

4)异常

4.1)异常应该仅用于异常的情况,不应该用于普通的控制流程

案例:不当使用异常处理控制流程

   // 使用异常处理来控制流程
    public static int parseNumber(String number) {
        try {
            return Integer.parseInt(number);
        } catch (NumberFormatException e) {
            throw e;
        }
    }

✅案例:使用常规控制结构替代异常处理

  // 使用常规控制结构来处理正常流程
    public static boolean isNumeric(String str) {
        if (str == null) {
            return false;
        }
        try {
            Integer.parseInt(str);
            return true;
        } catch (NumberFormatException e) {
            return false;
        }
    }

4.2)不要忽略异常

很多代码都违法了这一条原则,所以本文值得再强调。 当方法会抛出一个异常时,就是想要告诉你一些重要信息,所以不要忽略它。忽略它很简单,catch住,然后里面什么也不做。异常就是强制我们要处理的,空的catch违背了异常的本意,是一种不好的实践。它不仅违背了异常处理的本意,还可能导致潜在的问题未被发现和解决。

错误案例

 try {

        // 可能抛出IOException
        throw new IOException("File not found");
    } catch (IOException e) {
        // 空的catch块,忽略异常
    }

4.3)异常封装

对于业务层面的异常,应当进行适当的封装,定义统一的异常模型。避免直接将底层异常暴露给上层模块,以保持业务逻辑的清晰性。比如DependencyFailureException:表示服务端依赖的其他服务出现错误,服务端是不可用的,可以尝试重试,类比HTTP的5XX响应状态码。InternalFailureException:表示服务端自身出现错误,服务端是不可用的,可以尝试重试,类比HTTP的5XX响应状态码。

4.4)异常转换

1.Web 层绝不应该继续往上抛异常,因为已经处于顶层,无继续处理异常的方式,如果意识到这个异常将导致页面无法正常渲染,那么就应该直接跳转到友好错误页面,加上友好的错误提示信息。

2.开放接口层不能直接抛异常,应该将异常处理成code错误码和错误信息message方式返回。其中错误码应该能够快速识别错误的来源,便于团队成员快速定位问题。同时,错误码应易于比对,有助于团队对错误原因达成共识。其中错误编码可参考HTTP协议的响应状态码:

•2XX(成功响应):表示操作被成功接收并处理。例如,200表示请求成功。

•4XX(客户端错误):表示请求包含语法错误或无法完成请求。例如,404表示请求的资源(网页等)不存在。

•5XX(服务端错误):表示服务器在处理请求的过程中发生了错误。例如,500表示服务器内部错误,无法完成请求。

5)日志

5.1)日志三字经:准、懂、少

准: 日志打印一定要准确,该打的地方打,不该打的地方不打。如何确定什么地方该打,原则之一看上线后日志是否可以覆盖方法的所有业务场景

懂: 打印日志不只是给你自己看的,更是给团队其他人看的,所以一定要打印的让其他人也能看懂,尽量用一些通俗易懂的文字描述让团队能看懂

少: 少即是多,日志太多第一影响性能,第二存储成本,第三影响排查



5.2)日志注意事项

1.日志必须有traceId,可追踪唯一性

2.日志打印建议打中文结合代码英文字段方法属性等,确保日志内容清晰、易于理解和分析,否则看完日志还得去看代码

3.对外API方法出入参必须打印

4.调用其他团队API(JSF接口、中间件Redis等)同理,必须打印出入参

5.异常信息要打印

6.不用打重复日志,比如在DAO层,由于可能会遇到多种类型的异常,DAO层不需要打印日志。这是因为在Manager或Service层,异常会被再次捕获并记录到日志文件中。

7.在 Service 层出现异常时,必须记录出错日志到磁盘,其中日志记录应该遵循一定的规范,包括错误码、异常信息和必要的上下文信息。日志内容应该清晰明了,相当于保护案发现场。

案例:团队日志我一直想治理,其中2个痛点:第一个是打印的太多,第二个是很多日志只有当事人能看懂,其他成员看不懂



6)详细的注释

详细的代码注释在方法中至关重要,原因如下:

1.业务迭代:随着业务的不断迭代,许多方法的意图变得难以理解。

2.有坑的代码:团队中存在非常规、有坑的代码,增加了维护的难度。

3.人员变更:团队成员的变动使得代码的可读性和可维护性变得更加重要。

方法注释的要点

1.描述方法和客户端之间的约定:注释应详细描述方法的功能和其与调用方之间的约定,即方法应该完成什么任务。

2.列出前置条件:注释应列出所有调用该方法前必须满足的条件。这可以帮助调用者理解在什么情况下可以安全地调用该方法。

3.列出后置条件:注释应明确调用方法后哪些条件肯定会成立。这有助于调用者了解调用方法后的预期结果和状态变化。

4.描述副作用:如果方法有任何副作用,如启动后台线程或修改入参对象的某个值,这些都应该在注释中详细说明。这可以帮助调用者预见和处理可能的影响。

public int filterBusinessType( Request request,Response response) {
 /** * 切记:return必须在下面这行代码(isXXX方法)后面,因为外面会使用response.A()来判断逻辑 
     * 你可以理解本filterBusinessType方法会返回业务类型,同时如果isXXX方法会修改response.setA()属性 
 */ 
 boolean flag = isXXX(request, response); 
 if(...){
  return ... 
 } 
 } 

对外API文档

对于对外的API文档,注释应详细说明每个字段的条件,确保调用方能够无歧义地理解API的使用。关于API文档的细节,在此不做详细讨论,但同样需要强调清晰和详细的重要性。



通过详细的注释,能够提高代码的可读性和可维护性,减少因业务迭代、历史代码和人员变更带来的困扰。



✅案例:针对时效内核,代码比较抽象,添加的详细注释详细,加一下case案例,方便新人可读性

❎注意点: 1、注释会撒谎,代码注释的时间越久,就离其代码的本意越远,越来越变得错误,原因很简单:程序员不能坚持维护注释。 2、不准确的注释比没注释坏的多,只有代码能忠实的告诉你告诉你它做的事,那是唯一真正准确的信息来源

三、出参

1)返回空的集合或者数组,而不是null

如果方法返回null,而不是空的集合或者数组,那么几乎所有使用这个方法的地方,都需要特殊判断null,这样很容易由于遗忘而出错,




如本文里面信息不对请指正,如有更好的知识点,欢迎评论交流完善补充。谢谢!




相关文献

1、Effective Java

2、Clean Code

3、京东JAVA代码规范

点赞
收藏
评论区
推荐文章
如何让Java编译器帮你写代码
本文结合京东监控埋点场景,对解决样板代码的技术选型方案进行分析,给出最终解决方案后,结合理论和实践进一步展开。通过关注文中的技术分析过程和技术场景,读者可收获一种样板代码思想过程和解决思路,并对Java编译器底层有初步了解。
Easter79 Easter79
3年前
Taro 在京东购物小程序上的实践
Taro简介Taro是一个基于React语法规范的多端统一开放框架,大家可以通过taro.aotu.io进一步了解。而前段时间Taro发布后,京东购物小程序就开始了部分页面基于Taro的重构工作,本文便是对商品分类页使用Taro进行代码重构的一些实践分享。混合开发模式过去的京东购物小程序未使用任何第三
Stella981 Stella981
3年前
Apache Flink 在实时金融数据湖的应用
本文由京东搜索算法架构团队分享,主要介绍ApacheFlink在京东商品搜索排序在线学习中的应用实践。文章的主要大纲如下:1、背景2、京东搜索在线学习架构3、实时样本生成4、FlinkOnlineLearning5、监控系统6、规划总结一、背景在京东的商品搜索排序中,
京东云开发者 京东云开发者
3星期前
揭秘动态化跨端框架在鸿蒙系统下的高性能解决方案
作者:京东科技胡大海前言(后文统称“动态化”)是一个由京东金融大前端团队全自主研发的,一份代码,可以在HarmonyOS、iOS、Android、Web四端运行的跨平台解决方案。在研发团队使用后可大幅降低研发人力成本;为业务提供实时触达、A/B触达等能力以
京东云开发者 京东云开发者
3星期前
Code Review:探索工程实践之道
作者:京东物流冯志文前言本文参考《京东JAVA代码规范V1.1》\&Google代码评审工程实践方法论,结合团队代码评审的实践经验整理成文档,这份文档是我们团队集体经验的结晶。我相信公司其他部门也有类似的经验和最佳实践。希望通过互相交流和学习,共同提高代码
京东云开发者 京东云开发者
1星期前
【稳定性】稳定性建设之变更管理
作者:京东物流冯志文背景在软件开发和运维领域,变更管理是一个至关重要的环节。无论是对现有系统的改进、功能的增加还是修复漏洞,变更都是不可避免的。这些变更可能涉及到软件代码的修改、配置的调整、服务器的扩容、三方jar包的变更等等。然而,变更的执行过程往往伴随
京东云开发者 京东云开发者
1星期前
京东APP百亿级商品与车关系数据检索实践
作者:京东零售张强导读本文主要讲解了京东百亿级商品车型适配数据存储结构设计以及怎样实现适配接口的高性能查询。通过京东百亿级数据缓存架构设计实践案例,简单剖析了jimdb的位图(bitmap)函数和lua脚本应用在高性能场景。希望通过本文,读者可以对缓存的内
京东云开发者 京东云开发者
1星期前
JavaScript 与 Rust 和 WebAssembly 集成
作者:京东物流梁瑞乐偶然一次机会,接触了Rust的代码。当时想给团队小伙伴做演示,发现自己并不能在移动端按照文档生成演示demo。我就想,要是Rust代码能转化成JavaScript就好了。结果一搜,还真有。下面整理成文档,分享给大家。为大家解决问题,多提
想要做好代码质量,如何破局?
作者:苗现方想要做好代码质量,我们不得不提什么是代码质量?本文中讨论的代码质量一般是指代码的风格、重复率和复杂度等,代码是技术团队的价值产物,是宝贵的财富,同样代码质量的好坏可以直接体现出团队的重视程度和技术管理水平。代码质量的下降是内
京东云开发者 京东云开发者
10个月前
码上加速,低代码解锁高效交付案例
一、背景简介站长工作台,致力于为京东物流所有站长、运营管理人员提供高效工作平台,拥有多元化的业务形态。我们力求提升团队研发效率、实现敏捷业务交付,以打造一支具备灵活性、高度协作和强适应能力的敏捷团队。二、提效案例描述2.1、痛点分析站长工作台的报表页面和任