简体   繁体   中英

Spring Boot | How to dynamically add new tomcat connector?

I need to make my Spring Boot application start/stop listening on a new port dynamically. I understand a new tomcat connector needs to be injected into Spring context for this.

I'm able to add a connector using a ServletWebServerFactory bean and tomcatConnectorCustomizer . But this bean is loaded only during Spring Bootup.

@Bean
public ServletWebServerFactory servletContainer() {

    TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
    TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
        connector.setPort(serverPort);

        connector.setScheme("https");
        connector.setSecure(true);

        Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();

        protocol.setSSLEnabled(true);
        protocol.setKeystoreType("PKCS12");
        protocol.setKeystoreFile(keystorePath);
        protocol.setKeystorePass(keystorePass);
        protocol.setKeyAlias("spa");
        protocol.setSSLVerifyClient(Boolean.toString(true));
        tomcat.addConnectorCustomizers(tomcatConnectorCustomizer);
        return tomcat;

    }
}

Is there any way to add a tomcat connector during run time? Say on a method call?

Update: Connector created but all requests to it return 404:

I've managed to add a Tomcat connector at runtime. But the request made to that port are not going to my RestController.

    TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();

    TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
        Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
        connector.setScheme("http");
        connector.setSecure(false);
        connector.setPort(8472);
        protocol.setSSLEnabled(false);
    };
    tomcat.addConnectorCustomizers(tomcatConnectorCustomizer);

    tomcat.getWebServer().start();

How should I proceed further?

Hi here is my sample project: sample project

1- Main Application (DemoApplication.java):

    @SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
    SpringApplication.run(DemoApplication.class, args);
    }
}

2 - Config file (AppConfig.java):

@Configuration
public class AppConfig {

@Autowired
private ServletWebServerApplicationContext server;

private static FilterConfig filterConfig = new FilterConfig();

@PostConstruct
void init() {
    //setting default port config
    filterConfig.addNewPortConfig(8080, "/admin");
}

@Bean
@Scope(value = WebApplicationContext.SCOPE_APPLICATION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public FilterConfig createFilterConfig() {
    return filterConfig;
}

public void addPort(String schema, String domain, int port, boolean secure) {
    TomcatWebServer ts = (TomcatWebServer) server.getWebServer();
    synchronized (this) {
        ts.getTomcat().setConnector(createConnector(schema, domain, port, secure));
    }
}

public void addContextAllowed(FilterConfig filterConfig, int port, String context) {
    filterConfig.addNewPortConfig(port, context);
}

 public void removePort(int port) {
    TomcatWebServer ts = (TomcatWebServer) server.getWebServer();
    Service service = ts.getTomcat().getService();
    synchronized (this) {
        Connector[] findConnectors = service.findConnectors();
        for (Connector connector : findConnectors) {
            if (connector.getPort() == port) {
                try {
                    connector.stop();
                    connector.destroy();
                    filterConfig.removePortConfig(port);
                } catch (LifecycleException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

private Connector createConnector(String schema, String domain, int port, boolean secure) {
    Connector conn = new Connector("org.apache.coyote.http11.Http11NioProtocol");
    conn.setScheme(schema);
    conn.setPort(port);
    conn.setSecure(true);
    conn.setDomain(domain);
    if (secure) {
        // config secure port...
    }
    return conn;
}
}

3 - Filter (NewPortFilter.java):

public class NewPortFilter {
@Bean(name = "restrictFilter")
public FilterRegistrationBean<Filter> retstrictFilter(FilterConfig filterConfig) {
    Filter filter = new OncePerRequestFilter() {

        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {

            // get allowed url contexts
            Set<String> config = filterConfig.getConfig().get(request.getLocalPort());
            if (config == null || config.isEmpty()) {
                response.sendError(403);
            }
            boolean accepted = false;
            for (String value : config) {
                if (request.getPathInfo().startsWith(value)) {
                    accepted = true;
                    break;
                }
            }
            if (accepted) {
                filterChain.doFilter(request, response);
            } else {
                response.sendError(403);
            }
        }
    };
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<Filter>();
    filterRegistrationBean.setFilter(filter);
    filterRegistrationBean.setOrder(-100);
    filterRegistrationBean.setName("restrictFilter");
    return filterRegistrationBean;
}
}

4 - Filter Config (FilterConfig.java):

public class FilterConfig {

    private Map<Integer, Set<String>> acceptedContextsByPort = new ConcurrentHashMap<>();

    public void addNewPortConfig(int port, String allowedContextUrl) {
        if(port > 0 && allowedContextUrl != null) {
            Set<String> set = acceptedContextsByPort.get(port);
            if (set == null) {
                set = new HashSet<>();
            }
            set = new HashSet<>(set);
            set.add(allowedContextUrl);
            acceptedContextsByPort.put(port, set);
        }
    }

    public void removePortConfig(int port) {
        if(port > 0) {
            acceptedContextsByPort.remove(port);
        }
    }

    public Map<Integer, Set<String>> getConfig(){
        return acceptedContextsByPort;
    }
}

5 - Controller (TestController.java):

@RestController
public class TestController {
@Autowired
AppConfig config;

@Autowired
FilterConfig filterConfig;

@GetMapping("/admin/hello")
String test() {
    return "hello test";
}

@GetMapping("/alternative/hello")
String test2() {
    return "hello test 2";
}

@GetMapping("/admin/addNewPort")
ResponseEntity<String> createNewPort(@RequestParam Integer port, @RequestParam String context) {
    if (port == null || port < 1) {
        return new ResponseEntity<>("Invalid Port" + port, HttpStatus.BAD_REQUEST);
    }
    config.addPort("http", "localhost", port, false);
    if (context != null && context.length() > 0) {
        config.addContextAllowed(filterConfig, port, context);
    }

    return new ResponseEntity<>("Added port:" + port, HttpStatus.OK);
}

@GetMapping("/admin/removePort")
ResponseEntity<String> removePort(@RequestParam Integer port) {
    if (port == null || port < 1) {
        return new ResponseEntity<>("Invalid Port" + port, HttpStatus.BAD_REQUEST);
    }
    config.removePort(port);

    return new ResponseEntity<>("Removed port:" + port, HttpStatus.OK);
 }
}

How to test it?

In a browser:

1 - try:

http://localhost:8080/admin/hello

Expected Response : hello test

2 - try:

http://localhost:8080/admin/addNewPort?port=9090&context=alternative

Expected Response : Added port:9090

3 - try:

http://localhost:9090/alternative/hello

Expected Response : hello test 2

4 - try expected errors:

http://localhost:9090/alternative/addNewPort?port=8181&context=alternative

Expected Response (context [alternative] allowed but endpoint not registered in controller for this context) : Whitelabel Error Page...

http://localhost:9090/any/hello

Expected Response (context [any] not allowed) : Whitelabel Error Page...

http://localhost:8888/any/hello

Expected Response (invalid port number) : ERR_CONNECTION_REFUSED

http://localhost:8080/hello

Expected Response (no context allowed [/hello]) : Whitelabel Error Page...

5 - try remove a port:

http://localhost:8080/admin/removePort?port=9090

6 - check removed port:

http://localhost:9090/alternative/hello

Expected Response (port closed) : ERR_CONNECTION_REFUSED

I hope it helps.

You should create ServletWebServerFactory bean as a prototype bean using @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) .

Now in bean where you need new tomcat connector to be injected into Spring context(MySingletonBean in example) autowire the application context and get ServletWebServerFactory bean(MyPrototypeBean in example) from getBean method. In this way you will always get new tomcat connector bean.

Following is a simple sample code:-

public class MySingletonBean {

    @Autowired
    private ApplicationContext applicationContext;

    public void showMessage(){
        MyPrototypeBean bean = applicationContext.getBean(MyPrototypeBean.class);
    }
}

Based on the accepted answer of @ariel-carrera, I did it a little differently (IMO cleaner):

  1. Define a normal Spring controller for handling the request on any dynamic port
import package.IgnoredBean;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

import static org.apache.commons.lang3.reflect.MethodUtils.getMethodsWithAnnotation;

//must be declared in a separate java file so that it's not picked up by component scanning as inner class
@IgnoredBean
@RestController
@Slf4j
@RequiredArgsConstructor
class DynamicController {

    static final Method HANDLER_METHOD = getMethodsWithAnnotation(DynamicController.class, RequestMapping.class)[0];
    
    private final String myContext;
    
    @RequestMapping
    public Object handle(
            @RequestBody Map<String, Object> body,
            @RequestParam MultiValueMap<String, Object> requestParams,
            @PathVariable Map<String, Object> pathVariables
    ) {
        Map<String, Object> allAttributes = new HashMap<>(body.size() + requestParams.size() + pathVariables.size());
        allAttributes.putAll(body);
        allAttributes.putAll(pathVariables);
        requestParams.forEach((name, values) -> allAttributes.put(name, values.size() > 1 ? values : values.get(0)));
        log.info("Handling request for '{}': {}", myContext, allAttributes);
        return allAttributes;
    }

    // this handler only affects this particular controller. Otherwise it will use any of your regular @ControllerAdvice beans or fall back to spring's default
    @ExceptionHandler
    public ResponseEntity<?> onError(Exception e) {
        log.debug("something happened in '{}'", myContext, e);
        return ResponseEntity.status(500).body(Map.of("message", e.getMessage()));
    }
}
  • Must be declared in its own file
  • It is not a Spring Bean, we will instantiate it manually for each port and give it some context objects that are relevant to the port owner.
  • Note the @IgnoredBean, a custom annotation:
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target(TYPE)
@Retention(RUNTIME)
public @interface IgnoredBean {
}
@SpringBootApplication
@ComponentScan(excludeFilters = @ComponentScan.Filter(IgnoredBean.class))
...
public class MyApplication{...}
  1. The rest of it
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.coyote.http11.Http11NioProtocol;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.embedded.tomcat.TomcatWebServer;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition;
import org.springframework.web.servlet.mvc.condition.RequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toUnmodifiableSet;

@Service
@RequiredArgsConstructor
@Slf4j
class DynamicControllerService {
    private final RequestMappingHandlerMapping requestHandlerMapper;
    private final Map<Integer, RequestMappingInfo> mappingByPort = new ConcurrentHashMap<>();
    private Tomcat tomcat;

    @Autowired
    void setTomcat(ServletWebServerApplicationContext context) {
        tomcat = ((TomcatWebServer) context.getWebServer()).getTomcat();
    }

    public int addMapping(@Nullable Integer givenPort, RequestMethod method, String path, Object myContext) {
        val connector = new Connector(new Http11NioProtocol());
        connector.setThrowOnFailure(true);
        //0 means it will pick any available port
        connector.setPort(Optional.ofNullable(givenPort).orElse(0));
        try {
            tomcat.setConnector(connector);
        } catch (IllegalArgumentException e) {
            // if it fails to start the connector, the object will still be left inside here
            tomcat.getService().removeConnector(connector);
            val rootCause = ExceptionUtils.getRootCause(e);
            throw new IllegalArgumentException(rootCause.getMessage(), rootCause);
        }
        int port = connector.getLocalPort();
        val mapping = RequestMappingInfo
                .paths(path)
                .methods(method)
                .customCondition(new PortRequestCondition(port))
                .build();
        requestHandlerMapper.registerMapping(
                mapping,
                new DynamicController("my context for port " + port),
                DynamicController.HANDLER_METHOD
        );
        mappingByPort.put(port, mapping);
        log.info("added mapping {} {} for port {}", method, path, port);
        return port;
    }

    public void removeMapping(Integer port) {
        Stream.of(tomcat.getService().findConnectors())
                .filter(connector -> connector.getPort() == port)
                .findFirst()
                .ifPresent(connector -> {
                    try {
                        tomcat.getService().removeConnector(connector);
                        connector.destroy();
                    } catch (IllegalArgumentException | LifecycleException e) {
                        val rootCause = ExceptionUtils.getRootCause(e);
                        throw new IllegalArgumentException(rootCause.getMessage(), rootCause);
                    }
                    val mapping = mappingByPort.get(port);
                    requestHandlerMapper.unregisterMapping(mapping);
                    log.info("removed mapping {} {} for port {}",
                            mapping.getMethodsCondition().getMethods(),
                            Optional.ofNullable(mapping.getPathPatternsCondition())
                                    .map(PathPatternsRequestCondition::getPatternValues)
                                    .orElse(Set.of()),
                            port
                    );
                });
    }

    @RequiredArgsConstructor
    private static class PortRequestCondition implements RequestCondition<PortRequestCondition> {

        private final Set<Integer> ports;

        public PortRequestCondition(Integer... ports) {
            this.ports = Set.of(ports);
        }

        @Override
        public PortRequestCondition combine(PortRequestCondition other) {
            return new PortRequestCondition(Stream.concat(ports.stream(), other.ports.stream()).collect(toUnmodifiableSet()));
        }

        @Override
        public PortRequestCondition getMatchingCondition(HttpServletRequest request) {
            return ports.contains(request.getLocalPort()) ? this : null;
        }

        @Override
        public int compareTo(PortRequestCondition other, HttpServletRequest request) {
            return 0;
        }
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM