当前位置:首页 > 文章列表 > 文章 > java教程 > 模块化项目为什么读不到服务实现:uses 与 provides 排查

模块化项目为什么读不到服务实现:uses 与 provides 排查

来源:17golang原创 2026-10-08 15:26:51 0浏览 收藏

Java 模块化项目里 ServiceLoader 读不到服务实现,最常见的原因集中在四处:消费模块没有声明 uses,实现模块没有声明 provides ... with ...,提供者类型不满足公开构造规则,或者实现模块根本没有进入 module path 与模块解析图。先把这四处对齐,再看业务代码,通常比反复改 ServiceLoader.load() 更快。

Java SE 25 ServiceLoader 官方文档:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ServiceLoader.html

在命名模块中,服务消费和服务提供都是模块契约的一部分:消费方用 uses 声明要发现什么,提供方用 provides ... with ... 声明由哪个类型提供。应用模块通常不应该直接 requires 实现模块。

我第一次遇到的现象:代码没报错,服务列表却是空的

我会用一个“文本格式化器”来复现这类问题。项目被拆成三个命名模块:com.example.api 放接口,com.example.impl 放 Markdown 实现,com.example.app 负责加载并调用服务。拆分之前直接 new MarkdownFormatter() 很直观,改成 ServiceLoader 后,最让人困惑的就是实现类明明已经编译,迭代器里却没有元素。

这里有一个容易带偏排查方向的直觉:既然应用要使用实现,是不是应该在应用模块里写 requires com.example.impl?这样虽然可能让实现模块进入解析图,却破坏了服务机制想要的解耦。正确关系是应用依赖 API,并声明自己消费服务;实现模块依赖 API,并声明自己提供服务。模块系统通过服务绑定找到提供者。

最小配方:三个模块各自只做一件事

Java API、应用、实现三个模块与 uses、provides、ServiceLoader 的静态关系图
图1:静态结构图。应用模块读取并声明使用服务,实现模块读取 API 并声明提供者;应用不直接依赖实现模块。

先建立 API 模块。接口必须能被消费模块和实现模块访问,因此 API 模块需要导出接口所在包:

// com.example.api/module-info.java:只导出服务接口所在包
module com.example.api {
    exports com.example.api;
}
package com.example.api;

// 服务类型保持小而稳定,调用方只依赖这个契约
public interface TextFormatter {
    String format(String text);
}

消费模块只读取 API 模块,并通过 uses 表明它会用 ServiceLoader 查找这个服务:

// com.example.app/module-info.java:声明读取 API 和消费服务
module com.example.app {
    requires com.example.api;
    uses com.example.api.TextFormatter;
}

实现模块同样读取 API,但不需要把实现包导出给应用。它通过 provides 把服务类型与提供者类型配对:

// com.example.impl/module-info.java:把接口和具体提供者绑定
module com.example.impl {
    requires com.example.api;
    provides com.example.api.TextFormatter
        with com.example.impl.MarkdownFormatter;
}
package com.example.impl;

import com.example.api.TextFormatter;

// 提供者是 public,并具有隐式 public 无参构造方法
public final class MarkdownFormatter implements TextFormatter {
    @Override
    public String format(String text) {
        // 示例只演示服务发现,格式化逻辑保持简单
        return "**" + text + "**";
    }
}

这个配置里没有 exports com.example.impl。这不是遗漏:ServiceLoader 并不要求实现包对消费模块导出。减少实现包的外部可见性,正是模块服务带来的一个直接好处。

关键调用:加载动作必须发生在声明 uses 的模块中

应用的入口类可以只引用服务接口。为了让“没有提供者”不再静默通过,我更喜欢先把结果收集起来,再给出明确异常:

package com.example.app;

import com.example.api.TextFormatter;
import java.util.List;
import java.util.ServiceLoader;

public final class Main {
    public static void main(String[] args) {
        // 加载调用位于声明 uses 的 com.example.app 模块中
        List providers = ServiceLoader
            .load(TextFormatter.class)
            .stream()
            .map(ServiceLoader.Provider::get)
            .toList();

        // 空列表通常说明提供声明或模块解析范围仍有问题
        if (providers.isEmpty()) {
            throw new IllegalStateException("没有发现 TextFormatter 服务实现");
        }

        // 示例逐个调用所有已发现的实现,便于观察多提供者情况
        for (TextFormatter formatter : providers) {
            System.out.println(formatter.format("module service"));
        }
    }
}

Java 语言规范把 uses 定义为“当前模块消费某个服务”的声明,把 provides 定义为“当前模块为某个服务提供哪些提供者”的声明。它们不是注释,也不是给构建工具看的可选元数据,而是命名模块服务发现的一部分。

完整片段:编译和启动时都要使用 module path

源码目录以模块名为第一层目录时,可以用下面的方式编译三个模块并启动应用:

# 一次编译 API、实现与应用三个命名模块
javac -d out --module-source-path src \
  -m com.example.api,com.example.impl,com.example.app

# 从 module path 启动应用模块,不要把实现 JAR 只放到 class path
java --module-path out \
  -m com.example.app/com.example.app.Main

预期结果是应用至少发现一个 TextFormatter 并输出格式化文本。这里没有把命令输出包装成真实运行证据;落地到自己的项目时,应把 out 换成实际模块产物目录,并确认实现模块的 JAR 或 exploded module 确实位于 module path。

如果编译成功但运行时返回空列表,我首先比较启动命令,而不是继续改接口。IDE 里也要区分 module path 和 class path:一个包含 module-info.class 的实现 JAR 如果被放错位置,运行期看到的模块图可能和编译期并不相同。

我会按四个位置排查,而不是反复改 ServiceLoader

Java ServiceLoader uses、provides、提供者类型与模块解析四类故障的静态排查关系图
图2:静态排查图。消费声明、提供声明、实现类条件和模块可观测性分别对应不同故障信号,不是终端截图或运行证据。
检查位置正确写法常见现象修复重点
消费模块uses 完整服务类型名调用处出现服务配置错误,或服务契约不完整把 uses 放在真正调用 ServiceLoader 的命名模块
实现模块provides 服务 with 提供者服务列表为空确认服务类型与 uses 中完全一致
提供者类型public 顶级/静态类型,满足构造或 provider 方法规则迭代时抛 ServiceConfigurationError检查公开无参构造或公开静态 provider 方法
模块路径实现模块可观测并进入解析图编译正常,运行时找不到实现检查 module path、模块名与启动根模块

排查 uses 时,我会看“谁调用了 ServiceLoader.load”,而不是看接口定义在哪个模块。接口可以定义在 API 模块,真正的消费声明应该在执行加载的模块里。若加载被封装在另一个库模块中,uses 也要跟着加载动作移动。

排查 provides 时,我会逐字比较服务类型的完全限定名。提供者必须声明在当前实现模块中。若提供者使用普通构造方式,它需要是服务类型的子类型,并提供公开无参构造;另一种合法变体是提供公开静态无参 provider() 方法,由该方法返回服务类型的子类型。

两个容易混淆的变体

同一个服务允许多个实现

provides 的 with 子句可以列出多个提供者,不同实现模块也可以分别提供同一服务。ServiceLoader 返回的是可迭代的提供者集合,不承诺业务优先级。需要“默认实现”时,不要依赖偶然顺序,应该在服务契约中增加明确元数据,再由应用按规则选择。

// 一个实现模块可以为同一服务声明多个提供者
module com.example.impl {
    requires com.example.api;
    provides com.example.api.TextFormatter with
        com.example.impl.MarkdownFormatter,
        com.example.impl.PlainTextFormatter;
}

模块化 JAR 与传统 META-INF/services 不要混着想

传统 class path SPI 使用 META-INF/services/服务完全限定名 文件登记实现;显式命名模块则把服务关系写进 module-info.java。自动模块可以暴露传统服务配置,但当项目已经使用显式模块描述符时,我会优先统一到 uses 与 provides,避免一部分配置在描述符、一部分配置藏在资源文件中。

兼容坑:能编译不代表运行时模块图相同

这类问题最明显的代价是排查范围跨越源码、构建和启动三层。接口和实现代码都可能完全正确,但打包插件漏掉 module-info.class、实现 JAR 被放到 class path、运行脚本使用了不同模块目录,都会让运行时服务发现与开发环境不一致。

我会保留一条最小化启动命令作为部署检查项,并在需要时添加 --show-module-resolution 观察模块解析信息。它适合确认实现模块是否进入解析图,但不应把整段控制台输出做成文章配图;记录模块名和服务绑定结论就够了。

最后的验收清单

  • API 模块导出了服务接口所在包,消费与实现模块都能读取它。
  • 调用 ServiceLoader.load 的模块声明了 uses。
  • 实现模块声明了 provides 服务 with 提供者,类型名完全一致。
  • 提供者是公开顶级或静态类型,并满足构造方法或 provider() 方法约束。
  • 实现模块产物位于运行时 module path,模块名与启动配置一致。
  • 应用不直接依赖具体实现,业务也不依赖提供者迭代顺序。

对我来说,这套排查顺序最大的收获不是“记住两行 module-info”,而是把服务发现看成一份跨模块契约:uses 说明需求,provides 说明供给,module path 决定供给是否可观测,提供者规则决定实例能否创建。四件事分开检查,空列表和配置错误就不再是一团模糊的反射问题。

相关问题

实现包必须 exports 吗?

通常不需要。服务提供者可以位于未导出的实现包中,消费方只通过服务接口使用它,这正好缩小模块公开 API。

应用模块要 requires 实现模块吗?

服务解耦场景通常不应该这样做。应用读取 API 并声明 uses,实现模块声明 provides;只要实现模块可观测,服务绑定会把合适的提供者纳入模块图。

为什么实现类有带参数构造后就加载失败?

普通提供者构造方式要求公开无参构造。若创建过程必须经过工厂逻辑,可以改用规范允许的公开静态无参 provider() 方法,并让它返回服务类型的实现。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
PHP 文件上传如何同时限制扩展名、MIME 与文件大小PHP 文件上传如何同时限制扩展名、MIME 与文件大小
上一篇
PHP 文件上传如何同时限制扩展名、MIME 与文件大小
html/template 与 text/template 的转义边界有什么不同
下一篇
html/template 与 text/template 的转义边界有什么不同
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    378次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    448次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    457次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    400次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    227次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码