almost 5 years ago

本文將簡單示範如何在 Spring Boot 上架設支援 http/2 的網站,以及實際測試 http/2 的 Request and Response Multiplexing,和實作 server push。

若你想更深入了解 http/2 底層及其運作原理,可參考 https://hpbn.co/http2/

環境

  • jdk 1.8 以上
  • gradle 2.3 以上

embedded container

目前 embedded container 僅 jetty 和 undertow 可支援 http/2。
雖然用 undertow 定比較簡單,但是我在實作 server push 時一直無法成功,因此最後還是採取用 jetty。

http/2 設定主要參考 http/2 with jetty,若是有興趣了解如何用 undertow,可參考 http/2 with undertow

如何完整實作整個範例

全部原始碼可在這裡下載

build with gradle

首先需要設定好 build.gradle

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:1.3.5.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'spring-boot'

jar {
    baseName = 'gs-spring-boot'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8


dependencies {
    compile("org.springframework.boot:spring-boot-starter-web:1.3.5.RELEASE")
    compile("org.springframework.boot:spring-boot-starter-jetty:1.3.5.RELEASE")
    compile("org.eclipse.jetty.http2:http2-server:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-alpn-server:9.3.10.v20160621")
    //compile("org.springframework.boot:spring-boot-starter-undertow:1.3.5.RELEASE")
    compile("org.mortbay.jetty.alpn:alpn-boot:8.1.8.v20160420")

    //因應 buildscript 下列 model 要指定版本
    compile("org.eclipse.jetty:jetty-http:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-io:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-jndi:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-plus:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-security:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-server:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-annotations:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-continuation:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-servlet:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-servlets:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-util:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-webapp:9.3.10.v20160621")
    compile("org.eclipse.jetty:jetty-xml:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:javax-websocket-client-impl:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:javax-websocket-server-impl:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-api:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-client:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-common:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-server:9.3.10.v20160621")
    compile("org.eclipse.jetty.websocket:websocket-servlet:9.3.10.v20160621")


    //lib for http2 client
    //compile("org.springframework:spring-web:4.3.0.RELEASE")
    //compile("org.springframework:spring-core:4.3.0.RELEASE")
    //compile("com.squareup.okhttp3:okhttp:3.3.1")
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.3'
}

一個簡單 Spring Boot 網站入口

撰寫一個 Application.class

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

實作一個可支援 http/2 的 EmbeddedServletContainerCustomizer

撰寫 JettyHttp2Customizer.class

@Component
public class JettyHttp2Customizer implements EmbeddedServletContainerCustomizer {

    private final ServerProperties serverProperties;

    @Autowired
    public JettyHttp2Customizer(ServerProperties serverProperties) {
        this.serverProperties = serverProperties;
    }

    @Override
    public void customize(ConfigurableEmbeddedServletContainer container) {
        JettyEmbeddedServletContainerFactory factory = (JettyEmbeddedServletContainerFactory) container;

        factory.addServerCustomizers(new JettyServerCustomizer() {
            @Override
            public void customize(Server server) {
                if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
                    ServerConnector connector = (ServerConnector) server.getConnectors()[0];
                    int port = connector.getPort();
                    SslContextFactory sslContextFactory = connector
                            .getConnectionFactory(SslConnectionFactory.class).getSslContextFactory();
                    HttpConfiguration httpConfiguration = connector
                            .getConnectionFactory(HttpConnectionFactory.class).getHttpConfiguration();

                    configureSslContextFactory(sslContextFactory);
                    ConnectionFactory[] connectionFactories = createConnectionFactories(sslContextFactory, httpConfiguration);

                    ServerConnector serverConnector = new ServerConnector(server, connectionFactories);
                    serverConnector.setPort(port);
                    // override existing connectors with new ones
                    server.setConnectors(new Connector[]{serverConnector});
                }
            }

            private void configureSslContextFactory(SslContextFactory sslContextFactory) {
                sslContextFactory.setCipherComparator(HTTP2Cipher.COMPARATOR);
                sslContextFactory.setUseCipherSuitesOrder(true);
            }

            private ConnectionFactory[] createConnectionFactories(SslContextFactory sslContextFactory,
                                                                  HttpConfiguration httpConfiguration) {
                SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, "alpn");
                ALPNServerConnectionFactory alpnServerConnectionFactory =
                        new ALPNServerConnectionFactory("h2", "h2-17", "h2-16", "h2-15", "h2-14");

                HTTP2ServerConnectionFactory http2ServerConnectionFactory =
                        new HTTP2ServerConnectionFactory(httpConfiguration);

                return new ConnectionFactory[]{sslConnectionFactory, alpnServerConnectionFactory,
                        http2ServerConnectionFactory};
            }
        });
    }
}

設定 SSL

雖然 http/2 沒規定一定要加密協定(例如 SSL),但目前大部分瀏覽器的 http/2 都需要跑在 https 上面。

產生認證

進入專案家目錄底下輸入

Enter keystore password:  
Re-enter new password:
What is your first and last name?
  [Unknown]:  
What is the name of your organizational unit?
  [Unknown]:  
What is the name of your organization?
  [Unknown]:  
What is the name of your City or Locality?
  [Unknown]:  
What is the name of your State or Province?
  [Unknown]:  
What is the two-letter country code for this unit?
  [Unknown]:  
Is CN=Unknown, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=Unknown correct?


會產一個認證檔名為:keystore.p12

設定 Spring Boot

修改 application.properties 如下

server.port = 8443
server.ssl.enable = true
server.ssl.key-store = keystore.p12
server.ssl.key-store-password = mypassword
server.ssl.keyStoreType = PKCS12
server.ssl.keyAlias = tomcat

至此你的 Spring Boot 網站已可以支援 SSL 連線。

修改 jvm 啟動參數

加入下面啟動參數

java -jar -Xbootclasspath/p:${USER_HOME_DIR}/.gradle/caches/modules-2/files-2.1/org.mortbay.jetty.alpn/alpn-boot/8.1.8y.v20160420/a6414838c42ddfa1110ecfac50a9906e330940fc/alpn-boot-8.1.8.v20160420.jar web.jar

這時候啟動你的 web,一個支援 http/2 的網站就完成了。

來驗證吧

先在瀏覽器上安裝 "http 2 and spdy indicator" ( chrome 和 firefox 都有這套件)。
開啟頁面 http://localhost:8443
如果在瀏覽器右上角看到閃電亮起來如下圖,就代表成功啦!


如果在瀏覽器右上角看到閃電亮起來,就代表成功啦!

http/2 的 Request and Response Multiplexing 和 server push 功能測試

http/2 在 TCP/IP 四層中的 Application 層中多塞了一層 Binnary Framing Layer,而這機制也改變了 server 和 client 交換資料的方式:

Request and Response Multiplexing

有別於 http/1.x 中一個 request 就佔據一個 connection,http/2 中一個 connection 可以同時提供給多個 request 和 response 交錯傳輸資料。為了更直觀了解這特色,先寫個簡單的 index.html,開場就先跟 server 發出 20 個影像檔請求:

<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
    <img src="1.jpeg">
    <img src="2.jpeg">
    <img src="3.jpeg">
    <img src="4.jpeg">
    <img src="5.jpeg">
    <img src="6.jpeg">
    <img src="7.jpeg">
    <img src="8.jpeg">
    <img src="9.jpeg">
    <img src="10.jpeg">
    <img src="11.jpeg">
    <img src="12.jpeg">
    <img src="13.jpeg">
    <img src="14.jpeg">
    <img src="15.jpeg">
    <img src="16.jpeg">
    <img src="17.jpeg">
    <img src="18.jpeg">
    <img src="19.jpeg">
    <img src="20.jpeg">
</body>
</html>

若是用 http/1.x ,在 chrome 的開發者工具中可以看到 browser 跟 server 請求資源的時間序如下圖:


可以看到整個頁面耗時 884ms,其中 image 的請求不是同時發給 server,而是有先後順序;這是因為 chrome 預設對同一個 web server 同時只能發出六個請求。

而當使用 http/2 以後,變成一次同時發出全部請求:


而耗時降到 450ms 以下。

更棒的是只要你修改設定讓網站支援 http/2,而不用修改到任何程式碼。

測試 server push

也由於 Binary framing Layer,http/2 也打破了以往一個 request 對應一個 response 的模式,可以一個 request對應多個 respnse。這就是 server push,允許由 server 主動 push 。
可用來指定某些資源在頁面讀取前先 push 到 client 端 。
實作上很簡單,下面是一個簡單的 範例,利用 filter 抓取連到 index.html 的請求,透過 jettyRequest push 資源給 client 端。

ServerPushFilter.class

@WebFilter(filterName = "serverPushFilter", urlPatterns = "/index.html")
public class ServerPushFilter implements Filter {
    Logger logger = Logger.getLogger(ServerPushFilter.class);
    public void init(FilterConfig filterConfig) throws ServletException {

    }
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        Request jettyRequest = (Request) request;
        if (jettyRequest.isPushSupported()) {
            logger.info("server push");
            for(int i=1;i<20;i++) {
                jettyRequest.getPushBuilder()
                        .path("/" + Integer.toString(i) + ".jpeg")
                        .push();
            }
        } else {
            logger.info("non http2");
        }
        chain.doFilter(request, response);
    }
    public void destroy() {

    }
}

再使用開發者工具看一下這次發出請求的狀況:


可以看到雖然整個耗時約略相等,但在 index.html 載入完以後,每張圖片的載入時間都在 16ms 上下;相對 Request and Response Multiplexing 每個影像檔資源耗時約在 30ms 左右。

http/2 其他特色

其他諸如 Stream Prioritization、Flow Control 等,但目前還查不到如和實作,有機會再分享。

結論

本文簡介了如何讓 Spring Boot 架的網站支援 http/2,而不用透過 nginx 或 apache 這樣的 web server,希望能對大家有幫助。

← 用 Spring Boot 實作預防暴力登入嘗試的機制