列出所有已部署的REST端点(Spring Boot、Jersey)

43

使用Spring Boot是否有可能列出所有已配置的REST端点?Actuator在启动时列出所有现有路径,我想要类似于我的自定义服务,这样我就可以在启动时检查所有路径是否正确配置,并将此信息用于客户端调用。

我该如何做到这一点?我在我的服务bean上使用@Path/@GET注释,并通过ResourceConfig#registerClasses进行注册。

是否有一种方法查询所有路径的配置信息?

更新:我通过以下方式注册REST控制器

@Bean
public ResourceConfig resourceConfig() {
   return new ResourceConfig() {
    {  
      register(MyRestController.class);
    }
   };
}

更新2:我希望有类似的东西

GET /rest/mycontroller/info
POST /res/mycontroller/update
...

动机:当spring-boot应用启动时,我希望打印出所有已注册的控制器及其路径,以便不必再猜测要使用哪些端点。


如何使用Tomcat实现某事 - Akhil Surapuram
5个回答

22

可能最好的方法是使用一个ApplicationEventListener。从那里,您可以监听“应用程序完成初始化”事件,并从ApplicationEvent中获取ResourceModelResourceModel将拥有所有已初始化的Resource。然后,您可以像其他人提到的那样遍历Resource。下面是一种实现。其中部分实现来自DropwizardResourceConfig

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.classmate.TypeResolver;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import org.glassfish.jersey.server.model.Resource;
import org.glassfish.jersey.server.model.ResourceMethod;
import org.glassfish.jersey.server.model.ResourceModel;
import org.glassfish.jersey.server.monitoring.ApplicationEvent;
import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.glassfish.jersey.server.monitoring.RequestEventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class EndpointLoggingListener implements ApplicationEventListener {

    private static final TypeResolver TYPE_RESOLVER = new TypeResolver();

    private final String applicationPath;

    private boolean withOptions = false;
    private boolean withWadl = false;

    public EndpointLoggingListener(String applicationPath) {
        this.applicationPath = applicationPath;
    }

    @Override
    public void onEvent(ApplicationEvent event) {
        if (event.getType() == ApplicationEvent.Type.INITIALIZATION_APP_FINISHED) {
            final ResourceModel resourceModel = event.getResourceModel();
            final ResourceLogDetails logDetails = new ResourceLogDetails();
            resourceModel.getResources().stream().forEach((resource) -> {
                logDetails.addEndpointLogLines(getLinesFromResource(resource));
            });
            logDetails.log();
        }
    }

    @Override
    public RequestEventListener onRequest(RequestEvent requestEvent) {
        return null;
    }

    public EndpointLoggingListener withOptions() {
        this.withOptions = true;
        return this;
    }

    public EndpointLoggingListener withWadl() {
        this.withWadl = true;
        return this;
    }

    private Set<EndpointLogLine> getLinesFromResource(Resource resource) {
        Set<EndpointLogLine> logLines = new HashSet<>();
        populate(this.applicationPath, false, resource, logLines);
        return logLines;
    }

    private void populate(String basePath, Class<?> klass, boolean isLocator,
            Set<EndpointLogLine> endpointLogLines) {
        populate(basePath, isLocator, Resource.from(klass), endpointLogLines);
    }

    private void populate(String basePath, boolean isLocator, Resource resource,
            Set<EndpointLogLine> endpointLogLines) {
        if (!isLocator) {
            basePath = normalizePath(basePath, resource.getPath());
        }

        for (ResourceMethod method : resource.getResourceMethods()) {
            if (!withOptions && method.getHttpMethod().equalsIgnoreCase("OPTIONS")) {
                continue;
            }
            if (!withWadl && basePath.contains(".wadl")) {
                continue;
            }
            endpointLogLines.add(new EndpointLogLine(method.getHttpMethod(), basePath, null));
        }

        for (Resource childResource : resource.getChildResources()) {
            for (ResourceMethod method : childResource.getAllMethods()) {
                if (method.getType() == ResourceMethod.JaxrsType.RESOURCE_METHOD) {
                    final String path = normalizePath(basePath, childResource.getPath());
                    if (!withOptions && method.getHttpMethod().equalsIgnoreCase("OPTIONS")) {
                        continue;
                    }
                    if (!withWadl && path.contains(".wadl")) {
                        continue;
                    }
                    endpointLogLines.add(new EndpointLogLine(method.getHttpMethod(), path, null));
                } else if (method.getType() == ResourceMethod.JaxrsType.SUB_RESOURCE_LOCATOR) {
                    final String path = normalizePath(basePath, childResource.getPath());
                    final ResolvedType responseType = TYPE_RESOLVER
                            .resolve(method.getInvocable().getResponseType());
                    final Class<?> erasedType = !responseType.getTypeBindings().isEmpty()
                            ? responseType.getTypeBindings().getBoundType(0).getErasedType()
                            : responseType.getErasedType();
                    populate(path, erasedType, true, endpointLogLines);
                }
            }
        }
    }

    private static String normalizePath(String basePath, String path) {
        if (path == null) {
            return basePath;
        }
        if (basePath.endsWith("/")) {
            return path.startsWith("/") ? basePath + path.substring(1) : basePath + path;
        }
        return path.startsWith("/") ? basePath + path : basePath + "/" + path;
    }

    private static class ResourceLogDetails {

        private static final Logger logger = LoggerFactory.getLogger(ResourceLogDetails.class);

        private static final Comparator<EndpointLogLine> COMPARATOR
                = Comparator.comparing((EndpointLogLine e) -> e.path)
                .thenComparing((EndpointLogLine e) -> e.httpMethod);

        private final Set<EndpointLogLine> logLines = new TreeSet<>(COMPARATOR);

        private void log() {
            StringBuilder sb = new StringBuilder("\nAll endpoints for Jersey application\n");
            logLines.stream().forEach((line) -> {
                sb.append(line).append("\n");
            });
            logger.info(sb.toString());
        }

        private void addEndpointLogLines(Set<EndpointLogLine> logLines) {
            this.logLines.addAll(logLines);
        }
    }

    private static class EndpointLogLine {

        private static final String DEFAULT_FORMAT = "   %-7s %s";
        final String httpMethod;
        final String path;
        final String format;

        private EndpointLogLine(String httpMethod, String path, String format) {
            this.httpMethod = httpMethod;
            this.path = path;
            this.format = format == null ? DEFAULT_FORMAT : format;
        }

        @Override
        public String toString() {
            return String.format(format, httpMethod, path);
        }
    }
}

然后您只需要使用Jersey在Spring Boot application.properties中设置的属性spring.jersey.applicationPath作为应用程序路径,注册监听器即可。 这将成为根路径,就像在ResourceConfig子类上使用@ApplicationPath一样。

@Bean
public ResourceConfig getResourceConfig(JerseyProperties jerseyProperties) {
    return new JerseyConfig(jerseyProperties);
}
...
public class JerseyConfig extends ResourceConfig {

    public JerseyConfig(JerseyProperties jerseyProperties) {
        register(HelloResource.class);
        register(new EndpointLoggingListener(jerseyProperties.getApplicationPath()));
    }
}

需要注意的一件事情是,在Jersey servlet上默认没有设置load-on-startup。这意味着,直到第一个请求之前,Jersey不会在启动时加载。因此,您在第一次请求之前看不到监听器触发。我已经提出了一个问题可能会获得配置属性,但与此同时,您有几个选择:

  1. 将Jersey设置为过滤器而不是servlet。过滤器将在启动时加载。使用Jersey作为过滤器,大部分情况下实际上并没有什么区别。要配置此项,您只需要在application.properties中添加一个Spring Boot属性即可。

spring.jersey.type=filter
  • 另一个选择是覆盖Jersey ServletRegistrationBean并设置其loadOnStartup属性。以下是示例配置。其中一些实现直接从JerseyAutoConfiguration中获取。

  • @SpringBootApplication
    public class JerseyApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(JerseyApplication.class, args);
        }
    
        @Bean
        public ResourceConfig getResourceConfig(JerseyProperties jerseyProperties) {
            return new JerseyConfig(jerseyProperties);
        }
    
        @Bean
        public ServletRegistrationBean jerseyServletRegistration(
            JerseyProperties jerseyProperties, ResourceConfig config) {
            ServletRegistrationBean registration = new ServletRegistrationBean(
                    new ServletContainer(config), 
                    parseApplicationPath(jerseyProperties.getApplicationPath())
            );
            addInitParameters(registration, jerseyProperties);
            registration.setName(JerseyConfig.class.getName());
            registration.setLoadOnStartup(1);
            return registration;
        }
    
        private static String parseApplicationPath(String applicationPath) {
            if (!applicationPath.startsWith("/")) {
                applicationPath = "/" + applicationPath;
            }
            return applicationPath.equals("/") ? "/*" : applicationPath + "/*";
        }
    
        private void addInitParameters(RegistrationBean registration, JerseyProperties jersey) {
            for (Entry<String, String> entry : jersey.getInit().entrySet()) {
                registration.addInitParameter(entry.getKey(), entry.getValue());
            }
        }
    }
    

    更新

    看起来Spring Boot将会在1.4.0版本中 增加load-on-startup属性,所以我们不必覆盖Jersey的ServletRegistrationBean


    没有起作用,我得到了Jersey应用程序的所有端点 GET /rest/application.wadl OPTIONS /rest/application.wadl GET /rest/application.wadl/{path} OPTIONS /rest/application.wadl/{path} GET /rest/engine OPTIONS /rest/engine,但我正在寻找“GET /rest/engine/default/processinstance”... - Jan Galinski
    发布一个资源类的示例。关于OPTIONS和wadl,过滤掉它们并不会对其产生太大影响。 - Paul Samsotha
    使用Servlet 3,可以使用.@Provider注释注册监听器。在这种情况下,您还必须注入ServletContext以获取上下文路径。它也会立即启动,无需进行上述描述的配置。 - Sergey
    @peeskillet,我注意到你编写的监听器没有考虑通过.@ApplicationPath指定的路径。如何读取此注释的值? - Sergey
    @Sergey 最好将值直接传递给构造函数,因为有多种创建应用程序路径的方法。如果您真的想从注释中读取它,可以执行 YourJerseyConfig.class.getAnnotation(ApplicationPath.class).value()。我的示例 Spring Boot 配置类只是使用了来自 application.properties 文件的配置属性:spring.jersey.applicationPath。该值将出现在 JerseyProperties 对象中,或者您可以将其注入到 Spring 的 @Value 中。 - Paul Samsotha
    显示剩余7条评论

    12

    所有REST端点都列在/actuator/mappings端点中。

    通过属性management.endpoints.web.exposure.include激活映射端点。

    例如:management.endpoints.web.exposure.include=env,info,health,httptrace,logfile,metrics,mappings


    非常感谢,这对开发环境来说非常方便。我还在我的build.gradle(.kts)中添加了implementation("org.springframework.boot:spring-boot-starter-actuator"),并且它与你提供的URL (/actuator/mappings) 完美地配合使用。 - R. Du

    3
    你可以在你的ResourceConfig对象上使用ResourceConfig#getResources,然后通过迭代它返回的Set<Resource>来获取所需的信息。抱歉,我现在没有足够的资源去尝试它。:-p

    我尝试使用RunListeners的“finished”方法来实现。我得到了监听器已运行的输出,但是对getResources()的循环为空。请参见更新的问题。 - Jan Galinski

    3

    应用程序完全启动后,您可以询问ServerConfig

    ResourceConfig instance; 
    ServerConfig scfg = instance.getConfiguration();
    Set<Class<?>> classes = scfg.getClasses();
    

    classes包含所有缓存的端点类。

    javax.ws.rs.core.ConfigurationAPI文档中可以了解到:

    获取不可变的已注册JAX-RS组件(例如提供程序或功能)类集,以在可配置实例的范围内实例化、注入和利用。

    但是,在应用程序的init代码中无法执行此操作,因为类可能尚未完全加载。

    使用这些类,您可以扫描它们以查找资源:

    public Map<String, List<InfoLine>> scan(Class baseClass) {
        Builder builder = Resource.builder(baseClass);
        if (null == builder)
            return null;
        Resource resource = builder.build();
        String uriPrefix = "";
        Map<String, List<InfoLine>> info = new TreeMap<>();
        return process(uriPrefix, resource, info);
    }
    
    private Map<String, List<InfoLine>> process(String uriPrefix, Resource resource, Map<String, List<InfoLine>> info) {
        String pathPrefix = uriPrefix;
        List<Resource> resources = new ArrayList<>();
        resources.addAll(resource.getChildResources());
        if (resource.getPath() != null) {
            pathPrefix = pathPrefix + resource.getPath();
        }
        for (ResourceMethod method : resource.getAllMethods()) {
            if (method.getType().equals(ResourceMethod.JaxrsType.SUB_RESOURCE_LOCATOR)) {
                resources.add(
                    Resource.from(
                        resource.getResourceLocator()
                                .getInvocable()
                                .getDefinitionMethod()
                                .getReturnType()
                    )
                );
            }
            else {
                List<InfoLine> paths = info.get(pathPrefix);
                if (null == paths) {
                    paths = new ArrayList<>();
                    info.put(pathPrefix, paths);
                }
                InfoLine line = new InfoLine();
                line.pathPrefix = pathPrefix;
                line.httpMethod = method.getHttpMethod();
                paths.add(line);
                System.out.println(method.getHttpMethod() + "\t" + pathPrefix);
            }
        }
        for (Resource childResource : resources) {
            process(pathPrefix, childResource, info);
        }
        return info;
    }
    
    
    private class InfoLine {
        public String pathPrefix;
        public String httpMethod;
    }
    

    谢谢你的提示,约翰尼斯,但我不是在寻找所有类,我正在寻找它们注册的已解析路径。由于路径可以通过控制器继承(以及spring-boot根路径设置)堆叠,仅扫描具有路径注释的类行不通,对吗?或者你有一个具体场景的例子吗?我更新了问题。 - Jan Galinski
    1
    我已经修改了答案,并附上了我用来检索该信息的代码。我们不使用Spring-Boot,而是使用Tomcat/Jersey,但原则应该是相同的,因为你有一个ResourceConfig可以使用。只需尝试一下,看看它是否有效,或者Spring控制器继承是否会在其中引发问题。 - Johannes Jander
    我刚刚接受并奖励了peeskillets的答案。你的答案看起来非常接近,但他提供了完整的代码示例。无论如何,还是谢谢你! - Jan Galinski

    1

    1
    不适用于Jersey端点(这个问题是关于它的)。 - Paul Samsotha

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接