对象方法与静态方法在工具类设计中的边界

发布于:6/13/2023, 1:31:05 PM @孙博
技术分享 | 设计模式,工具类,代码设计,面向对象
许可协议:署名-非商业性使用(by-nc)

对于一个正常规模的业务系统,除了必要的业务代码之外,还会有一些公共方法来完成公共逻辑,如获取请求方 IP 地址、日期和数字的格式化处理、检查用户权限等。尽管这些公共方法的具体实现可能不同,但它们通常都是扮演着完成公共逻辑的角色。这些公共方法可能会被封装成服务,也可能会被写入工具类的静态方法中。那么为什么会有这些差异?这些不同的实现方式在设计层面上有何不同之处?本文将探讨这个问题。

既然提到了静态方法与通过对象暴露的方法,首先我们要想一下这两种方式本质的区别是什么。我们经常会提到“面向对象”这个词,如果从这一点来说,静态方法与通过对象暴露的方法的一个重要的区别就是——工具类的静态方法是一个孤立的方法,而对象提供的方法是隶属于特定对象的。

考虑到大环境的因素,接下来的讨论将以 Java 为例。


Spring 是我们经常会用到的框架,在许多大厂,Spring Boot 甚至是面试中必备的技能,就好像一个人如果没用过 Spring 就等同于完全不懂 Java 一样。但有一说一,这个框架本身确实也很优秀,它所蕴含的设计思想确实有着非常多值得借鉴的地方,就拿我们今天的主题来说,在 Spring 中就存在着许多公共方法,有的是封装在了对象中,还有的则被写入了静态工具类,我们来看看他们到底有什么不同。

我们先来看两个最常见的类—— ApplicationContextStringUtils ——后者是一个工具类,它提供了许多操作字符串的静态方法,例如判断是否为空、去除空格等。而前者则是一个对象,它包含了应用上下文的所有信息,并提供了许多有用的方法、属性和事件。这两者的实现方式不同,也会导致它们在设计上存在一些区别。

org.springframework.context.ApplicationContext

ApplicationContext 是 Spring 框架中最常用的一个接口,它是 Spring 容器的核心接口之一,用于加载和管理 Spring 应用中的所有 bean 以及提供各种应用程序级别的服务。

在 Spring 中,ApplicationContext 负责:

  1. 加载 bean 配置信息:ApplicationContext 会读取 bean 配置文件中的信息,并且实例化所有定义好的 bean,并将它们存储在 Spring 的 BeanFactory 中,以便在应用中使用。

  2. 管理 bean 生命周期:ApplicationContext 可以管理所有 bean 的生命周期,它会在 bean 实例化、属性赋值和初始化等各个阶段调用相应的方法,以确保每个 bean 都是正确地初始化并且处于可用状态。

  3. 提供依赖注入功能:ApplicationContext 会自动识别 bean 之间的依赖关系,并且将依赖的 bean 注入到目标 bean 中,从而省去了手动编写繁琐的代码的麻烦。

  4. 提供 AOP 支持:ApplicationContext 支持面向切面编程(AOP),可以通过配置切面来实现各种增强和通知功能。

  5. 提供事件机制:ApplicationContext 可以发布各种事件以及监听器,用于实现各种事件的处理逻辑。

可以说,ApplicationContext 是 Spring 中最重要的接口之一,它提供了 Spring 框架中许多重要的功能,并且广泛地应用于各种 Java 应用程序中。

org.springframework.util.StringUtils

注:org.apache.commons.lang3.StringUtils 等相似工具类同理。

StringUtils 作为 Spring 框架中的工具类,其最明显的特性就是提供了简单、实用、高效的字符串处理方法。以下是 StringUtils 的几个特点:

  1. 轻量级:StringUtils 的核心代码非常简洁,具有轻量性的特点,要比 Java 自带的 String 处理类更加保持程序的精简。

  2. 支持 null 处理:StringUtils 提供了大量的方法,这些方法都支持 null 处理。如 StringUtils.trim(null)方法会处理 null 值,返回 null,而不是抛出 NullPointerException 异常。

  3. 多样性:StringUtils 提供了数十种字符串操作方法,不论是字符串比较、替换、删除、填充、格式化等,都可以轻松地在 StringUtils 中找到相应的方法。其中大多数方法还支持忽略大小写的操作。

  4. 性能高效:StringUtils 的方法经过了精心的代码编写和测试、优化,性能非常高效,比 Java 原生的 String 处理类更快。

  5. 不依赖与 Spring 框架:虽然 StringUtils 这个工具方法是 Spring 框架中的一部分,但是由于其独立性,开发者们可以在不依赖于 Spring 框架的基础上使用。

总之,StringUtils 是一种非常实用的字符串处理工具类,在 Java 开发中得到了广泛的使用。其轻量级、多样性、高效性等特点使得程序员们能够更加便捷地处理字符串。


下面写一小段简单的用法示例,以下代码由 ChatGPT 3.5 生成:

import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.util.StringUtils;

public class Main {
    public static void main(String[] args) {
        // 通过ClassPathXmlApplicationContext获取context对象
        ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");

        // 获取字符串,并使用StringUtils进行处理
        String str = "   hello world!    ";
        String result = StringUtils.trimWhitespace(str);

        // 从context对象中获取bean,并使用bean的方法对处理后的字符串进行输出
        MyBean myBean = context.getBean(MyBean.class);
        myBean.doSomething(result);
    }
}

class MyBean {
    public void doSomething(String str) {
        // 输出处理后的字符串
        System.out.println(str);
    }
}

从代码示例中可看出,如果想要使用 ApplicationContext 则需要进行一次实例化,但与示例内容不同的是,在实际实施过程中可能更常见的方式是通过 @Autowired 注解进行注入;而使用 StringUtils 则更简单,仅仅通过调用静态方法的方式即可完成需求。

根据上面两个类的情况,如果仅从功能性设计的角度来看,好像在程序设计时到底该用静态方法还是对象方法就是一件一目了然的事情,根本不需要写这个文章来特地进行讨论。而之所以会花上一些篇幅聊一聊,还是因为在具体工程实施过程中,我们遇到的情况往往会比较模糊。

下面我们来说说项目实施过程中,关于 HTTP 工具类常见的封装方式及在封装过程中遇到的值得关注的问题。


如果以“ http工具类 ”为关键词,在搜索引擎中会找到许多以 HttpUtils 等命名的类,其中通常会提供诸如postget之类的静态方法,这种方式确实也能够满足日常的工作需要。如果不考虑代码设计层面的问题的话,这么写也没有什么明显的错误,况且在许多项目中很多人本身也会以类似的方式去写代码。

但写代码的同时,大家是否想过一个问题——无论是 HttpClient,还是 RestTemplate 之类的类,它们都是被设计成了需要实例化才可以使用的对象形式,而不是直接提供了一个带有静态方法的工具类?难道是因为对象设计显得更专业?显然不是,设计成对象并不意味着大型框架的作者是因为偏向于使用这种形式,因为同样在框架中,就像 StringUtils 等工具类照样是设计成以静态方法提供能力的方式。

我们都知道,HTTP 协议本身是无状态的,如果仅从一次请求的发送与响应过程来说,将发送请求看成是与 StringUtils.isBlank 相似的静态过程也无妨。但实际上,考虑到发送请求实际所消耗的资源与配置数据等因素,建立并保持连接、配置请求地址、甚至是设计请求失败时的错误处理都是可以对象化的。

就那我们比较常用的 RestTemplate 举例,虽然有无参构造函数,但在 new RestTemplate()时实际上也是可以传入 ClientHttpRequestFactory对象的,这个工厂类的对象除了可以帮我们创建 HttpRequest 对象外,更是可以作为帮我们维护连接池的重要工具——如果我们使用的是HttpComponentsClientHttpRequestFactory这个实现。在创建 HttpComponentsClientHttpRequestFactory 的实例时,同时可以将指定的 HttpClient 对象传进去。HttpClient 对象本身可以具有指定连接池、指定连接保持策略等能力,依赖于底层能力的支持,RestTemplate 的实例可以在默认实现的基础上,具备更多的资源分配能力。除去资源利用的能力外,也可以在 RestTemplate 对象上,通过增加拦截器的方式为其增加全局的请求头。

说了这么多,这么复杂,多写那么多代码带来的附加能力和直接使用静态方法相比,有什么实质性的好处吗?


我们在项目中,如果遇到了依赖外部接口的场景,往往都是一个特定的一个或一组 HTTP 接口,在具体的实施过程中,我们是可以将“鉴权”、“连接池”等逻辑做分别的封装的。比如我们的系统如果需要实现 OAuth 2.0 鉴权的逻辑,可能会有部分功能将依赖于授权中心所提供的同一个域名下的多个接口服务,而调用这些接口需要加上相同的 HTTP 请求头;与此同时,系统里还会有一些业务存在对其他服务接口的依赖,但不同的是,这些“其他服务”依赖的外部接口可能会来自不同的域名,并且有着完全不同的鉴权方式与响应解析方式等差异。

假如使用的是传统的静态方法工具类的方式,可能就没有办法以更少的系统资源来实现上述需求——因为不同的域名、不同的请求头与不同的响应处理都很难让我们使用同一个对象、同一套拦截器方案。其实对于这种场景,只需要换个思路,将对外发送 HTTP 请求的客户端做一次再封装,将请求 OAuth 2.0 服务的客户端与请求其他接口的客户端分别封装在不同的实现类中,在需要调用接口时,服务调用侧使用对应的客户端实例发起请求即可,使用这种方式就可以实现“独占客户端”、“独占 HTTP 处理逻辑”、“独占配置”等能力。

说起来很抽象,下面将给出一个示例,下面的代码取自我们调用接口获取 ChatGPT 应答结果的部分逻辑,引入的包、请求地址、无关的方法等多处代码已被移除,此处仅就代码设计给出示例。

由于 Azure 接口均属于是公开信息,此处代码不涉及商业机密,如需获取更详细的字段信息,可自行搜索相关接口文档。

// 局部代码,隐去无关逻辑
@Slf4j
@Component
public class AzureChatClient {
    @Autowired
    private TrendService trendService;
    @Autowired
    private ConfigService configService;
    @Autowired
    private ChatClientExceptionHandlerFactory exceptionHandlerFactory;
    private RestTemplate restTemplate;
    private PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
    private final String URL = "http://example.com"; // 出于安全原因,此处实际接口地址已隐去。
    @Value("${chatgpt.api-key}")
    private String API_KEY;
    private String CONFIG_KEY_AZURE_MAX_CONNECTION = "AZURE_MAX_CONNECTION";
    private Integer CONFIG_VALUE_AZURE_MAX_CONNECTION = 512;
    private Integer CONFIG_VALUE_AZURE_MAX_CONNECTION_LIMIT = 65535;
    private AtomicInteger concurrency = new AtomicInteger(0);

    private void loadConfig() {
        Integer value = this.configService.getIntegerOrDefault(CONFIG_KEY_AZURE_MAX_CONNECTION);
        if (value != null && value > CONFIG_VALUE_AZURE_MAX_CONNECTION_LIMIT) {
            log.info(SkynetMarker.create(this.getClass()), "Azure API 最大连接数配置的是 <{}> 超过最大值 <{}> 将按照 <{}> 进行设置。",
                    value, CONFIG_VALUE_AZURE_MAX_CONNECTION_LIMIT, CONFIG_VALUE_AZURE_MAX_CONNECTION_LIMIT);
            value = CONFIG_VALUE_AZURE_MAX_CONNECTION_LIMIT;
        }
        if (value != null && !value.equals(CONFIG_VALUE_AZURE_MAX_CONNECTION) && value > 0) {
            log.info(SkynetMarker.create(this.getClass()), "Azure API 最大连接数由 <{}> 变更为 <{}>",
                    CONFIG_VALUE_AZURE_MAX_CONNECTION, value);
            CONFIG_VALUE_AZURE_MAX_CONNECTION = value;

            connectionManager.setMaxTotal(this.CONFIG_VALUE_AZURE_MAX_CONNECTION);
            connectionManager.setDefaultMaxPerRoute(this.CONFIG_VALUE_AZURE_MAX_CONNECTION);
        }
    }

    @PostConstruct
    public void init() {
        this.configService.onChanged(() -> this.loadConfig());
        this.loadConfig();
        ConnectionKeepAliveStrategy keepAliveStrategy = new ConnectionKeepAliveStrategy() {
            @Override
            public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) {
                return 30 * 1000;
            }
        };
        connectionManager.setMaxTotal(this.CONFIG_VALUE_AZURE_MAX_CONNECTION);
        connectionManager.setDefaultMaxPerRoute(this.CONFIG_VALUE_AZURE_MAX_CONNECTION);
        HttpClient httpClient = HttpClientBuilder.create()
                .setConnectionManager(connectionManager)
                .setKeepAliveStrategy(keepAliveStrategy)
                .build();
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
        factory.setConnectTimeout(30000);
        factory.setReadTimeout(60000 * 3);
        this.restTemplate = new RestTemplate(factory);
        this.restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
        this.restTemplate.getInterceptors()
                .add((HttpRequest request, byte[] bytes, ClientHttpRequestExecution execution) -> {
                    request.getHeaders().set("api-key", this.API_KEY);
                    request.getHeaders().set("Content-Type", MediaType.APPLICATION_JSON_VALUE);
                    request.getHeaders().set("Connection", "keep-alive");
                    return execution.execute(request, bytes);
                });
    }

    public AzureResponseChatCompletions completions(AzureRequestChatCompletions request) throws Exception {
        RequestMetricContext metric = RequestMetricContext.start(this.trendService, ChatSuppliers.AZURE, false);
        SkynetMarker marker = SkynetMarker.create(this.getClass());
        String request_body = JSONObject.toJSONString(request);

        log.info(marker, "向 Azure API 发送请求:{}", request_body);
        this.trendService.log(TrendKeys.MATRIC_PROXY_REQUEST_CONCURRENT, new HashMap<>(metric.getTags()),
                concurrency.incrementAndGet());
        try {
            ResponseEntity<String> response = this.restTemplate.postForEntity(URL, request_body, String.class);
            String response_body = response.getBody();
            if (response.getStatusCode().is2xxSuccessful()) {
                log.info(marker, "从 Azure API 获得响应,耗时 <{}ms> 响应代码 <{}> 响应内容:{}", metric.getTotalTimeMillis(),
                        response.getStatusCodeValue(), response_body);
                metric.completeSuccessfully();
                return JSONObject.parseObject(response_body, AzureResponseChatCompletions.class);
            } else {
                log.warn(marker, "从 Azure API 获得响应,耗时 <{}ms> 响应代码 <{}> 响应内容:{}", metric.getTotalTimeMillis(),
                        response.getStatusCodeValue(), response_body);
                metric.completeWithException(new BizException("Azure API 返回错误代码:" + response.getStatusCodeValue()));
                throw new Exception("Azure API 返回错误代码:" + response.getStatusCodeValue());
            }
        } catch (Exception e) {
            metric.completeWithException(e);
            log.error(marker, "从 Azure API 获得响应时发生错误,耗时 <{}ms> 错误内容 <{}> 错误详情",
                    metric.getTotalTimeMillis(), e.getMessage(), e);
            ChatClientExceptionProcessable handler = this.exceptionHandlerFactory
                    .get(new CompletedException(ChatSuppliers.AZURE, e));
            handler.process();
            throw e;
        } finally {
            this.trendService.log(TrendKeys.MATRIC_PROXY_REQUEST_CONCURRENT, new HashMap<>(metric.getTags()),
                    concurrency.decrementAndGet());
        }
    }
}

从上面的代码可以看出,这里设计了一个通过Azure API获取ChatGPT应答的客户端,该客户端内置了并发数的统计、控制、埋点等多处逻辑,也会为每次请求都带上API-Key的请求头来实现鉴权。这样的话,当外部服务需要通过该客户端实例发送请求时,就不需要考虑加鉴权信息、或是处理响应内容的反序列化等。

有了具体的HTTP客户端实验,在需要调用时则可以简单写成如下方式,也不需要指定接口地址,或是添加鉴权。
下面示例中体现了“完整的JSON响应”与“流式事件响应”两种不同的对话方式:

// 局部代码,隐去无关逻辑
@Component
public class AzureChatServiceImpl
        extends ChatService<AzureChatSession, AzureRequestChatCompletions, AzureResponseChatCompletions>
        implements AzureChatService {
    @Autowired
    private AzureChatClient chatClient;
    @Autowired
    private AzureStreamClient streamClient;

    // ... 局部代码,隐去无关逻辑

    @Override
    protected AzureResponseChatCompletions postCompletions(AzureRequestChatCompletions request) throws Exception {
        return this.chatClient.completions(request);
    }

    @Override
    protected Flux<String> streamCompletions(AzureRequestChatCompletions request,
            SupplierStreamCompletedCallBack<AzureResponseChatCompletions> callback) throws Exception {
        return this.streamClient.completions(request, callback);
    }

    // ... 局部代码,隐去无关逻辑
}

通过上述两段代码示例,已经可以看出一些封装专用HTTP客户端的好处,但作为“同一个项目中使用多个不同接口的实例”,这里再额外给出通过百度接口调用对话服务的示例,稍有区别的是,这里给出的是一个“流式”接口的实现——也就是为“流式事件响应”方式提供服务的典型用法。

相关代码以供参考,具体调用参数请以百度文档为准。

// ... 局部代码,隐去无关逻辑
@Slf4j
@Component
public class BaiduStreamClient {
    @Autowired
    private ChatClientExceptionHandlerFactory exceptionHandlerFactory;
    @Autowired
    private TrendService trendService;
    protected WebClient webClient;
    private final String URL = "http://example.com"; // 出于安全原因,此处实际接口地址已隐去。
    @Autowired
    private BaiduOAuthService oAuthService;

    @PostConstruct
    public void init() {
        this.webClient = WebClient.builder()
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    public Flux<String> completions(BaiduRequestChatCompletions request,
            SupplierStreamCompletedCallBack<BaiduResponseChatCompletions> callback) {
        return Flux.create(emitter -> {
            BaiduStreamClientSubscriber subscriber = new BaiduStreamClientSubscriber(
                    emitter, callback, this.exceptionHandlerFactory,
                    RequestMetricContext.start(this.trendService, ChatSuppliers.BAIDU, true));
            Flux<String> response = this.getChatResponse(request);
            response.subscribe(subscriber);
            emitter.onDispose(subscriber);
        });
    }

    public Flux<String> getChatResponse(BaiduRequestChatCompletions request) {
        SkynetMarker marker = SkynetMarker.create(this.getClass());
        request.setStream(true);
        String request_body = JSONObject.toJSONString(request);
        log.info(marker, "向 Baidu API 发送请求:{}", request_body);
        String url = URL + "?access_token=" + this.oAuthService.getAccessToken();
        return webClient.post()
                .uri(url)
                .bodyValue(request_body)
                .retrieve()
                .bodyToFlux(String.class)
                .onErrorResume(exception -> Mono.error(exception));
    }
}

根据两个 HttpClient 的实现对比可以看出,我们使用这种对象式的写法,确实是可以让这些客户端变得“专用”,且每一个客户端都将具有独立的配置数据及依赖的外部服务。以百度专用的客户端为例,我们甚至可以在暴露自身 HttpClient 通信能力的基础上,额外在内部依赖由另一个外部服务提供的用来获取 AccessToken 的方法为当前客户端获取身份令牌,这种具有依赖关系的写法如果是通过常规的那种静态工具类是很难实现的。

这里以两个客户端的实现为例写出了对象化的好处,那么传统的静态工具类就一无是处吗?当然不会,StringUtils 仍然存在于 Spring 中就说明静态类一定是有自己的适用场景的。


上面例子中,我们对 HttpClient 使用了对象式的封装,主要是因为我们需要维持内部状态,假如没有外部依赖,且不需要维持上下文的过程式代码来说,静态方法反而是最优雅的封装方式。就像 StringUtils 体用的是对字符串公共操作能力的封装一样,我们的项目中也有一些关于数值获取的工具类,以规避 null 值带来的错误:

public final class DataHelper {
    private DataHelper() {
    }

    public static Double getOrDefault(Double value) {
        return DataHelper.getOrDefault(value, 0.0);
    }

    public static Double getOrDefault(Double value, Double defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        return value;
    }

    public static Long getOrDefault(Long value) {
        return DataHelper.getOrDefault(value, 0L);
    }

    public static Long getOrDefault(Long value, Long defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        return value;
    }

    public static Integer getOrDefault(Integer value) {
        return DataHelper.getOrDefault(value, 0);
    }

    public static Integer getOrDefault(Integer value, Integer defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        return value;
    }

    public static String getOrDefault(String value) {
        return DataHelper.getOrDefault(value, "");
    }

    public static String getOrDefault(String value, String defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        return value;
    }
}

或者是计算耗时区间的工具类,用来以部门规范的形式来统计耗时分布:

public final class TimeCostUtils {
    private TimeCostUtils() {
    }

    public static String range(long milliseconds) {
        long start = 0;
        long end = 5;
        while (milliseconds > end) {
            start = end;
            end = start * 2;
        }
        return String.format("%d-%dms", start, end);
    }
}

这些明显不存在外部依赖的方法,且都是独立无状态的,就完全没有必要以对象的形式来进行封装。如果存在迭代,也只需要在这些工具类中添加或修改方法即可。


以上为个人在多年编码过程中总结的一些经验,但正如《人月神话》所说,软件设计《没有银弹》,在具体的项目实施过程中,一定要以项目的实际特点为切入点,使用恰当的方法和手段完成功能的实现。程序设计是工程学,同样也是哲学,尽管没有绝对优秀的设计,但仍然可以在设计过程中尽量避免前人踩过的坑,以让我们的代码成果更稳定、更可靠。

本文仅是一个参考,如有不足还望指正。