生產中的Vertx - Teemo Tech Blog

banq發表於2019-02-20

Vert.x是一個非常高效能的庫,用於實現低延遲服務。它的多反應堆模式使得在幾毫秒內每秒處理許多請求成為可能。
使用實時出價,我們每秒收到數千個請求,我們必須在不到100毫秒的時間內回答。這就是我們選擇Vert.x的原因。
在本文中,我將向您介紹我們從基於該庫的4年運營生產服務中學到的經驗教訓。

基礎
vert.x應用程式看起來像下面的程式碼片段。main方法建立一個vertx例項; 然後使用一些部署選項透過此例項部署Verticle。

public static void main(String[] args) {
    VertxOptions vertxOptions = new VertxOptions().setMetricsOptions(metricsOptions);
    Vertx vertx = Vertx.vertx(vertxOptions);
  
    DeploymentOptions deploymentOptions = new DeploymentOptions().setInstances(instances);
    vertx.deployVerticle(MainVerticle.class, deploymentOptions);
}


端點
端點透過路由器公開,路由器將路由對映到處理程式,請求處理程式基本上是您的業務程式碼。

public class MainVerticle extends AbstractVerticle {
    @Override
    public void start(Vertx vertx) {
        Router router = Router.router(vertx);
        router.route().consumes("application/json");
        router.route().produces("application/json");
        router.route().handler(BodyHandler.create());
        router.get("/ping").handler(pingHandler);
        vertx.createHttpServer().requestHandler(router).listen(8080);
    }
}


請求處理程式
處理程式負責對系統中發生的事情做出反應。一旦非同步操作結束(透過失敗,成功或超時),它們將管理程式碼執行。

public class PingHandler implements Handler<RoutingContext> {
    public void handler(RoutingContext context) {
        context.response().setStatusCode(200).end("pong");
    }
}


失敗處理程式
如果在請求處理期間發生故障,我們還可以將故障處理程式附加到Route以執行一段程式碼。
失敗處理程式非常適合確保連線正確關閉,將度量標準記錄為可幫助我們分析應用程式行為的錯誤型別,尤其是意外錯誤。

public class FailureHandler implements Handler<RoutingContext> {
  
    public void handle(RoutingContext context) {
        Throwable thrown = context.failure();
        recordError(thrown);
        context.response().setStatusCode(500).end();
    }
  
    private void recordError(Throwable throwable) {
        // Your logging/tracing/metrics framework here
    }
}


自適應性
1. 配置
應用程式的某些部分是可配置的,因為我們希望按照12個因素原則將相同的二進位制檔案執行到多個環境中。
Vert.x生態系統提供了一個vertx-config模組,它是一個非常精心設計的模組,用於處理配置載入。它圍繞ConfigStore概念進行組織,代表任何能夠包含配置的東西(Redis,Consul,Files,Environment Variables)。
如下例所示,配置儲存可以使用ConfigRetriever進行連結,最新值將覆蓋第一個值。

private static ConfigRetrieverOptions getConfigRetrieverOptions() {
    JsonObject classpathFileConfiguration = new JsonObject().put("path", "default.properties");
    ConfigStoreOptions classpathFile =
            new ConfigStoreOptions()
                    .setType("file")
                    .setFormat("properties")
                    .setConfig(classpathFileConfiguration);

    JsonObject envFileConfiguration = new JsonObject().put("path", "/etc/default/demo");
    ConfigStoreOptions envFile =
            new ConfigStoreOptions()
                    .setType("file")
                    .setFormat("properties")
                    .setConfig(envFileConfiguration)
                    .setOptional(true);

    JsonArray envVarKeys = new JsonArray();
    for (ConfigurationKeys key : ConfigurationKeys.values()) {
        envVarKeys.add(key.name());
    }
    JsonObject envVarConfiguration = new JsonObject().put("keys", envVarKeys);
    ConfigStoreOptions environment = new ConfigStoreOptions()
            .setType("env")
            .setConfig(envVarConfiguration)
            .setOptional(true);

    return new ConfigRetrieverOptions()
            .addStore(classpathFile) // local values : exhaustive list with sane defaults
            .addStore(environment)   // Container / PaaS friendly to override defaults
            .addStore(envFile)       // external file, IaaS friendly to override defaults and config hot reloading
            .setScanPeriod(5000);
}



使用外部配置系統(如Redis,Consul或您的雲提供商,如Google Runtime Config)時,此設計特別好。您的系統將嘗試從遠端系統檢索配置,如果失敗則返回到環境變數,如果未定義,則將使用預設配置檔案。請注意將遠端儲存設定為可選儲存,以避免在發生遠端系統故障時出現異常。
配置也是一種熱重新載入,這使我們的應用程式能夠在不停機的情況下調整其行為。ConfigurationRetriever會定期重新整理配置(預設為5秒),因此您可以將其注入應用程式,然後呼叫它以檢索其最新值。
我們的主要方法現在看起來像這樣,請注意我們在部署Verticle之前等待第一次配置檢索,並透過Event Bus傳播配置更改。

public static void main(String[] args) {
    VertxOptions vertxOptions = new VertxOptions().setMetricsOptions(metricsOptions);
    Vertx vertx = Vertx.vertx(vertxOptions);

    ConfigRetrieverOptions configRetrieverOptions = getConfigRetrieverOptions();
    ConfigRetriever configRetriever = ConfigRetriever.create(vertx, configRetrieverOptions);

    // getConfig is called for initial loading
    configRetriever.getConfig(
            ar -> {
                int instances = Runtime.getRuntime().availableProcessors();
                DeploymentOptions deploymentOptions =
                        new DeploymentOptions().setInstances(instances).setConfig(ar.result());
                vertx.deployVerticle(MainVerticle.class, deploymentOptions);
            });

    // listen is called each time configuration changes
    configRetriever.listen(
            change -> {
                JsonObject updatedConfiguration = change.getNewConfiguration();
                vertx.eventBus().publish(EventBusChannels.CONFIGURATION_CHANGED.name(), updatedConfiguration);
            });
}



我們的Verticle現在需要透過訂閱EventBus主題來決定如何對配置更改做出反應。

package co.teemo.blog.verticles;

public class MainVerticle extends AbstractVerticle {
    private PingHandler pingHandler;
    private FailureHandler failureHandler;

    @Override
    public void start() {        
        this.pingHandler = new PingHandler();
        this.failureHandler = new FailureHandler();
        
        configureRouteHandlers(config());
        configureEventBus();

        Router router = Router.router(vertx);
        router.route().consumes("application/json");
        router.route().produces("application/json");
        router.route().handler(BodyHandler.create());

        router.get("/ping").handler(pingHandler).failureHandler(failureHandler);
        vertx.createHttpServer().requestHandler(router).listen(8080);
    }

    private void configureEventBus() {
        vertx.eventBus().<JsonObject>consumer(
            EventBusChannels.CONFIGURATION_CHANGED.name(),
            message -> {
                logger.debug("Configuration has changed, verticle {} is updating...", deploymentID());
                configureRouteHandlers(message.body());
                logger.debug("Configuration has changed, verticle {} has been updated...", deploymentID());
            });
    }

    private void configureRouteHandlers(JsonObject configuration) {
        String pingResponse = configuration.getString(ConfigurationKeys.PING_RESPONSE.name());
        pingHandler.setMessage(pingResponse);
    }
}


例如,我選擇改變每個處理程式中的變數以調整端點響應。

public class PingHandler implements Handler<RoutingContext> {
    private String pingResponse;

    public void handle(RoutingContext context) {
        context.response().setStatusCode(200).end(pingResponse);
    }

    public void setPingResponse(String pingResponse) {
        this.pingResponse = pingResponse;
    }
}



關於模組可擴充套件性,因為新增新的配置儲存非常容易,我們需要擴充套件vertx-config以支援Google Runtime Config,實現它是一件輕而易舉的事。您只需要實現兩個類:ConfigStore定義如何從儲存中檢索配置,ConfigStoreFactory定義如何建立ConfigStore併為其注入配置,如憑據或過濾條件。

設計失敗
我們的生產服務依賴於一些外部依賴,資料庫,訊息佇列,鍵/值儲存,遠端API,......
由於我們不能依賴外部系統健康,因此當上遊依賴性面臨中斷或意外延遲時,我們必須準備應用程式以適應自身。Vertx生態系統包含一個實現Circuit Breaker模式模組,可以輕鬆應對。
對於這個例子,讓我們定義一個新的處理程式,它將聯絡令人敬畏的PokeAPI列出口袋妖怪!由於這是一個外部依賴,我們需要使用Circuit Breaker包裝呼叫。

public class PokemonHandler implements Handler<RoutingContext> {

    private static final Logger logger = LoggerFactory.getLogger(PokemonHandler.class);
    private static final JsonArray FALLBACK = new JsonArray();

    private final WebClient webClient;
    private final CircuitBreaker circuitBreaker;

    private int pokeApiPort;
    private String pokeApiHost;
    private String pokeApiPath;

    public PokemonHandler(Vertx vertx) {
        WebClientOptions options = new WebClientOptions().setKeepAlive(true).setSsl(true);
        this.webClient = WebClient.create(vertx, options);

        CircuitBreakerOptions circuitBreakerOptions = new CircuitBreakerOptions()
                .setMaxFailures(3)
                .setTimeout(1000)
                .setFallbackOnFailure(true)
                .setResetTimeout(60000);

        this.circuitBreaker = CircuitBreaker.create("pokeapi", vertx, circuitBreakerOptions);
        this.circuitBreaker.openHandler(v -> logger.info("{} circuit breaker is open", "pokeapi"));
        this.circuitBreaker.closeHandler(v -> logger.info("{} circuit breaker is closed", "pokeapi"));
        this.circuitBreaker.halfOpenHandler(v -> logger.info("{} circuit breaker is half open", "pokeapi"));        
    }

    @Override
    public void handle(RoutingContext context) {

        Function<Throwable, JsonArray> fallback = future -> FALLBACK;
        Handler<Future<JsonArray>> processor = future -> {
            webClient.get(pokeApiPort, pokeApiHost, pokeApiPath).send(result -> {
                if (result.succeeded()) {
                    future.complete(result.result().bodyAsJsonObject().getJsonArray("results"));
                } else {
                    future.fail(result.cause());
                }
            });
        };
        Handler<AsyncResult<JsonArray>> callback = result -> {
            if (result.succeeded()) {
                JsonArray pokemons = result.result();
                context.response().setStatusCode(200).end(Json.encodePrettily(pokemons));
            } else {
                Throwable cause = result.cause();
                logger.error(cause.getMessage(), cause);
                context.response().setStatusCode(500).end(cause.getMessage());
            }
        };

        circuitBreaker.executeWithFallback(processor, fallback).setHandler(callback);
    }

    public void setPokeApiUrl(String pokeApiHost, int pokeApiPort, String pokeApiPath) {
        this.pokeApiHost = pokeApiHost;
        this.pokeApiPort = pokeApiPort;
        this.pokeApiPath = pokeApiPath;
    }
}



現在,我們可以使用流量控制模擬網路延遲,並在達到最大故障限制後檢視斷路器是否開啟。然後我們的處理程式將立即回覆後備值。
tc qdisc add dev eth0 root netem delay 2000ms

要模擬外部服務恢復,讓我們刪除延遲規則並觀察斷路器是否會再次關閉,並返回遠端API響應。
tc qdisc add del eth0 root netem

觀測
沒有人不會對生產進行監視,因此我們必須定義如何在執行時觀察我們的應用程式。

健康檢查
觀察我們心愛的軟體最基本的方法是詢問它如何定期執行,感謝vertx生態系統,有一個模組
我們的策略是暴露兩個端點,一個用於判斷應用程式是否存活,另一個用於判斷應用程式是否健康。它可能沒有健康就活著,因為它需要載入一些初始資料。
我喜歡使用“健康”端點進行監控,以瞭解由於外部依賴性故障導致服務質量下降的時間,並使用活動端點進行警報,因為它需要外部操作來恢復(重新啟動服務,替換例項, ...)
讓我們將這些健康檢查新增到PokemonHandler中。

public class PokemonHandler implements Handler<RoutingContext> {

    // ...
    private final HealthChecks healthChecks;

    public PokemonHandler(Vertx vertx) {

        // ...
        this.healthChecks = HealthChecks.create(vertx);
        healthChecks.register("pokeApiHealthcheck", 1000, future -> {
            if (circuitBreaker.state().equals(CircuitBreakerState.CLOSED)) {
                future.complete(Status.OK());
            } else {
                future.complete(Status.KO());
            }
        });
    }
    
    // ...

    public HealthChecks getHealthchecks() {
        return healthChecks;
    }
}


現在我們需要在路由器中公開相關的端點。

public class MainVerticle extends AbstractVerticle {

    // ...

    @Override
    public void start() {
        // ...
        router.get("/alive").handler(HealthCheckHandler.create(vertx));
        router.get("/healthy").handler(HealthCheckHandler.createWithHealthChecks(healthChecks));

        vertx.createHttpServer().requestHandler(router).listen(8080);
    }
}


透過這種配置,我們的軟體可以告訴我們它是否存活,在這種情況下,它是否已準備好在最佳條件下實現其目標。這是我在腦海裡做的對映:
  • 活著和健康:正常,一切都很好。
  • 活著不健康:警告,應用程式在降級模式下工作。
  • 不活著:CRITICAL,應用程式不起作用。


日誌
一旦發出警報,我們首先要做的就是檢視指標和錯誤日誌。因此,我們需要以可利用的方式釋出日誌。
我們要:

  • 使用易於解析的格式(如JSON)匯出日誌。
  • 新增有關執行環境(主機,容器ID,平臺,環境)的資訊,以確定問題是否容易與程式隔離; 計算節點或應用程式的版本。
  • 新增有關執行上下文(使用者ID,會話ID,相關ID)的資訊,以瞭解導致錯誤的序列。

格式:
我們需要配置logback以輸出具有預期格式的日誌。

<configuration>
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
            <layout class="ch.qos.logback.contrib.json.classic.JsonLayout">
                <timestampFormat>yyyy-MM-dd'T'HH:mm:ss.SSSX</timestampFormat>
                <timestampFormatTimezoneId>Etc/UTC</timestampFormatTimezoneId>
                <jsonFormatter class="ch.qos.logback.contrib.jackson.JacksonJsonFormatter">
                    <prettyPrint>true</prettyPrint>
                </jsonFormatter>
            </layout>
        </encoder>
    </appender>



你需要知道的一個小技巧是vertx日誌記錄本身並不關心logback配置,我們必須透過將“vertx.logger-delegate-factory-class-name”系統屬性設定為“io.vertx.core.logging.SLF4JLogDelegateFactory”來明確告訴我們要使用的內容。

System.setProperty("vertx.logger-delegate-factory-class-name", "io.vertx.core.logging.SLF4JLogDelegateFactory");



您也可以使用-Dvertx.logger-delegate-factory-class-name = io.vertx.core.logging.SLF4JLogDelegateFactory引數啟動應用程式,在JVM級別執行此操作。
現在我們的應用程式輸出標準JSON,讓我們新增一些關於它執行的環境的上下文資料。為此,我們使用MDC來維護可以新增後設資料的上下文。


package co.teemo.blog.verticles;

public class MainVerticle extends AbstractVerticle {
    private static final Logger logger = LoggerFactory.getLogger(MainVerticle.class);

    private PingHandler pingHandler;
    private FailureHandler failureHandler;
    private PokemonHandler pokemonHandler;
    private GreetingHandler greetingHandler;

    @Override
    public void start() {
        configureLogging();
        // ...
    }

    private static void configureLogging() {
        // It's OK to use MDC with static values
        MDC.put("application", "blog");
        MDC.put("version", "1.0.0");
        MDC.put("release", "canary");
        try {
            MDC.put("hostname", InetAddress.getLocalHost().getHostName());
        } catch (UnknownHostException e) {
            // Silent error, we can live without it
        }
    }
}




請注意,MDC不能用於新增上下文後設資料,如userId,requestId,sessionId,correlationId,...),因為它依賴於與vertx非同步性質不相容的執行緒本地值。請參閱此主題以進一步挖掘。
我們需要另一種解決方案來記錄這些資料......作為一種解決方法,讓我們將它們新增到日誌訊息本身,讓我們的集中式日誌平臺解析它並將其轉換為後設資料。

private void logError(String userId, Throwable thrown) {
    String dynamicMetadata = "";
    if(userId != null) {
        dynamicMetadata = String.format("userId=%s ", userId);
    }
    logger.error(dynamicMetadata + thrown.getMessage());
}


現在,觸發驗證錯誤會給我們一個充滿上下文的訊息,使日誌成為分析導致錯誤的路徑的強大工具。

{
  "timestamp" : "2019-02-08T14:44:09.207Z",
  "level" : "ERROR",
  "thread" : "vert.x-eventloop-thread-5",
  "mdc" : {
    "hostname" : "mikael-XPS-13-9360",
    "application" : "blog",
    "release" : "canary",
    "version" : "1.0.0"
  },
  "logger" : "co.teemo.blog.handlers.FailureHandler",
  "message" : "userId=toto Name must start with an uppercase char",
  "context" : "default"
}


度量
Vertx與Dropwizard MetricsMicrometer本地整合。假設我們想要檢視每個端點處理的請求數。我不會演示如何向後端報告指標,但您基本上有兩個選項:Dropwizard Reporters和Opencensus。
以下是使用標準Dropwizard Metric Reporter配置指標的方法。

package co.teemo.blog;

public class Application {

    public static void main(String[] args) {
        // Initialize metric registry
        String registryName = "registry";
        MetricRegistry registry = SharedMetricRegistries.getOrCreate(registryName);
        SharedMetricRegistries.setDefault(registryName);

        Slf4jReporter reporter = Slf4jReporter.forRegistry(registry)
                .outputTo(LoggerFactory.getLogger(Application.class))
                .convertRatesTo(TimeUnit.SECONDS)
                .convertDurationsTo(TimeUnit.MILLISECONDS)
                .build();
        reporter.start(1, TimeUnit.MINUTES);

        // Initialize vertx with the metric registry
        DropwizardMetricsOptions metricsOptions = new DropwizardMetricsOptions()
                .setEnabled(true)
                .setMetricRegistry(registry);
        VertxOptions vertxOptions = new VertxOptions().setMetricsOptions(metricsOptions);
        Vertx vertx = Vertx.vertx(vertxOptions);
        
        // ...
    }
}



我們可以及時觀察服務水平指標的演變。它允許我們檢視應用程式指標(請求/秒,HTTP響應程式碼,執行緒使用情況......)和業務指標(對我的應用程式有意義的那些,如新客戶,停用客戶,平均活動持續時間......)。
例如,如果我們想要計算應用程式錯誤,我們現在可以像往常一樣使用Dropwizard。

package co.teemo.blog.handlers;

public class FailureHandler implements Handler<RoutingContext> {

    private final Counter validationErrorsCounter;    

    public FailureHandler() {
        validationErrorsCounter = SharedMetricRegistries.getDefault().counter("validationErrors");
    }

    public void handle(RoutingContext context) {
        Throwable thrown = context.failure();        
        recordError(thrown);        
        context.response().setStatusCode(500).end(thrown.getMessage());        
    }

    private void recordError(String userId, Throwable thrown) {
        validationErrorsCounter.inc();        
    }
}



跟蹤
最後但同樣重要的是,我們可能需要嚮應用程式新增跟蹤,以瞭解當我們檢測到意外行為並在我們的系統中遍歷特定請求時會發生什麼。
Vert.x目前尚未準備好處理此功能集,但它肯定朝著這個方向前進。請參閱RFC

安全
這並不奇怪,我們需要在將其部署到生產環境之前保護對應用程式的訪問。

1. 身份驗證和授權
我們使用Auth0來處理這些目的。由於值不是公開的,我沒有在Github儲存庫中新增本文的這一部分。相反,我給你連結到一些有用的資源:


請記住,您永遠不應該信任網路,因此請在應用程式級別實施身份驗證和授權。
用於將身份與每個操作相關聯的身份驗證,根據與執行操作的身份相關聯的許可權來限制操作的授權。例如,我們的健康端點可以公開訪問,但應該使用最小特權原則限制操作。
使用Auth0,解碼和驗證令牌足以執行身份驗證,檢查範圍是執行授權所必需的。
2.輸入驗證
應始終驗證輸入,以確保我們的應用程式永遠不會處理可能導致系統損壞的惡意或錯誤資料。再一次,有一個模組
讓我們為我們的應用程式新增一個新的“hello world”端點,並說它只能使用以下引數呼叫:
  • 一個名稱:是需要字串開頭以大寫字元與字母字元構成的路徑引數。
  • 一個授權所需要串標頭
  • 一個版本標頭,是一個可選的INT

讓我們將驗證和問候處理程式新增到我們的路由器,而不要忘記實現自定義驗證器來處理名稱的業務驗證。

public class GreetingHandler implements Handler<RoutingContext> {

    @Override
    public void handle(RoutingContext routingContext) {
        // Thanks to the validation handler, we are sure required parameters are present
        String name = routingContext.request().getParam("name");
        String authorization = routingContext.request().getHeader("Authorization");
        int version = Integer.valueOf(routingContext.request().getHeader("Version"));

        String response = String.format("Hello %s, you are using version %d of this api and authenticated with %s", name, version, authorization);
        routingContext.response().setStatusCode(200).end(response);
    }
}

public class MainVerticle extends AbstractVerticle {
    // ...
    private GreetingHandler greetingHandler;

    @Override
    public void start() {    
        //...
        
        this.greetingHandler = new GreetingHandler();      

        HTTPRequestValidationHandler greetingValidationHandler = HTTPRequestValidationHandler
                .create()
                .addHeaderParam("Authorization", ParameterType.GENERIC_STRING, true)
                .addHeaderParam("Version", ParameterType.INT, true)
                .addPathParamWithCustomTypeValidator("name", new NameValidator(), false);        

        router.get("/greetings/:name")
                .handler(greetingValidationHandler)
                .handler(greetingHandler)
                .failureHandler(failureHandler);

        //...
    }
}

public class NameValidator implements ParameterTypeValidator {
  
    @Override
    public RequestParameter isValid(String value) throws ValidationException {
        if(!isFirstCharUpperCase(value)) {
            throw new ValidationException("Name must start with an uppercase char");
        }
        if(!isStringAlphabetical(value)) {
            throw new ValidationException("Name must be composed with alphabetical chars");
        }
        return RequestParameter.create(value);
    }

    private static boolean isFirstCharUpperCase(String value) {
        return Character.isUpperCase(value.charAt(0));
    }

    private static boolean isStringAlphabetical(String value) {
        return value.chars().allMatch(Character::isAlphabetic);
    }
}


如果我們在沒有任何必需引數的情況下呼叫端點,則丟擲ValidationException,並且註冊的FailureHandler將繼續請求處理。

curl -i -H 'Authorization: toto' -H 'Version: 1'  http://localhost:8080/greetings/fds
> HTTP/1.1 400 Bad Request
> content-length: 38
> Name must start with an uppercase char



結論
Vert.x生態系統在模組數量方面令人印象深刻,這些模組幾乎涵蓋了我們生產中所需的所有功能。該庫設計得很好,併為每個概念提出了SPI,這使得它非常易於擴充套件。
例如,我們需要新增新的配置商店來載入來自Google執行時配置和Google計算後設資料的配置,第一個實施草案花了我們不到一天的時間!
雖然跟蹤是Observability的缺失部分,但它不是阻止在生產環境中釋出我們的服務,因為我們能夠透過執行狀況檢查,度量標準和日誌來觀察它。
您可以在此處找到原始碼:https//github.com/migibert/vertx-in-production
 

相關文章