客户端

  • 以下流程图展示了:val netSocket = vertx.createNetClient().connect(6379, "localhost").coAwait() 连接到服务器的过程
    
    sequenceDiagram
    %% 时序图例子,-> 直线,-->虚线,->>实线箭头
        participant NetClientImpl
        participant VertxHandler
        participant NetSocketImpl
        participant ChannelProvider
    	Note over VertxHandler : VertxHandler<C extends VertxConnection> extends ChannelDuplexHandler
    	loop NetClientImpl::connectInternal2(...)
    		NetClientImpl -->> NetClientImpl : channelProvider = new ChannelProvider(...)
    		NetClientImpl ->> ChannelProvider : channelProvider.handler(ch -> NetClientImpl::connected(..., ch, ...))
    		NetClientImpl ->> ChannelProvider : channelProvider.connect(...)
    		loop ChannelProvider::handleConnect
    			ChannelProvider ->> ChannelProvider : bootStrap.handler(new ChannelInitializer<Channel>() {... initSSL ...})
    			ChannelProvider ->> ChannelProvider : bootStrap.connect(...)
    			Note over ChannelProvider : ChannelFuture.isSuccess()
    			loop ChannelProvider::connected(handler, channel, ssl, ...)
    				ChannelProvider ->> ChannelProvider : context.dispatch(channel, handler)				
    			end
    		end
    	end
    	loop NetClientImpl::connected(..., ch, ...)
    		NetClientImpl -->> NetClientImpl : channelGroup.add(ch)
    		loop NetClientImpl::initChannel(ch.pipeline(), ssl)
    			NetClientImpl -->> NetClientImpl : pipeline.addLast("logging", new LoggingHandler(...))
    			NetClientImpl -->> NetClientImpl : pipeline.addLast("chunkedWriter", new ChunkedWriteHandler())
    			NetClientImpl -->> NetClientImpl : pipeline.addLast("idle", new IdleStateHandler(...))
    		end
    		NetClientImpl -->> NetClientImpl : handler = VertxHandler.create(ctx -> new NetSocketImpl(...))
    		NetClientImpl -->> NetClientImpl : handler.removeHandler(NetSocketImpl::unregisterEventBusHandler)
    		NetClientImpl -->> NetClientImpl : handler.addHandler(sock -> {sock.registerEventBusHandler()})
    		NetClientImpl -->> NetClientImpl : ch.pipeline().addLast("handler", handler)
    	end
    
    	loop VertxHandler的各种回调
    		loop VertxHandler::handlerAdded(ChannelHandlerContext ctx)
    			VertxHandler -->> VertxHandler : setConnection(connectionFactory.apply(ctx))
    			VertxHandler -->> VertxHandler : addHandler.hander(connection)
    		end
    		loop VertxHandler::channelRead(ChannelHandlerContext ctx, Object msg)
    			VertxHandler -->> NetSocketImpl : conn.read(msg)
    			loop NetSocketImpl::handleMessage
    				Note over NetSocketImpl : messageHandler为用户指定的消息处理器
    				NetSocketImpl -->> NetSocketImpl : messageHandler.handler(msg)
    			end
    		end	
    	end
    
    

服务器端

TODO: 也没啥看的,和客户端一个调性~,不过有一点:vert.x的netty服务器端无法自定义Netty handler,不过也不是完全没有办法,可以参考:

VertxImpl

  • sharedNetServers(Map<ServerID, NetServerInternal>)
    • 解决多个Verticle实例监听同一个hostport的问题
    • key为ServerID,当ServerID内的hostport都相同时,代表同一个ServerID
    • 可以参考文档:共享 TCP 服务端

线程相关

ExecutorService

  • VertxImpl#eventLoopGroup(EventLoopGroup)
    • 源于transport的默认实现:Transport#eventLoopGroup(int type, int nThreads, ThreadFactory threadFactory, int ioRatio)
    • 本质为:MultiThreadIoEventLoopGroup,其children数量为:2 * CPU核数,每个child是一个:SingleThreadIoEventLoop
    • 线程前缀:vert.x-eventloop-thread-
  • VertxImpl#acceptorEventLoopGroup(EventLoopGroup)
    • 源于transport的默认实现:Transport#eventLoopGroup(int type, int nThreads, ThreadFactory threadFactory, int ioRatio)
    • 本质为:MultiThreadIoEventLoopGroup,其children数量为:1,每个child是一个:SingleThreadIoEventLoop
    • 线程前缀:vert.x-acceptor-thread-
  • VertxImpl#virtualThreadExecutor(ExecutorService)
    • 通过反射的方式获取到VirtualThreadBuilder
    • 本质为:ThreadPerTaskExecutorService
    • 线程前缀:vert.x-virtual-thread-

WorkerPool(包含一个ExecutorService和PoolMetrics)

  • VertxImpl#workerPool
    • ExecutorService本质为:ThreadPoolExecutor
    • corePoolSize/maximumPoolSize默认为:2 * CPU核数
    • 当使用WORKER的方式部署一个Verticle时,如果没有指定workerPoolName,默认是该WorkerPool
  • VertxImpl#internalWorkerPool
    • ExecutorService本质为:ThreadPoolExecutor
    • corePoolSize/maximumPoolSize默认为:2 * CPU核数
  • VertxImpl#virtualThreaWorkerPool
    • ExecutorService本质为:ThreadPerTaskExecutorService
    • 其内部的ExecutorServiceVertxImpl#virtualThreadExecutor共享同一个实例引用

stickyEventLoop

  • 本质为:ThreadLocal<WeakReference<EventLoop>>
  • 起到线程亲和作用,同一个线程调用stickyEventLoop()时,尽量返回之前的线程
  • 每次从eventLoopGroup.next()返回一个线程

ContextImpl

当调用Vertx#deployVerticle(...)部署一个Verticle时,被VertxImpl#getOrCreateContext()创建,内部包含的重要变量:

  • threadingModel(ThreadingModel):可选值为:EVENT_LOOPWORKERVIRTUAL_THREADEXTERNAL
  • eventLoop(EventLoopExecutor):TODO
  • executor(EventExecutor):TODO
  • workerPool(WorkerPool):TODO
    根据threadingModel的不同,决定了eventLoopexecutorworkerPool使用不同的线程及线程池

ThreadModel

  • EVENT_LOOP
    • executor
      • 本质为EventLoopExecutor,内部包含1个netty的EventLoop(SingleThreadIoEventLoop)的引用
      • 线程来自于:VertxImpl#stickyEventLoop()也即VertxImpl#eventLoopGroup.next()
    • eventLoop
      • executor共享同一个实例引用
    • workerPool
      • 线程来自于调用函数的参数或者VertxImpl#workerPool(当参数为空时)
  • WORKER
    • executor
      • 本质为:WorkerExecutor,内部包含1个WorkerPool的引用
      • 线程来自于调用函数的参数或者VertxImpl#workerPool(当参数为空时)
    • eventLoop
      • EVENT_LOOP下的eventLoop
    • workerPool
      • 当进行部署时,如果指定了workerPoolName,则新创建一个WorkerPool,否则使用默认的:VertxImpl#workerPool
      • workerPool的线程池和executor的线程池共享同一个实例引用
  • VIRTUAL_THREAD
    • executor
      • 本质为:WorkerExecutor,内部包含1个WorkerPool的引用
      • 线程来自于VertxImpl#virtualThreaWorkerPool
    • eventLoop
      • EVENT_LOOP下的eventLoop
    • workerPool
      • 线程来自于VertxImpl#virtualThreaWorkerPool
      • workerPool的线程池和executor的线程池共享同一个实例引用

NetServerImpl

可以通过Vertx#createNetServer创建,其实例也被HttpServerImpl#tcpServer持有,可以内部实现基于Netty。当其bind(...)被调用时,并非真正执行绑定,当且仅当vertx.sharedTcpServers()不存在相同的绑定时,执行真正的Netty Bootstrap的创建及绑定操作(可以参考文档:共享 TCP 服务端),主要成员包括:

  • worker(Handler<Channel>)
    • Channel初始化时,将channel加入到NetServerImpl#channelGroup
    • Channel配置Pipeline
  • listenContext(ContextInternal)
    • 如果是在VertxThread线程(例如在Verticle#start()函数)中调用了listen(...),则listenContext为当前线程(或Verticle)的Context,否则会触发Vertx#getOrCreateContext创建一个新的Context
  • eventLoop(EventLoop)
    • 线程来自于ContextInternal#nettyEventLoop()也即ContextImpl#eventLoop#eventLoop
    • 在执行bind(...)操作时eventLoop也会被加入到channelBalancer的workers(被用作Netty的BootstrapEventLoopGroup)列表,也就是说:Verticle的线程会参与到Netty的子EventLoopGroup中,当一个Channel被创建时,Netty会从所有的子EventLoopGroup选择一个EventLoop,当进行初始化时,Vert.x会通过Channel身上的EventLoop,找到
  • channelGroup(ChannelGroup)
    • 本质为:DefaultChannelGroup
    • 线程来自于ContextInternal#nettyEventLoop()也即ContextImpl#eventLoop#eventLoop
    • eventLoop共享同一个实例引用
    • 每一个NetServerImpl
  • handler(Handler<NetSocket>)
    • 提供给上层逻辑的回调接口,上层逻辑在调用NetServer#listen之前必须先赋值
    • 具体作用TODO
  • exceptionHandler(Handler<Throwable>)
    • 提供给上传逻辑的回调接口,HttpServerImpl的默认实现为:t -> log.trace("Connection failure", t);
  • channelBalancer(ServerChannelLoadBalancer)
    • 继承自ChannelInitializer<Channel>,实现Channel的初始化
    • 被用作Netty的BootstrapchildHandler
    • 其内部成员:workers(VertxEventLoopGroup)被用作Netty的Bootstrap的子EventLoopGroup

生成KeyStore

  • 通过openssl生成公私钥对:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    openssl genpkey -algorithm RSA -out private.key -pkeyopt rsa_keygen_bits:2048
    ````
    * 通过**openssl**生成自签名证书
    ``` bash
    openssl req -new -x509 -key private.key -out certificate.crt -days 3650
    ````
    * 通过**openssl**将证书和公私钥存储到**keystore**
    * 由于Vert.x的[源码](https://github.com/eclipse-vertx/vertx-auth/blob/76717f72da6977e14394ef4ca67dfca47d430f31/vertx-auth-common/src/main/java/io/vertx/ext/auth/impl/jose/JWK.java#L168)中加载keystore时,仅识别指定的别名,因此这里选择对应的别名:**RS256**,也即**RSA**秘钥及**SHA256**哈希算法
    * keystore的密码为:`123456`
    ``` bash
    openssl pkcs12 -export -in certificate.crt -inkey private.key -out keystore.p12 -name "RS256" -password pass:123456

    #另外,keystore提取私钥的方式:
    openssl pkcs12 -in keystore.p12 -nodes -nocerts -out extracted_private.key -password pass:123456
    ````
    * 将生成的`keystore.p12`文件拷贝到项目工程的`resources`目录下
    # JWT认证及授权服务端
    * 编写vert.x http服务端,并且使用jwt认证
    ``` java
    package com.example.vertxStudy

    import io.vertx.core.Future
    import io.vertx.core.VerticleBase
    import io.vertx.core.json.JsonObject
    import io.vertx.ext.auth.JWTOptions
    import io.vertx.ext.auth.KeyStoreOptions
    import io.vertx.ext.auth.authorization.PermissionBasedAuthorization
    import io.vertx.ext.auth.jwt.JWTAuth
    import io.vertx.ext.auth.jwt.JWTAuthOptions
    import io.vertx.ext.auth.jwt.authorization.impl.JWTAuthorizationImpl
    import io.vertx.ext.web.Router
    import io.vertx.ext.web.handler.AuthorizationHandler
    import io.vertx.ext.web.handler.BodyHandler
    import io.vertx.ext.web.handler.JWTAuthHandler
    import io.vertx.ext.web.handler.SecurityAuditLoggerHandler
    import io.vertx.kotlin.core.http.httpServerOptionsOf


    class JWTAuthVerticle : VerticleBase() {

    override fun start(): Future<*> {

    val router = Router.router(vertx)
    router.route().handler(SecurityAuditLoggerHandler.create()).handler(BodyHandler.create())

    val config = JWTAuthOptions()
    .setKeyStore(
    KeyStoreOptions()
    .setType("pkcs12") // 或 "pkcs12"
    .setPath("keystore.p12")
    .setPassword("123456")
    )
    .setJWTOptions(JWTOptions().setAlgorithm("RS256"))

    val jwtAuthProvider = JWTAuth.create(vertx, config)
    val jWTAuthHandler = JWTAuthHandler.create(jwtAuthProvider)

    router.post("/login").handler { ctx ->

    val body = ctx.body().asJsonObject()
    if (body == null) {
    ctx.response()
    .setStatusCode(400) // Bad Request
    .end("Bad Request: Body is missing or not a valid JSON. Please ensure the 'Content-Type' header is 'application/json'.")
    return@handler
    }

    // 现在可以安全地访问 body
    val role = body.getString("role")

    // 为简单起见,我们为示例用户创建一个token
    val claims = JsonObject()
    //这里应该使用真正的用户
    .put("sub", "987654321")
    .put("name", "Nancy")
    //指定授权,可以不用放在realm_access下,而是直接放在roles下,这里是为了后面的演示特意为之
    .put("realm_access", JsonObject().put("roles", listOf(role)))
    val token = jwtAuthProvider.generateToken(claims)

    ctx.response().putHeader("content-type", "text/plain").end(token)
    }

    //指定permission列表所在的路径,需要和login返回的token的路径保持一致
    val rootClaim = "realm_access/roles"
    val authorizationProvider = AuthorizationHandler
    .create(PermissionBasedAuthorization.create("admin"))
    .addAuthorizationProvider(JWTAuthorizationImpl(rootClaim))

    router.route("/protected")
    //先认证身份
    .handler(jWTAuthHandler)
    //再验证授权(需要有admin的授权)
    .handler(authorizationProvider)

    router.get("/protected").handler { ctx ->
    ctx.response().end("Hello ${ctx.user().get<String>("name")}!")
    }

    return vertx
    .createHttpServer(httpServerOptionsOf(registerWriteHandler = true))
    .requestHandler(router)
    .listen(8888).onSuccess { http ->
    println("HTTP server started on port ${http.actualPort()}")
    }
    }
    }

生成token

方式1

  • 打开Online JWT tool,在Decoded Header中输入:
    1
    {"alg":"RS256","typ":"JWT"}
  • 在Decoded Payload中输入授权服务器生成的业务数据(本示例没有admin身份):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true,
    "iat": 1516239022,
    "realm_access": {
    "roles": [
    "user"
    ]
    }
    }
  • 在Private Key中,输入private.key中的私钥,例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    -----BEGIN PRIVATE KEY-----
    MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDSXVvT5zcW3laj
    0FUZELJrJUpKhsguYCBnWFJIk/54IxEuLh53G6xeI8dWhhSIRwDy6wr/UFfx40pD
    44WUBtrOREC599g2yCaUku1b8hf6T7+gU4vpGdBt5HEggGWf9vVsIy6AEFiFx6ta
    0wqU2EfX6tVeU37KLmAboRNhQQmsFdxl0TaYv8Z3sVN3UQ61axL/1gpcTQqGw0TC
    hvDwXXAodSj6/3LPEts707IJ5S5QKanKEjdLdO0JOdcaR69k5iG+1NMgktQBm5Wb
    /ym0rAfP5syC0hhHY+EfLl8sqOzls4u1L4zsr0pTZmDd+IV/GcbeR6saxskQW10J
    IWwuNh3FAgMBAAECggEBAIGV1wJS9SGWNwLUTAeTvIS6ROhh3KCMwIlI4+8nk2MI
    HwY2jLfMiUK4gYvwLmfAbXo2heC+6l3sKIxndJq6GkFj1Ad7AGrGotV1KNgx6slc
    JOwraw1dxIJdbb6auX9vzsEdbwCekRlf5VOem28TSDZex69CeeEr7jI26ExJlx60
    fZOVm9f6sqH8xs2w1Subdsjrpcqv11TZJ0D9RwhBZYBnE1bP70LzCUlOYl2KAEFT
    ob/oh34Xr0Ra1kdwWPsPbCXFs5JGqEW8IGUZcA/YpADNOEsw2f4arbHFs5nGUrRb
    4+dkTVLquIQm3AmQyPucx/Wpd9pfpZqnsgMXUIFvHiECgYEA/uqIMZpLOLk2WhCf
    Kk9cPy2pGO7htUBtm5pRkQCD+GUsYYDzrGXQTLLKuH1+Cqr7TnDpu1/CZjJGJnOB
    /IsS3LbQ6t5wwXEqfi44vIpOGF9Uw6ESoNQhiYl29QmgqkxeWgIzpMSwmXvwlTJl
    9GK4rnzfbh0C4+ysc1chFXjijCkCgYEA00JVeIuwZ31cMggEV1YRWUZwPnBiB8zT
    /T2C8kNCO2pAGYnqjzfd2dWu3L9NH2ah4UvKBfIBd0JVOETFh/Z9wEigZjHFeZzC
    maziNmUWlDMvtSmzTzP4SOITEvYn+2iSVyx8dQl2OCnRK74o3/RjDRCkpCAUnlaQ
    IobAmyM7+D0CgYEAlZOanLhzgPI0sT5llpcAgtXRDh9Fc2w9pHs1d0b9KOh81S2s
    TbFkO00B5KVGKw5O7aUVkOvMjtjbDr7iPASC6d2f0uD4+pjjnSyUABWLY4O0oiHG
    Y3Z1w1VU5s1iZ+rtxhTapsj+8uClt3XeGzs+MKx3Eg6V3pXGTtuGPyoyjJkCgYEA
    0eYXe+T2yWxhnQggIBJQvG3i+fa5P9zAR93E7CXteZEQzQ8dsVylnVjVG1krLGbR
    skKWICAaWr7aY0TZKkS7HsBKNh9/gFxDcWK3g6oeq/LLlOkw0iHlr6yHnRBcG0wE
    En8NzU8wWY8a16ZhgFqVy5ZcrwF82vFQ1i0i00FHxVUCgYBUc6JC1DePyCApdQXJ
    Qn/pYtPwBKSmllLkEkrzUPmJSlWVgdzfynjGU5WF/2DFOaKHxoKW+npaRnXgi3c4
    oJs1Ow/nrYFZZSxnXgI/hWCC/+eu2JfHOnPqIcgRniJ25jEINcoK+LjSAVthfad7
    vjkKAV0kmZtN6kmFfBNS34k+XA==
    -----END PRIVATE KEY-----
  • 生成token,例如:
    1
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfX0.QPrVDABsyOkI4qUhxhhWis5o21T1xYYJqWw5Itp31jirQLHREvwF3lx0DEkFESguJ95qcnwJH9HHAcOT-y_BGyQaosktnBRJsgedAr4FByRO2hwzrAvUpAYHXcLfzbL_5H8KmuheN5sf3TxdFp3LWdvFSiHhom3oPALIkdrOzwSGx75ET4O6SvTTO86N6XimIPbJuhPRl7QIDzYKMPELiQH7LyooXlS0-hQCRIV6n82wYOH8UM1lM75lp4bUtPM4F3w7__Fc3LFzBKSMiOs-fkn-oYkRpv8d6fbjMA39HkN0AOZj4RmCM54-HCFETIWvLuOZ2Yivt6QgCO-F2Su6SQ

方式2

  • 通过/login动态下发token
    1
    token=$(curl -XPOST -d '{"role" : "admin"}' -H 'Content-Type: application/json' http://10.12.16.218:8888/login) && echo $token

验证token

  • 在curl中发送请求:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    #针对方式1(且没有admin授权)
    $ curl -i -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInVzZXIiXX19.mQm9BLP-RkKJDaiiuzy-YmtO8-wQyjOpHlb2i_hrrTjwoYFx1AqdP7egvaAhxLdxwgMI9qgstTA-WOA6Vi01jfVoTk0RfV0rzocDDMOHrNaevlqkBSPIB2Jx8EYWQNCTZ8vjvTPezmgWgQjxaA7l21IwzOqA23N-v9ziEdJx46EkRCeee6EO-Bb9jN63DekbiYAz-ccsPT3PxjdFtSklxLmDePNNXeS7HjwnHqbYj6fFAd0RlbXq71lQS5MfgPhkC_SdCjdtOUIe6v6twSMDZcDrpGfX47RY1jDOJ-f-QjnbKbyWdZ6j7_uasgDr9BGlIpcXVV7F4nkeuNMA4kQp7g' http://10.12.16.218:8888/protected
    HTTP/1.1 403 Forbidden
    content-length: 9

    Forbidden

    #针对方式2
    $ curl -H "Authorization: Bearer ${token}" http://10.12.16.218:8888/protected Hello Nancy!

OAuth2认证(记忆法则:3次302)

  1. 用户请求登录(第1次302)
    • 用户在你的应用中点击“使用GitHub登录”,本例为:**/login**
    • Vert.x 服务器(通过 OAuth2AuthHandler)响应用户的浏览器,发送一个 HTTP 302 重定向响应,其location为OAuth2服务器地址。
    • Location Header 指向 GitHub 的授权页面,并附带 client_idredirect_uri (http://localhost:8888/callback) 等参数。
  2. 用户在GitHub授权
    • 用户的浏览器跳转到 GitHub 网站。
    • 用户输入用户名密码,并点击“授权”,同意让你的应用访问其信息。
  3. GitHub重定向回你的应用(第2次302跳转)
    • GitHub 的服务器在用户授权后,并不会直接调用你的服务器。
    • 它会向用户的浏览器发送一个 HTTP 302 重定向响应。
    • 这个响应的 Location Header 就是你在第一步中提供的回调URL,并且附加上了授权码 code 和一个 state 值,例如:http://localhost:8888/callback?code=ABCDEFG&state=HIJKLMNOP
  4. 浏览器发起回调请求
    • 用户的浏览器收到这个 302 响应后,会立即根据 Location Header 的指示,向 http://localhost:8888/callback?code=... 发起一个新的GET请求
    • 所以,最终是用户的浏览器,而不是 GitHub 的服务器,向你的 Vert.x 应用发起了 /callback 请求。
  5. 你的服务器处理回调(第3次302)
    • 你的 Vert.x 应用收到来自浏览器的 /callback 请求。
    • 后续流程就和我们之前讨论的一样了:Router 将这个请求交给了 oauth2Handler 设置的内部处理器。
    • 这个处理器从 URL 中提取 code,然后在后台(服务器到服务器的通信)向 GitHub 的 API 发送请求,用 code 换取最终的 access_token
    • 向用户的浏览器发送一个 HTTP 302 重定向响应,location为:/login(也即第1次请求的路径)。
  6. 浏览器继续发起登录请求
    • 进入到用户路由Handler

流程图


sequenceDiagram
%% 时序图例子,-> 直线,-->虚线,->>实线箭头
    participant 浏览器
    participant OAuth2AuthHandlerImpl
    participant OAuth2AuthProviderImpl
    participant OAuth2API
    participant GitHub
    Note over OAuth2AuthHandlerImpl,OAuth2API: Vert.x OAuth2框架
    浏览器 ->> OAuth2AuthHandlerImpl: /login
	Note over 浏览器,OAuth2AuthHandlerImpl: http://localhost:8888/login
    loop ctx.user()为空
        OAuth2AuthHandlerImpl -->> OAuth2AuthHandlerImpl: HttpException(302, redirect_url)
        OAuth2AuthHandlerImpl -->> OAuth2AuthHandlerImpl: session.put("state", wj9awH9D)
        OAuth2AuthHandlerImpl ->> 浏览器: http status: 302
        Note over 浏览器,OAuth2AuthHandlerImpl: location:<br/>https://github.com/login/oauth/authorize?<br/>redirect_uri=http://localhost:8888/callback&<br/>state=wj9awH9D&response_type=code&<br/>client_id=xxx
    end
    浏览器 ->> GitHub: https://github.com/login/oauth/authorize?redirect_uri=http://localhost:8888/callback&state=wj9awH9D&response_type=code&client_id=xxx
    Note over 浏览器,GitHub: 用户在GitHub登录并授权
    GitHub ->> 浏览器: http status: 302
    Note over 浏览器,GitHub: http://localhost:8888/callback?code=172376f83cd7cd78f6d3&state=wj9awH9D
    浏览器 ->> OAuth2AuthHandlerImpl: /callback
    Note over 浏览器,OAuth2AuthHandlerImpl: http://localhost:8888/callback?<br/>code=172376f83cd7cd78f6d3&<br/>state=wj9awH9D
    OAuth2AuthHandlerImpl ->> OAuth2AuthHandlerImpl: callback.handler
    OAuth2AuthHandlerImpl -->> OAuth2AuthHandlerImpl: check state && session.remove("state")
    Note over OAuth2AuthHandlerImpl,OAuth2AuthHandlerImpl: 防止重放攻击
    OAuth2AuthHandlerImpl ->> OAuth2AuthProviderImpl: authProvider.authenticate
    OAuth2AuthProviderImpl ->> OAuth2API: api.token(grantType, params)
    OAuth2API ->> GitHub: POST /login/oauth/access_token
    Note over OAuth2API,GitHub: header<br/>User-Agent : vertx-auth-oauth2<br/>Accept : application/json,...<br/>Content-Type : application/x-www-form-urlencoded
    GitHub ->> OAuth2AuthHandlerImpl: response
    Note over GitHub, OAuth2AuthHandlerImpl: {"access_token" :"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",<br/>  "token_type" : "bearer",<br/>  "scope" : "user:email"}
    OAuth2AuthHandlerImpl ->> OAuth2AuthHandlerImpl: session.regenerateId()
    OAuth2AuthHandlerImpl ->> 浏览器: http status 302
    Note over OAuth2AuthHandlerImpl, 浏览器: cache-control : no-cache, no-store, must-revalidate<br/>expires : 0<br/>location : /login<br/>set-cookie : vertx-web.session=dfdxxxx950bfe1ac580
	loop ctx.user()不为空
	    浏览器 ->> OAuth2AuthHandlerImpl: /login
	    Note over 浏览器,OAuth2AuthHandlerImpl: vertx-web.session : dfdxxxx950bfe1ac580
	    OAuth2AuthHandlerImpl ->> OAuth2AuthHandlerImpl: postAuthentication(ctx)
	    OAuth2AuthHandlerImpl ->> OAuth2AuthHandlerImpl: 检查用户的scope是否满足路由的scope
	    OAuth2AuthHandlerImpl ->> OAuth2AuthHandlerImpl: ctx.next()继续流转到用户路由
	end

对大多数人来说,Java垃圾回收器是一个可以让用户愉快处理业务的黑匣子。程序开发程序,测试(QE)验证功能,运维负责部署。在这个过程中,你可能会对堆大小、PermGen/Metaspace或线程进行一些调整,除此之外,似乎一切都运行正常。那么问题来了,当你准备打开这个匣子时会发生什么?当这些默认值不再满足需求时又将怎样呢?作为一名开发人员、测试人员、性能工程师或架构师,了解垃圾收集工作原理的基础知识,以及如何收集和分析相应的数据并将其转化为有效的调优实践,都是非常宝贵的技能。在本系列文章中,我将带你体验G1垃圾收集器,并把你从G1初学者转变为G1爱好者,并且将GC的性能优化到极致。

我们从最基本的话题开始:G1收集器的关键是什么?它是如何工作的?如果对于它的设计目标、如何决策以及如何设计没有一个综合性的理解。这就好比你设定好了目的地,但是却没有准备好交通工具或者导航地图。

G1收集器的核心目标是实现一个可预测的,软性的暂停时间,这个目标就是通过参数 -XX:MaxGCPauseMillis 定义的,与此同时也保持一贯的(consistent )应用吞吐量。最终目标是满足当今高性能的、多线程的以及堆内存不断增大的应用程序的业务需求。G1的一般性原则是:暂停时间设置得越大,可实现的吞吐量和总延迟就越高。而暂停时间目标设置得越小,可实现的吞吐量和总延迟就越低。而你使用G1的目标就是结合应用程序的运行需求、自身特性以及对于G1的理解,调整出一组参数(options),并实现业务需求下的最佳运行状态。有一点需要牢记的是:调优是一个不断循序渐进的过程,在这个过程中,你需要通过反复地测试和评估来建立测试基线和调优设置。而对于调优这件事,并不存在一个明确的指南或者说万金油参数,你需要对性能进行评估,然后调整参数,再次评估,直到达到目标要求。

对于G1来说,它通过几种不同的方式来实现这些目标。首先,就像它的名字一样,G1收集存活对象数量最少的region(也即垃圾优先!),并将存活对象压缩/转移(compacts/evacuates)到新region。其次,G1使用了一系列逐步的、并行的、多阶段的循环来满足软暂停的目标。这允许G1在规定的时间内做最必要的事情,而不必考虑整个堆的大小。

在上文中,我们引入了一个新的概念: 区域 (regions)(译注:后文统一使用region)。简单来说,一个region代表一个已分配的堆内存区间,它可以存储任何分代的对象,并且不需要和同一代的其他region保持地址连续性。在G1中,传统的年轻代(Young )和老年代(Tenured )的概念仍然存在。年轻代包含Eden区和Survivor区,对象在Eden区创建,当发生GC时,对象被转移到Survivor区。存活对象一直待在Survivor区直到它们被回收或者由于年龄超过 XX:MaxTenuringThreshold (默认值为15)晋升到老年代。老年代包括Old region,当存活对象的年龄达到 XX:MaxTenuringThreshold 时就从Survivor区晋升到该区。当然万事都有例外,我们会在后文详细讨论。当虚拟机启动时,region的数量就被计算出来了。并且遵循这么一个原则:region的数量尽量的接近2048,每个region的大小都是1MB到64MB之间,并且该值是2的指数幂(2^n)。简单举例,假设存在一个12GB大小的堆,那么:

1
2
3
12288 MB / 2048 = 6 MB  - 6不是2的指数幂,不符合要求
12288 MB / 8 MB = 1536 - 1536个region有些小
12288 MB / 4 MB = 3072 - 嗯,可以接受

根据上面的计算,默认情况下JVM虚拟机会分成3072个region,每个region的大小为4MB,正如如下图所示。当然,你也可以通过设置参数: -XX:G1HeapRegionSize 显式的设置region的个数。当手动设置region数量时,理解堆大小与region数量的比值就显得非常重要,因为region数量越少,G1的灵活性就会越低,扫描、标记和收集每个region所需的时间也就越长。不管什么情况下,空的region都会添加到被称为“空闲列表”(free list)的无序链表中。

虽然G1是一个分代的垃圾收集器,但是在堆空间上分配和使用并非连续的,因为它需要动态调整年轻代和老年代的配比,以达到最佳性能。当对象开始被分配时,会从空闲列表中分配一个region作为本地线程分配缓冲区(TLAB),该分配操作通过CAS确保同步性。之后对象就从该缓存区中创建而不需要额外同步。当某region的空间被对象填满后,会选择一个新的region继续填充。当所有Eden区的region都被填满时,会触发一次 转移暂停evacuation pause , 也称作 young collection / young gc / young pause或者mixed collection / mixed gc / mixed pause),Eden区内region的数量就大致就代表了软暂停时间内需要垃圾回收的region数量。整个堆分配的Eden region的数量介于5%和60%之间,并且在每次young gc后基于本次yong gc的性能情况进行动态调整。

以下是将对象分配到非连续Eden区的示意图

1
2
3
4
GC pause (young); #1
[Eden: 612.0M(612.0M)->0.0B(532.0M) Survivors: 0.0B->80.0M Heap: 612.0M(12.0G)->611.7M(12.0G)]
GC pause (young); #2
[Eden: 532.0M(532.0M)->0.0B(532.0M) Survivors: 80.0M->80.0M Heap: 1143.7M(12.0G)->1143.8M(12.0G)]

根据上面的 GC pause (young) 日志,我们可以发现在 #1所示的暂停中,由于Eden区达到了 612.0M (总空间为也612.0M,一共153个region)而触发转移(evacuation)操作。整个Eden区域都被转移(0.0B)。鉴于本次GC所消耗的时间,Eden区还被缩减到532.0M(133个region)。在**#2所示的暂停中,我们看到由于达到了532.0M的上限,转移操作再次触发。并且由于暂停时间符合预期,Eden区仍然保持在532.0M**。

译注:

有些文章会把evacuation翻译为拷贝疏散,本文使用《深入Java虚拟机:JVM G1GC的算法与实现》一书中的译法,翻译为转移

当上述young gc发生时,死亡对象被回收,存活对象被转移并压缩到Survivor区。G1收集器包含一个由G1ReservePercent(默认值为10%)明确定义的硬性边界,这个边界保证了在转移时整堆中总有一部分空间作为Survivor区。而如果没有这个硬性边界,整个堆都会被耗光直到没有内存空间用来做转移。我们并不能拍胸脯说这种情况绝对不会发生,所以说这也是一个调优参数。这一原则可以确保在每次成功转移后,之前所有分配的Eden region都返回到空闲列表中,所有被转移的生存对象最终进入Survivor区。

下图是一个标准的young gc的示意图:

我们继续上面的流程,新对象会再次被分配到Eden区。当Eden空间被填满时,又会触发一次young gc。根据现有存活对象的年龄(所谓年龄,就是对象撑过了多少次的young gc),对象将会晋升到Old region。由于Survivor空间是年轻代的一部分,在年轻代gc(young pauses)期间,对象会被回收或者晋升。

下面是一个young gc的示例,Survivor区的存活对象被转移到一个新的Old region,而来自Eden区的存活对象被转移到新的Survivor区。而那些执行转移的region(删除线所示)会变成空的,并且重新回到空闲列表中。

G1会按照这种方式持续执行,直到遇到如下三种事件中的任意一种:

  1. 当G1触及到一个可配置的被称为InitiatingHeapOccupencyPercent(IHOP)的软边界。
  2. 当G1触及到一个可配置的硬边界:G1ReservePercent
  3. 当G1触发了一次大对象分配(humongous allocation,这正是上文说的那个例外,下面会详细介绍)

先讨论最常见的情形,IHOP事件代表young gc期间的某个时间点,此时Old region内的对象超过了整堆的45%(默认值)。该百分比作为young gc的一个组成部分,被不断地计算和评估。当三种情形中的任意一种被触发,就会发出请求启动并发标记周期(concurrent marking cycle)。

译注:

  1. 关于InitiatingHeapOccupencyPercent参数,在JDK-6976060之前,计算方式为:整堆的使用量 / 整堆大小,而之后是:Old region(包括humongous region)的使用量 / 整堆大小,具体详情可以参考 [R大的解答]([HotSpot VM] 请教G1算法的原理讨论第3页: - 资料 - 高级语言虚拟机 - ITeye群组)
  2. 这里所说的并发标记周期也叫做全局并发标记(global concurrent marking),指的是包括:初始标记、并发标记、最终标记、清理这几个阶段的统称,不要和其中的并发标记阶段混淆
1
2
3
4
8801.974: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: occupancy higher than threshold, occupancy: 12582912000 bytes, allocation request: 0 bytes, threshold: 12562779330 bytes (45.00 %), source: end of GC]
8804.670: [G1Ergonomics (Concurrent Cycles) initiate concurrent cycle, reason: concurrent cycle initiation requested]
8805.612: [GC concurrent-mark-start]
8820.483: [GC concurrent-mark-end, 14.8711620 secs]

G1的并发标记基于初始快照(snapshot-at-the-beginning, SATB)的原理。这意味着只有被快照“拍下”的存活对象才会参与是否为垃圾的识别,这当然是出于效率考虑。而并发标记期间任何新分配的对象都被认为是绝对存活的对象,不管它的实际存活状态如何。意识到这么一点非常重要:并发标记的时间越长,可收集对象和绝对存活对象的比值就会越大(译注:原文为:This is important because the longer it takes for concurrent marking to complete, the higher the ratio will be of what is collectible versus what is considered to be implicitly live.)。如果在并发标记期间分配的对象多于最终回收的对象,堆内存最终会被耗尽。在并发标记周期中,你会发现young gc会持续进行,因为在并发标记周期中,不是每个子阶段都会导致STW(stop-the-world)。

下图展示了当一次young gc结束后且达到IHOP 阈值时堆空间。

一旦并发标记周期完成,紧接着就是一次young gc,随后是第2种类型的转移,这被称为mixed gc。mixed gc和young gc的工作方式几乎相同,但是有两个主要区别。首先,mixed gc还会回收、转移并压缩被选定的Old region。其次,mixed gc的转移不同于young gc的转移。它的工作目标是尽可能快速、频繁的回收。这样做的目的是为了在软性暂停时间内最小化Eden / Survivor区的数量,使得Old region的数量最大化。

译注:

关于上面说的:最小化Eden / Survivor区的数量,可以参考:[G1Policy::calculate_young_list_desired_min_length](jdk17u/g1Policy.cpp at master · openjdk/jdk17u (github.com))

1
8821.975: [G1Ergonomics (Mixed GCs) start mixed GCs, reason: candidate old regions available, candidate old regions: 553 regions, reclaimable: 6072062616 bytes (21.75 %), threshold: 5.00 %]

上面的log日志展示了一次mixed gc,候选Old region的数量(553)含有21.75%的可回收空间,这个值高于G1HeapWastePercent所规定的5%的最小阈值(JDK8u40+默认为5%,JDK7默认为10%),正因如此mixed gc被触发。鉴于不能执行费时的操作,G1会恪守垃圾优先的策略:根据候选Old region存活对象占比,决定是否将其加入到有序的回收候选列表中。如果一个Old region内的存活对象小于G1MixedGCLiveThresholdPercent 所规定的百分比(JDK8u4+默认为85%,JDK7默认值为65%),该Old region就被加入到回收候选列表中。反而言之,如果一个Old region内存活对象的比率大于65%(JDK7)或85%(JDK8u40+),G1就不再浪费时间在这次mixed gc中对其进行回收和转移。

1
8822.178: [GC pause (mixed) 8822.178: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 74448, predicted base time: 170.03 ms, remaining time: 829.97 ms, target pause time: 1000.00 ms]

与young gc相比,mixed gc使用相同的暂停时间,而回收的region却横跨3个region(译注:Eden、Survivor、Old)。它是通过G1MixedGCCountTarget (默认值为8)实现对Old region的逐步(incremental )回收的。具体来讲,它是将候选回收列表中Old region的数量除以G1MixedGCCountTarget (译注:假设商为X),然后在接下来的mixed gc循环中每次最少都要收集X个Old region。回收完毕后,如果可回收region仍然大于G1HeapWastePercent,mixed gc循环就会持续下去。

1
8822.704: [G1Ergonomics (Mixed GCs) continue mixed GCs, reason: candidate old regions available, candidate old regions: 444 regions, reclaimable: 4482864320 bytes (16.06 %), threshold: 10.00 %]

下图展示了一次mixed gc。所有的Eden区都会被回收并转移到Survivor区。所有的Survivor区也会被回收,根据年龄的不同,足够老的存活对象会晋升到老年代。与此同时,也会选择一组Old region进行回收,这些region内的存活对象会被压缩并转移到新的Old region中。这种压缩和转移的过程可以显著减少内存碎片,同时保证空闲列表中有足够的空闲region。

下图展示了当mixed gc结束后堆内存的状态。所有的Eden区域都被回收,存活对象被转移到新分配的Survivor区域。原来的Survivor也被回收,(满足条件的)存活对象晋升到Old region中。回收候选列表中的Old region会重新返回空闲列表,同时仍然存活的对象被压缩、转移到新的Old region。

mixed gc会持续进行直到这个8次(译注:也即G1MixedGCCountTarget )循环结束,或者可回收百分比小于G1HeapWastePercent。此时,mixed gc循环结束,接下来回归到标准的young gc中。

1
8830.249: [G1Ergonomics (Mixed GCs) do not continue mixed GCs, reason: reclaimable percentage not over threshold, candidate old regions: 58 regions, reclaimable: 2789505896 bytes (9.98 %), threshold: 10.00 %]

目前我们已经讨论了常见的场景。我们回过头来讨论前面提到的异常情况。这种异常就是当分配的对象大于region的50%。在这种情况下,这个对象就被认为是大对象(humongous),并且会执行专门的大对象分配(humongous allocations)。

1
2
3
Region Size: 4096 KB
Object A: 12800 KB
Result: Humongous Allocation across 4 regions

下图展示了一个12.5MB的大对象横跨4个连续region的情况

  1. 大对象仅仅是一个对象,因此需要被分配到连续的region中,这可能会导致严重的碎片化。
  2. 大对象被直接分配到老年代中特殊的大对象region(humongous region)中。这是因为如果分配到年轻代,那么转移和复制这个大对象的成本太高。
  3. 尽管上图中的对象只有12.5MB,他也必须使用4个完整的region,总容量为16MB
  4. 大对象分配总是会触发一次并发标记循环,不管是否达到IHOP的阈值

少量的大对象分配可能不会引起什么问题,但是如果它们被持续地分配就会导致明显的碎片化,同时带来显著的性能影响。在JDK8u40之前,大对象仅在Full gc时才会被回收,对于JDK7和JDK8的早期版本来说,这个影响非常大。这就是为什么掌握应用程序中对象大小和G1的region大小是至关重要的。尽管如此,在最新的JDK8中(译注:本文写于2016年12月6日),如果你的应用程序需要分配大量的大对象,那么反复的评估和调优绝对是一件好事。

1
2
4948.653: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: requested by GC cause, GC cause: G1 Humongous Allocation]
7677.280: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 14050918400 bytes, allocation request: 16777232 bytes, threshold: 12562779330 32234.274: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: occupancy higher than threshold, occupancy: 12566134784 bytes, allocation request: 9968136 bytes, threshold: 12562779330 bytes (45.00 %), source: concurrent humongous allocation]

最后也是最不幸的是,G1也不得不执行可怕的Full GC。尽管G1会极力避免Full gc,但如果调优不当,那么Full gc就仍然是一个很残酷的现实。鉴于G1的目标是管理更大的堆内存,Full gc可能会对线上业务和SLA(译注:这是什么)造成灾难性的影响。一个最主要的原因就是G1的Full gc是单线程的。如果讨论Full gc的原因,第一个也最应该避免的原因就和元空间(Metaspace)有关。

1
[Full GC (Metadata GC Threshold) 2065630K->2053217K(31574016K), 3.5927870 secs]

一个最新消息是:当更新到JDK8u40+,类卸载就不再需要一次Full gc!仍然可能遇到和元空间有关的 Full gc,但这已经是UseCompressedOops及UseCompressedClassSpoInters或并发标记所需的时间有关了(我们将在以后的文章中讨论)。

接下来导致Full gc的两个原因很真实,而且往往是不可避免的。作为码农,我们的工作是尽最大努力优化和评估创建对象的代码,从而延后和避免这两种情况的发生。其中一个原因是“转移目标空间耗尽”(to-space exhausted),随之而来的是一次Full gc。这说明转移失败(evacuation failures)了,也即堆空间无法再扩展(译注:也就是达到Xmx的配置)且没有可用空间执行转移操作。如果您还记得的话,我们之前讨论过由G1ReservePercent定义的硬边界事件。这表示需要转移到to-space的空间超出了您的可用(reserve)空间,并且堆空间已经彻底满了,因此没有可用region执行转移操作。在某些情况下,如果JVM能够解决空间问题,那么后面就不会有Full gc,但这仍然是一个代价非常昂贵的STW事件。

1
2
6229.578: [GC pause (young) (to-space exhausted), 0.0406140 secs]
6229.691: [Full GC 10G->5813M(12G), 15.7221680 secs]

如果你发现这种情形经常发生,你应该立刻意识到有很大的调优空间。另外一个原因就是并发标记期间的Full gc。在这种情况下,g1并没有转移失败,只是在并发标记完成并触发mixed gc之前用光了堆空间。这两个原因根源要么是内存泄漏,要么是对象分配和晋升的速度超过了g1的回收速度。如果 Full gc的占比很大,那么可以假设是因为对象分配和晋升有关。如果占比很小,并且最终遇到 OutOfMemoryError,那么就应该排查是否有内存泄漏。

1
2
3
57929.136: [GC concurrent-mark-start]
57955.723: [Full GC 10G->5109M(12G), 15.1175910 secs]
57977.841: [GC concurrent-mark-abort]

最后,我希望这篇文章能够帮助你了解G1的设计方式,以及它是如何做出垃圾回收决策的。我希望您继续关注本系列的下一篇文章,我们将深入挖掘各种JVM参数,以收集和解释通过GC日志产生的海量数据。

背景

乔梁大神曾经在他的《持续交付2.0》一书的第5.1.1节:《持续交付架构要求》中明确指出:为了提升交付速度,获得持续交付能力,系统架构在设计时应该考虑如下因素。

(1)为测试而设计(design for test)。如果我们每次写好代码以后,需要花费很大的精力,做很多的准备工作才能对它进行测试的话,那么从写好代码到完成质量验证就需要很长周期,当然无法快速发布。

(2)为部署而设计(design for development)。如果我们开发完新功能,当部署发布是,需要花费很长时间准备,甚至需要停机才能部署,当然就无法快速发布。

(3)为监控而设计(design for monitor)。如果我们的功能上线以后,无法对齐进行监控,除了问题只能通过用户反馈才发现。那么,持续交付的收益就会大幅降低了。

(4)为扩展而设计(design for scale)。这里的扩展性指两个方面,意识支持团队成员规模的扩展,而是支持系统自身的扩展。

(5)为失效而设计(design for failure)。俗话说:“常在河边走,哪能不湿鞋。”快速地部署发布总会遇到问题。因此,在开发软件功能之前,就应该考虑的一个问题是:一旦部署或发布失败,如何优雅且快速地处理。

在这5个设计原则中,为为监控而设计(design for monitor)属于游戏(服务器)开发中最容易被轻视甚至是忽视的原则之一,而在gamedo.core的开发设计过程中,译者本人也一直在思考如何实现系统监控的最佳实践,直到最近算是确定了技术栈,那就是站在巨人的肩上,使用Spring Boot ActuatorMicroMeter

正是由于gamedo.core最终要选用Spring Boot Actuator作为系统监控的关键技术,因此需要其进行相对深入的研究,这也是本笔记的产生的原因。而本文的所有内容都源于阅读参考文档(Spring Boot版本:2.5.0)以及阅读源码时的随读、随想和随写,为了便于以后查找,章节也完全和参考文档保持一致,并且由于某些章节比较简单,所以就被忽略掉,这可能导致本文的章节不是连续的。

启用产品就绪特性(Enabling Production-ready Features)

Spring Boot的所有产品就绪特性是都是由 spring-boot-actuator模块提供的,开启该特性的最佳方式就是引入专有的Starter,也即:spring-boot-starter-actuator

执行器的定义

执行器(actuator)是一个制造业术语,是指用于移动或控制某些物体的机械设备。可以通过执行器的微小变化带来巨大的变化。

对于使用Maven构建的项目,可以添加如下Starter依赖:

1
2
3
4
5
6
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>

对于使用Gradle的项目,使用如下配置:

1
2
3
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

端点(Endpoints)

通过端点可以监控应用程序,和应用程序交互。Spring Boot内置了一堆端点,用户也可以自行添加。比如health端点,它可以提供应用的基本健康信息。

每一个端点都可以被开启、禁用,也可以通过HTTP或JMX的方式暴露给外部。当端点同时开启且被暴露时,它才是可用的。内置的端点也只有在可用状态下才会被Spring Boot自动装配。大多数应用都选择HTTP的方式。端点的ID以及固定前缀/actuator组合成访问该端点的URL。例如对于health端点,其访问方式为:/actuator/health

spring-boot-actuator内置了一大堆开箱即用的端点,详情参考源文档。此外,当应用程序是一个Web应用(Spring MVC、Spring WebFlux或者Jersey),如下内置的额外端点:

端点ID 描述
heapdump 返回一个hprof堆转储文件,需要HotSpot虚拟机支持
jolokia 通过HTTP暴露JMX bean(需要Jolokia在classpath内,且不适用于WebFlux),需要增加jolokia-core依赖
logfile 返回日志文件的内容(如果logging.file.name或logging.file.path属性被设置),并且支持使用HTTP Range头检索部分日志文件的内容
prometheus 将metrics暴露成可以被Prometheus服务器抓取的格式,需要添加micrometer-registry-prometheus依赖

译者提示

  • 对于任意端点来说,可以对其进行两种操作:1、开启或关闭端点;2、通过HTTP或者JMX暴露(exposed)它。当某个端点被开启且被暴露时该端点才处于可用状态。而系统内置端点只有在可用时才会被自动装配。
  • 产品就绪特性属于Spring Boot的六大特性之一,详情可以参考官方文档Features一节
  • 自动装配属于Spring Boot的六大特性之一,简单来说,自动装配就是:如果检测到用户需要某个组件(例如发现引入了某个组件类库),并且用户还没有进行配置,那就启用内部自动装配逻辑,帮用户配置好。例如,如果用户引入了spring-boot-starter-logging,接下来不需要进行一大堆的日志配置,直接记录日志。这个术语会在本文档中多次提及。
  • 端点(Endpoints)和指标(metrics)是本文最重要的两个概念,占据了本文的大部分篇幅。端点的概念不难理解,属于Spring Boot Actuator的核心功能,从系统内部代码层面讲,就是一个个内置的自动装配的以及用户自定义的端点 bean,从外部用户角度讲,就是一个个可以访问的数据集合窗口(可以通过jmx或http)。而指标有时候指的就是众多端点中的metrics端点,有时候指的是metrics端点下的被Spring Boot使用micrometer采集到的指标。理解了这些,实际上就理解整篇文章的精髓。

开启端点(Enabling Endpoints)

默认情况下,除了shutdown端点,其他所有端点默认处于开启状态。如果要开关某个端点,可以配置:management.endpoint.<端点id>.enabled,例如开启shutdown端点的配置为:

1
2
3
4
management:
endpoint:
shutdown:
enabled: true

如果希望所有端点默认都是关闭状态,可以将management.endpoints.enabled-by-default配置为false,然后将某端点的enabled属性配置为true来单独开启。以info端点举例(只开启info端点,其他端点都关闭):

1
2
3
4
5
6
management:
endpoints:
enabled-by-default: false
endpoint:
info:
enabled: true

提示

当某个端点被关闭后,对应的端点bean会从Spring容器(application context)中移除。如果仅仅是不想让某个端点被暴露,可以使用include和exclude属性。

暴露端点(Exposing Endpoints)

因为端点可能会包含敏感信息,因此在决定暴露端点前需要仔细考虑。下表展示了内置端点的默认暴露状态(简而言之:JMX下默认全开,HTTP下只有health开启):

ID JMX Web
auditevents Yes No
beans Yes No
caches Yes No
conditions Yes No
configprops Yes No
env Yes No
flyway Yes No
health Yes Yes
heapdump N/A No
httptrace Yes No
info Yes No
integrationgraph Yes No
jolokia N/A No
logfile N/A No
loggers Yes No
liquibase Yes No
metrics Yes No
mappings Yes No
prometheus N/A No
quartz Yes No
scheduledtasks Yes No
sessions Yes No
shutdown Yes No
startup Yes No
threaddump Yes No

如果想调整某个端点的暴露状态,可以使用如下includeexclude属性:

属性 默认值
management.endpoints.jmx.exposure.exclude
management.endpoints.jmx.exposure.include *
management.endpoints.web.exposure.exclude
management.endpoints.web.exposure.include health

include属性列出的是需要开启暴露的端点的id集合,而exclude属性列出的是关闭暴露的端点的id集合。exclude的优先级高于include,且这两个属性都可以配置为一个以端点ID列表。

例如,如果只想通过JMX暴露healthinfo端点,而禁止暴露其他端点,可以使用如下配置:

1
2
3
4
5
management:
endpoints:
jmx:
exposure:
include: "health,info"

*代表所有端点。例如,如果想通过HTTP的方式暴露除了envbeans之外的所有端点,可以使用如下配置:

1
2
3
4
5
6
management:
endpoints:
web:
exposure:
include: "*"
exclude: "env,beans"

提示

  • *在YAML中具有特殊含义,因此在includeexclude中使用时需要添加双引号
  • 如果应用程序暴露在外网环境中,强烈建议加固HTTP端点
  • 如果想自定义端点的暴露策略,可以注册一个EndpointFilter bean

加固HTTP端点(Securing HTTP Endpoints)

信息敏感的URL需要进行安全加固,而对于HTTP方式的端点也应该得到相同待遇的处理。如果使用了Spring Security,端点默认会被Spring Security的内容协商策略(content-negotiation strategy)加固,例如只允许具有某种角色(role)的用户访问。Spring Boot提供了一些方便使用的RequestMatcher对象,可以配合Spring Security使用。

典型的Spring Security配置如下所示:

1
2
3
4
5
6
7
8
9
10
11
@Configuration(proxyBeanMethods = false)
public class MySecurityConfiguration {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeRequests((requests) -> requests.anyRequest().hasRole("ENDPOINT_ADMIN"));
http.httpBasic();
return http.build();
}
}

上面的例子中,EndpointRequest.toAnyEndpoint()用来匹配任意端点,然后还会确保用户拥有ENDPOINT_ADMIN角色。EndpointRequest中还有其他类似的匹配函数。可以查看API文档(HTMLPDF)获得更多详情。

如果应用程序部署在防火墙后面,可以允许所有端点被访问而无需进行鉴权。此时可以将management.endpoints.web.exposure.include配置为*****,如下所示:

1
2
3
4
5
management:
endpoints:
web:
exposure:
include: "*"

此外,如果使用了Spring Security,并且想允许未经过身份验证的访问,还需要一些自定义配置,例如:

1
2
3
4
5
6
7
8
9
10
@Configuration(proxyBeanMethods = false)
public class MySecurityConfiguration {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeRequests((requests) -> requests.anyRequest().permitAll());
return http.build();
}
}

提示

以上两个例子中的配置仅对端点请求生效。而对于Spring Boot来说,当存在用户自定义的SecurityFilterChain bean时,其安全配置就会自动失效。因此需要配置额外的SecurityFilterChain来适配应用程序的其他请求

配置端点(Configuring Endpoints)

对于端点中不包含参数的读操作(read operations)请求,端点会自动缓存响应数据(response),如果要修改某端点的缓存时间,可以配置cache.time-to-live 属性,如下配置就是将beans端点的缓存时间改成10秒:

1
2
3
4
5
management:
endpoint:
beans:
cache:
time-to-live: "10s"

提示

management.endpoint.<name>前缀用于标识要配置的端点

Hypermedia for Actuator Web Endpoints

跨域资源共享支持(CORS Support)

跨域资源共享(CORS)是一个W3C规范,可以协助制定灵活的跨域访问授权策略。如果你在使用Spring MVC或者Spring WebFlux,Web端点可以支持支持中场景。

CORS默认是被关闭的,并且只有management.endpoints.web.cors.allowed-origins被配置后才会开启,下面配置允许来自于example.comGETPOST请求:

1
2
3
4
5
6
management:
endpoints:
web:
cors:
allowed-origins: "https://example.com"
allowed-methods: "GET,POST"

提示

CorsEndpointProperties查看完整的属性列表

实现自定义端点(Implementing Custom Endpoints)

如果一个@Bean@Endpoint注解,那么任意被注解了@ReadOperation@WriteOperation或者@DeleteOperation的方法都以JMX方式暴露,而对于Web应用程序,则会以HTTP的方式暴露。当使用Jersey、Spring MVC或者Spring WebFlux时,端点可以通过HTTP暴露。当Jersey和Spring MVC都可用时,Spring MVC优先被使用。

以下例子暴露一个读操作(read operation),该操作返回了一个自定义对象

1
2
3
4
@ReadOperation
public CustomData getData() {
return new CustomData("test", 5);
}

可以使用@JmxEndpoint@WebEndpoint注解来开启特定方式的端点(JMX或者Web)。

可以使用@EndpointWebExtension@EndpointJmxExtension,这两个注解可以对特定方式的端点进行增强(JMX或者Web)

笔者提示

对于注解了@Endpoint@Bean,由于既可以通过JMX方式暴露,又可以通过HTTP的方式暴露(后文称这种端点为原生端点)。为了兼容性的需要,不得不放弃某些技术特性。因此Spring设计了@EndpointWebExtension@EndpointJmxExtension(从Spring Boot 2.2.0开始,还增加了@EndpointCloudFoundryExtension注解),这些注解的作用对原生端点进行技术特级别的扩展。例如对于以HTTP方式暴露的某个端点,可以为响应数据(Response)增加一个状态码(而JMX方式就不需要这个状态码),那么就可以使用@EndpointWebExtension对原生端点进行二次加工,示例可以参考源码中EnvironmentEndpointWebExtension的实现

最后,如果需要使用web框架相关的功能,可以实现Servlet或者使用Spring @Controller@RestController端点,不过代价是通过JMX方式暴露就不可用,并且当使用其他web框架时也不可用。

入参处理(Receiving Input)

通过参数的方式,端点的操作(Operations)来接收输入。当通过web暴露时,这些参数来自于请求的查询参数或者请求body中的JSON对象。当通过JMX暴露时,这些参数从MBean映射而来。默认情况下,参数是必须输入的。不过可以通过添加注解@javax.annotation.Nullable@org.springframework.lang.Nullable使之成为可选参数。

对于web请求body中JSON对象,每个属性值都可以被映射为端点的一个参数,例如请求body中的json结构为:

1
2
3
4
{
"name": "test",
"counter": 42
}

那么可以开发这么一个包含String nameint counter参数的端点与之对应,例如:

1
2
3
4
@WriteOperation
public void updateData(String name, int counter) {
// injects "test" and 42
}

提示

  • 由于原生端点的兼容性需要,因此端点的入参只能是基本类型,因此不支持将namecounter封装到CustomData
  • 如果想要将输入参数映射到函数的参数上,当编译Java代码时,需要使用-parameters参数,而对于Kotlin代码,则需要使用-java-parameters,如果在Gradle中使用了Spring Boot的插件或者在Maven中使用了spring-boot-starter-parent这个参数已经被自动开启。(关于-parameters的更多细节,可以参考StackOverflow的这个解答)

输入类型转换

在必要的情况下,传给端点的参数会被自动转换为端点的参数类型。在调用端点的操作函数(operation method)之前,通过JMX或HTTP请求的参数通过ApplicationConversionService对象和任意被@EndpointConverter注解标识的ConverterGenericConverter转换。

自定义Web端点(Custom Web Endpoints)

@Endpoint@WebEndpoint@EndpointWebExtension注解的端点会自动通过Jersey、Spring MVC或Spring WebFlux以HTTP的方式暴露。而如果Jersey和Spring MVC都可用,那么Spring MVC会被启用。

Web端点请求谓词(Web Endpoint Request Predicates)

对于每个HTTP端点的任意操作(operation),都会自动生成一个HTTP请求谓词

路径(Path)

请求谓词的路径取决于端点的ID和根路径(指的是以web方式暴露的根路径),默认的根路径是/actuator。例如,sessions端点的谓词路径就是/actuator/sessions

可以通过在端点的函数入参上增加@Selector注解实现路径的自定义解析。这些解析后的请求参数会被以路径变量的方式加入到请求谓词中。当端点的函数被调用时,这些路径变量会被当做入参传入。如果想捕获完整路径上所有剩余元素(每个被“/”分割的字符串都是一个元素),可以在最后一个函数入参上使用@Selector(Match=ALL_REMAINING)注解,这会把所有的元素转换为String[]数组。

HTTP方法(HTTP method)

请求谓词中的HTTP方法取决于操作类型,如下表所示:

操作类型 HTTP方法
@ReadOperation GET
@WriteOperation POST
@DeleteOperation DELETE

请求媒体类型(Consumes)

对于HTTP POST方式的@WriteOperation,其请求(Request)的媒体类型(Content-Type)为application/vnd.spring-boot.actuator.v2+json, application/json,而其他操作类型则为空。

响应媒体类型(Produces)

响应(Response)的媒体类型由@DeleteOperation@ReadOperation@WriteOperation注解内的produces属性决定。这个属性是可选的,如果没有配置,那么媒体类型会被自动设置。

如果操作函数的返回值是void或者Void,那么媒体类型为空。如果返回一个org.springframework.core.io.Resource,那么媒体类型类型为application/octet-stream(也即二进制流数据)。而其他的所有的操作,媒体类型都是application/vnd.spring-boot.actuator.v2+json, application/json

Web端点的响应状态码(Web Endpoint Response Status)

默认的响应状态码取决于操作类型(@DeleteOperation@ReadOperation@WriteOperation)和返回值(如果有返回值的话)。

  • 如果一个@ReadOperation返回了一个值,那么状态码是200(OK)。否则的话,状态码是404(Not Found)。

  • 如果一个@WriteOpertion@DeleteOperation返回了一个值,那么状态码是200(OK)。否则的话,状态码是204(No Cpmtemt)。

  • 如果一个操作被调用了而没有传入必须的参数,或者说参数没有被正确转换,那么操作函数将不会被调用,返回一个400(Bad Request)状态码

Web端点范围请求(Web Endpoint Range Requests)

可以使用HTTP范围请求来获取HTTP资源。当使用Spring MVC或Spring WebFlux时,如果某个操作返回了org.springframework.core.io.Resource,那么范围请求会被自动支持。

提示

Jersey不支持范围请求

Web端点安全(Web Endpoint Security)

对于web方式的端点或者web相关的端点扩展(web-specific endpoint extension),它们都可以接收java.security.Principalorg.springframework.boot.actuate.endpoint.SecurityContext作为操作函数的参数。前者通常配合@Nullable使用,为经过身份验证和未经过身份验证的用户提供不同的行为。而后者通常使用isUserInRole(Sring)进行权限检查

Servlet端点(Servlet Endpoints)

当同时满足如下两个条件后,就可以实现一个Servlet端点:

  • 实现一个Supplier<EndpointServlet>
  • 同时该类上增加@ServletEndpoint注解

Servlet端点可以和Servlet容器更深层地集成,但是牺牲了可移植性。这种方式的应用场景就是可以将已有的Servlet转化为端点。而对于新的端点,仍然尽量使用@Endpoint@WebEndpoint

Controller端点(Controller Endpoints)

@ControllerEndpoint@RestControllerEndpoint端点只可以在Spring MVC和Spring WebFlux下使用。当使用Spring MVC或Spring WebFlux的标准注解,例如@RequestMapping@GetMapping时,端点的操作方法都可以被正常映射,并且以端点的ID作为请求路径(path)的前缀。Controller端点提供了和Srping web框架更深的集成度,然而牺牲了可移植性。因此,尽量优先使用@Endpoint@WebEndpoint端点。

健康信息(Health Information)

可以通过健康信息检查应用程序的运行状态。健康信息通常被用来监控应用,并且在系统宕机时进行报警。health端点暴露的信息详情取决于management.endpoint.health.show-detailsmanagement.endpoint.health.show-components属性的配置,可以配置的值如下所示:

配置值 描述
never(默认值) 详情不可见
when-authorized 详情仅对授权用户可见。授权用户可以通过management.endpoint.health.roles配置
always 详情对所有用户可见

当一个用户拥有一个或多个端点角色(endpoint’s roles)时,他被认为是授权的。如果某个端点没有配置角色(这是默认状态),那么所有通过身份认证的用户都任务是被授权的。可以通过management.endpoint.health.roles配置角色。

提示

如果已经对应用程序进行安全控制( secured your application),而想使用always配置,那么需要对安全配置进行设置,是的所有经过身份认证和未经过身份认证的用户都获得访问权限

健康信息是 HealthContributorRegistry中收集所有健康数据的集合。默认情况下,这些健康数据是Spring上下文(ApplicationContext)中一个个HealthContributor实例,Spring Boot已经自动装配了很多HealthContributors,用户也可以自己实现。

HealthContributor既可以是一个HealthIndicator 也可以是一个CompositeHealthContributor,前者提供了实际的健康数据,并包含一个Status,后者提供了和其他HealthIndicator组合的能力。最终,所有这些HealthContributor形成了一个树状结构,来展示整个系统的健康状态。

默认情况下,系统的最终健康状态由Statusaggregator计算获得。Statusaggregator的实现类SimpleStatusAggregator的实现算法:

  • 内部维护了一个健康状态的有序列表,排序为:DOWNOUT_OF_SERVICEUPUNKNOWN
  • 对于外部传入的状态集合,首先过滤掉不识别的状态码(也即只保留这4个状态码)
  • 算出每个状态在有序列表中的索引,并取出索引值最小的状态,作为最终状态(实际上就是跟进有序列表的顺序进行排序)
  • 如果剩下的集合是空的,那么返还一个UNKNOWN状态(这种情形只发生所有传入的状态都不属于这4种状态的情况下)

提示

HealthContributorRegistry可以在运行状态下动态注册和反注册

自动装配的HealthIndicators(Auto-configured HealthIndicators)

Spring Boot已经自动装配了很多HealthIndicator,可以通过management.health.<key>.enabled将其开启或关闭

提示

  • 详细的HealthIndicator列表不再赘述,详情参考源文档
  • 可以通过设置management.health.defaults.enabled关闭所有的HealthIndicator

以下HealthIndicator也是可用的,但是默认情况下没有被启用:

Key 名字 描述
livenessstate LivenessStateHealthIndicator 暴露应用程序的活性状态
readinessstate ReadinessStateHealthIndicator 暴露应用程序的就绪状态

笔者提示

  • 关于活性状态,Spring Boot在源码中有详细的解释:当应用程序的内部状态是正确的(CORRECT),那么它被认为是存活的。而失活状态意味着应用程序内部状态已经出现严重异常(BROKEN)而且无法正常恢复,只能通过重启应用解决该异常。相应地,活性状态有两个状态,分别为:CORRECTBROKEN
  • 关于就绪状态,Spring Boot在源码中也有详细的解释:当应用程序处于存活(也即活性状态为CORRECT)状态且可以接受流量时,被认为是就绪的(ACCEPTING_TRAFFIC)。而当应用程序无法接受流量时意味着就绪失败(REFUSING_TRAFFIC),基础设施必须停止对其路由。相应地,就行状态也包含两种:ACCEPTING_TRAFFICREFUSING_TRAFFIC(也就是说就绪状态更倾向于一个web术语)

自定义HealthIndicators(Writing Custom HealthIndicators)

可以通过注册 HealthIndicator 接口的Bean实现自定义的监控数据。该接口类需要实现health()方法并且返回一个Health类。该返回值必须包含一个状态,并且可选择性地包含详细信息以供显示。以下代码展示了一个HealthIndicator实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class MyHealthIndicator implements HealthIndicator {

@Override
public Health health() {
int errorCode = check();
if (errorCode != 0) {
return Health.down().withDetail("Error Code", errorCode).build();
}
return Health.up().build();
}

private int check() {
// perform some specific health check
return ...
}
}

提示

HealthIndicator接口类的名字去掉HealthIndicator后缀(如果存在的话)会作为Bean的名字,上述实例中,这个自定义HealthIndicator的名字是my。

除了Srping Boot内置的Status之外,返回的Health内也可以使用自定义的状态。在这种情况下,需要实现一个自定义的StatusAggregator ,或者对management.endpoint.health.status.order属性进行配置,来替代默认的状态配置。

例如,假如在某个HealthIndicator内返回了一个自定义状态:FATAL,为了配置优先级,需要增加如下配置:

1
2
3
4
5
management:
endpoint:
health:
status:
order: "fatal,down,out-of-service,unknown,up"

HTTP的响应状态码反映了系统的整体监控状态。默认情况下,OUT_OF_SERVICEDOWN的状态码是503。所有没有配置映射的健康状态(包括UP),其状态码为200。如果对自定义健康状态的状态码进行了配置,那么DOWNOUT_OF_SERVICE的默认状态码会被禁用。如果仍然想保留这些配置,需要和自定义的状态一起进行显式地定义。下面的例子中,将FATAL映射为503(service unavailable),并且保留了DOWNOUT_OF_SERVICE的原有的配置

1
2
3
4
5
6
7
8
management:
endpoint:
health:
status:
http-mapping:
down: 503
fatal: 503
out-of-service: 503

提示

如果想要更多控制权,可以实现一个自定义的HttpCodeStatusMapper Bean

以下表格展示了内置状态的默认映射配置:

状态
DOWN SERVICE_UNAVAILABLE (503)
OUT_OF_SERVICE SERVICE_UNAVAILABLE (503)
UP 默认没有映射,所以状态码是200
UNKNOWN 默认没有映射,所以状态码是200

响应式健康指示器(Reactive Health Indicators)

对于响应式的应用,例如使用了Spring WebFlux,ReactiveHealthContributor提供了非阻塞的契约来获得应用的健康信息。和传统的HealthContributor很类似,其健康信息收集自ReactiveHealthContributorRegistry(默认情况下,所有的 HealthContributorReactiveHealthContributor 实例都在ApplicationContext中)。在弹性调度器下(elastic scheduler),常规的HealthContributors并不会调用响应式的API。

提示

在响应式应用中,ReactiveHealthContributorRegistry应该在运行时状态下注册或反注册健康指示器(health indicators),如果想注册常规的HealthContributor,需要使用ReactiveHealthContributor#adapt对其包装。

如果想通过响应式API提供自定义健康指示器(health indicators),可以注册实现了ReactiveHealthIndicator 接口的Bean到Spring容器中,以下代码展示了一个ReactiveHealthIndicator的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MyReactiveHealthIndicator implements ReactiveHealthIndicator {

@Override
public Mono<Health> health() {
return doHealthCheck().onErrorResume((exception) ->
Mono.just(new Health.Builder().down(exception).build()));
}

private Mono<Health> doHealthCheck() {
// perform some specific health check
return ...
}
}

提示

如果想自动处理异常错误,可以继承AbstractReactiveHealthIndicator

自动装配的ReactiveHealthIndicators(Auto-configured ReactiveHealthIndicators)

Spring提供了一些内置的响应式健康指示器(ReactiveHealthIndicators),当它们可以可用时(端点被开启且被正常暴露时即为可用),会被自动装配。详情参考源文档第2.8.4节的表格,此处不再赘述

提示

在必要的情况下,响应式的指示器会替代非响应式的指示器。并且任何没有被处理过的HealthIndicator也会被自动包装为响应式

健康分组(Health Groups)

处于某些原因,将健康指示器进行分组是很有用的。

可以创建management.endpoint.health.group.<name>属性来创建一个健康分组。并且使用includeexclude属性来配置指示器ID。例如,假如想创建一个仅仅包含数据库指示器的分支,可以配置如下:

1
2
3
4
5
6
management:
endpoint:
health:
group:
custom:
include: "db"

然后可以通过链接:localhost:8080/actuator/health/custom访问该分组

同理,如果想创建一个不包含数据库指示器的分组,可以配置如下:

1
2
3
4
5
6
management:
endpoint:
health:
group:
custom:
exclude: "db"

默认情况下,如同系统的健康信息一样,分组沿用了相同的StatusAggregatorHttpCodeStatusMapper设置。不过这些设置可以在每个分组上单独定义。如果有需要的话,还可以覆盖show-detailsroles的配置,例如:

1
2
3
4
5
6
7
8
9
10
11
12
management:
endpoint:
health:
group:
custom:
show-details: "when-authorized"
roles: "admin"
status:
order: "fatal,up"
http-mapping:
fatal: 500
out-of-service: 500

提示

如果想将自定义的StatusAggregatorHttpCodeStatusMapper Bean注册到某个分组上,可以使用注解@Qualifier("groupname")对其进行限定

数据源健康(DataSource Health)

DataSource健康指示器用来展示标准数据源(standard data source)类型和路由数据源(routing data source)类型的Bean的健康信息。路由数据源的健康信息也包含了目标数据源(its target data sources)的健康信息。在监控信息端点的HTTP响应中,每一个路由数据源的目标数据源的名字,由路由键值(routing key)来决定。如果想把路由数据源排除,可以将management.health.db.ignore-routing-data-sources设置为true

Kubernetes探针(Kubernetes Probes)

部署在Kubernetes上的应用程序可以通过容器探针反馈内部状态信息。kubelet可以根据Kubernetes的配置调用这些探针并反馈探针结果。

Spring Boot提供了开箱即用的应用程序可用性状态管理。当应用程序被部署到Kubernetes环境下,Spring Boot Acuator会通过ApplicationAvailability接口收集“Liveness”和“Readiness”信息,并且在健康指示器LivenessStateHealthIndicatorReadinessStateHealthIndicator中使用这些信息。这些指示器的信息显示在全局端点("/actuator/health")中。也可用通过使用健康分组"/actuator/health/liveness""/actuator/health/readiness" 访问这些端点。

可以使用如下端点信息配置Kubernetes:

1
2
3
4
5
6
7
8
9
10
11
12
13
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: <actuator-port>
failureThreshold: ...
periodSeconds: ...

readinessProbe:
httpGet:
path: /actuator/health/readiness
port: <actuator-port>
failureThreshold: ...
periodSeconds: ...

提示

<actuator-port>应该被配置为一个可以被访问的端口号。可以将其配置为web服务器的端口,或者通过management.endpoint.health.probes.enabled配置,使用独立的端口

这些健康分组只有在应用程序运行在Kubernetes环境下时才会自动生效。当然,也可以通过配置management.endpoint.health.probes.enabled来开启这些功能。

提示

如果应用程序的启动时间比liveness指示器所反映的时间要长,Kubernetes提供了一个“startupProbe”探针作为替代方案。不过“startupProbe”探针并不一定必须配置,因为在所有的启动任务完成之前"readinessProbe"探针都处于失败状态。可以查看探针在应用程序生命周期中的行为

警告

如果端点(Actuator endpoints)被配置在独立的Spring上下文(a separate management context)中,需要注意的是,这些端点使用的web配置信息(端口、连接池、框架的组件等)也是独立于应用程序的。在这种情况下,即使应用程序已经无法正常工作(例如无法接受新连接),探针可能也会返回正常状态。

使用Kubernetes探针检测外部状态(Checking External State with Kubernetes Probes)

Spring Boot Actuator将“活性”(liveness)和“就绪”(readiness)探针配置为健康分组。这意味着对于这些探针来说,所有的健康分组特性都适用。例如,可以配置如下健康指示器:

1
2
3
4
5
6
management:
endpoint:
health:
group:
readiness:
include: "readinessState,customCheck"

默认情况下,Spring Boot没有将其他的健康指示器添加到这些健康分组中。

“活性”(liveness)探针的健康检测不应该依赖于外部系统(笔者注:例如mongoDB、Redis等),如果应用程序的活性状态处于当宕机状态,Kubernetes会尝试重新启动应用程序来解决这个问题。也就是说如果外部系统出现异常(例如数据库、Web API或外部缓存),那么Kubernetes会重启所有的应用程序,这会导致级联故障(笔者注:雪崩效应)

而对于“就绪”探针来说,开发人员必须谨慎处理外部系统的检测结果。比如Srping Boot就没有包含任何额外的健康检查。如果应用程序的就绪状态处于未就绪(unready)状态,Kubernetes就不会将流量路由到该应用。那么把这些状态加入到“就绪”探针的探测中是理应该的。某些外部系统可能不是应用程序的关键依赖(比如应用程序已经部署了熔断或者降级策略),在这种情况下,这些外部系统就绝不应该被包含到“就绪”探针中。不妙的是,外部系统都是作为通用模块被应用程序所依赖的,这就需要做一个抉择:将其加入到“就绪”探针,当外部系统发生故障时,让应用程序也停止服务(out of service);或者将这些外部系统排除在外,在外部系统发生故障时,从更高层级上处理这些故障,比如使用断路器。(Unfortunately, an external system that is shared by all application instances is common, and you have to make a judgement call: include it in the readiness probe and expect that the application is taken out of service when the external service is down, or leave it out and deal with failures higher up the stack, e.g. using a circuit breaker in the caller.)

提示

If all instances of an application are unready, a Kubernetes Service with type=ClusterIP or NodePort will not accept any incoming connections. There is no HTTP error response (503 etc.) since there is no connection. A Service with type=LoadBalancer might or might not accept connections, depending on the provider. A Service that has an explicit Ingress will also respond in a way that depends on the implementation - the ingress service itself will have to decide how to handle the “connection refused” from downstream. HTTP 503 is quite likely in the case of both load balancer and ingress.

此外,如果应用程序使用了Kubernetes的自动伸缩(autoscaling)机制,对于负载均衡后面的程序,探针的表现可能会不同,这要取决于自动伸缩的配置。

应用程序生命周期和探针状态(Application Lifecycle and Probe States)

Kubernetes探针所支持的重要一点就是保持了和应用程序生命周期的一致性。应用程序内存中的内部状态:AvailabilityState和探针实际上返回的状态可能会有巨大的差异:在生命周期的某些阶段,探针可能是不可用状态。

当应用启动和关闭时,Sprin Boot会发布事件,探针可以监听这些事件,并且暴露AvailabilityState信息。

下面表格展示了在不同阶段下,AvailabilityState和HTTP服务器的状态,当Spring Boot应用程序启动时:

启动阶段 活性状态 就绪状态 HTTP服务器 备注
Starting BROKEN REFUSING_TRAFFIC 未启动 Kubernetes检查“活性”探针,如果耗时过长,就重启应用程序。
Started CORRECT REFUSING_TRAFFIC 拒绝连接 应用程序上下文已经刷新,应用程序执行启动任务并且还不能接收流量
Ready CORRECT ACCEPTING_TRAFFIC 接受连接 启动任务已经执行完毕,应用程序可以接收流量

当Spring Boot应用程序关闭时:

关闭阶段 活性状态 就绪状态 HTTP服务器 备注
Running CORRECT ACCEPTING_TRAFFIC 接受连接 (此时)已经接受关闭请求
Graceful shutdown CORRECT REFUSING_TRAFFIC 不接受新连接 如果启用该特性,将会根据请求优雅地关闭进程
Shutdown complete N/A N/A 服务器关闭 应用程序上下文被关闭并且应用也被关闭

提示

查看 Kubernetes容器生命周期查看关于Kubernetes部署更多的细节

应用程序信息(Application Information)

应用程序信息可以暴露ApplicationContext内所有的InfoContributor Bean收集到的信息。Spring Boot内置了很多自动装配的InfoContributor Bean,并且用户也可以编写自定义的Bean

自动装配InfoContributors(Auto-configured InfoContributors)

在适当的情况下,如下InfoContributor类型的Bean会被自动装配:

名称 描述
EnvironmentInfoContributor 暴露Environment内所有info开头的属性(笔者注:例如配置在application.yaml内的info.name: myApp )
GitInfoContributor 如果git.properties文件存,则暴露git相关信息
BuildInfoContributor 如果META-INF/build-info.properties文件存在,则暴露构建相关信息

提示

可以通过配置management.info.defaults.enabled关闭上述任意端点

自定义应用程序信息(Custom Application Information)

可以通过在Spring配置文件中设置info.*配置暴露自定义的数据。所有的Environment属性下info开头的键值都会被自动暴露。例如,可以在application.properties下增加如下配置:

1
2
3
info.app.encoding=UTF-8
info.app.java.source=11
info.app.java.target=11

提示

除了上述硬编码,还可以在构建期间生成属性值

以Maven为例,可以将上述配置重写为:

1
2
3
info.app.encoding=@project.build.sourceEncoding@
info.app.java.source=@java.version@
info.app.java.target=@java.version@

Git提交信息(Git Commit Information)

另外一个有用的info端点是展示源码所在的git仓库的提交信息。当GitProperties Bean存在时,这些信息就可以在info端点下暴露出来。

提示

如果classpath根路径下面存在一个git.properties文件,GitProperties Bean会被自动装配,可以通过“生成git信息”获得更多细节

默认情况下,在属性存在的前提下,端点会暴露出git.branchgit.commit.idgit.commit.time。如果想关闭任意属性,需要在git.properties中排除掉。如果想展示完整的git信息(也就是说git.properties文件的所有内容),可以配置management.info.git.mode属性,如下所示:

1
2
3
4
management:
info:
git:
mode: "full"

如果想从info端点关闭git提交信息,可以通过将management.info.git.enabled属性设置为false,如下所示:

1
2
3
4
management:
info:
git:
enabled: false

构建信息(Build Information)

如果BuildProperties Bean存在,还可以暴露构建信息,并且当classpath下存在META-INF/build-info.properties文件时生效。

提示

Maven和Gradle插件都可以生成META-INF/build-info.properties文件,可以通过“Generate build information”或多更多细节

自定义InfoContributors(Writing Custom InfoContributors)

可以通过注册继承了 InfoContributor接口的Bean实现自定义的应用信息。如下所示:

1
2
3
4
5
6
7
8
@Component
public class MyInfoContributor implements InfoContributor {

@Override
public void contribute(Info.Builder builder) {
builder.withDetail("example", Collections.singletonMap("key", "value"));
}
}

如果访问info端点,将会看到如下的额外信息:

1
2
3
4
5
{
"example": {
"key" : "value"
}
}

通过HTTP进行监控与管理(Monitoring and Management over HTTP)

如果你正在开发一个Web应用程序,Srping Boot Actuator会自动装配所有已经开启(enabled)的端点,使通过HTTP暴露。默认规则是通过端点的id附加一个/actuator前缀作为URL路径。例如,health端点的路径为/actuator/health

提示

  • Spring Boot Actuator被Spring MVC、Spring WebFlux和 Jersey原生支持。如果Jersey和Spring MVC都处于可用状态,那么将会使用Spring MVC。
  • 为了获得如API文档 (HTMLPDF)中所述的正确JSON响应,需要将Jackon加入到依赖中

自定义端点路径(Customizing the Management Endpoint Paths)

某些情况下,自定义端点路径的前缀是很有必要的。比如,应用程序可能已经使用了/actuator路径作为其他用途。可以设置management.endpoints.web.base-path属性来调整端点的前缀,如下所示:

1
2
3
4
management:
endpoints:
web:
base-path: "/manage"

这会将端点的URL从/actuator/{id} 改为/manage/{id}(例如:/manage/info

提示

除非管理端口被配置为其他的的HTTP端口,否则management.endpoints.web.base-path的配置是相对于server.servlet.context-path(Servlet web应用)或spring.webflux.base-path(响应式web应用)的。如果management.server.port被配置了,那么management.endpoints.web.base-path是相对于management.server.base-path的路径。

如果想将端点映射到其他路径,可以修改management.endpoints.web.path-mapping属性,以下示例将/actuator/health映射为/healthcheck

1
2
3
4
5
6
management:
endpoints:
web:
base-path: "/"
path-mapping:
health: "healthcheck"

自定义端口(Customizing the Management Server Port)

如果应用程序是基于云部署,那么使用默认的HTTP端口来暴露端点是一个明智的选择。不过,如果应用程序运行在自己内部的数据中心,可能会倾向于使用其他的HTTP端口。

可以通过设置management.server.port属性调整HTTP端口,如下所示:

1
2
3
management:
server:
port: 8081

提示

在Cloud Foundry上,默认情况下,应用程序只在端口8080上接收HTTP和TCP路由请求。如果想在Cloud Foundry上使用自定义的端口,你需要明确地设置应用程序的路由,将流量转发到自定义端口。

配置SSL(Configuring Management-specific SSL)

当使用了自定义端口之后,也可以通过management.server.ssl.*属性配置独立的SSL。例如,可以让端点管理通过HTTP访问,而应用程序通过HTTPS访问,如下配置所示:

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8443
ssl:
enabled: true
key-store: "classpath:store.jks"
key-password: secret
management:
server:
port: 8080
ssl:
enabled: false

又或者,端点管理和应用程序都通过SSL访问,但是使用不同的证书和秘钥(keystore),如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8443
ssl:
enabled: true
key-store: "classpath:main.jks"
key-password: "secret"
management:
server:
port: 8080
ssl:
enabled: true
key-store: "classpath:management.jks"
key-password: "secret"

自定义服务器地址

通过设置management.server.address属性,可以自定义端点的访问地址。如果只想监听内部网络或运维内部网络(ops-facing network ),有或者只想监听来来自localhost的连接,这个设置就会很有用。

提示

只有当端点端口与应用程序端口不同时,才可以配置监听不同的地址。

如下配置不允许远程连接访问:

1
2
3
4
management:
server:
port: 8081
address: "127.0.0.1"

禁用HTTP端点(Disabling HTTP Endpoints)

如果不想通过HTTP暴露端点,可以将端口设置为-1,例如:

1
2
3
management:
server:
port: -1

也可以通过设置management.endpoints.web.exposure.exclude属性达到达到这个效果。如下配置所示:

1
2
3
4
5
management:
endpoints:
web:
exposure:
exclude: "*"

通过JMX进行监控和管理(Monitoring and Management over JMX)

JAVA管理扩展(JMX)提供了管理和监控应用程序的标准机制。这个特性默认没有被启用,可以通过设置spring.jmx.enabledtrue来开启。默认情况下,Spring Boot的JMX MBeans位于org.springframework.boot作用域下。要想完全控制JMX域下的端点,可以考虑实现EndpointObjectNameFactory接口。

自定义MBean名字(Customizing MBean Names)

通常情况下,MBean的名字由端点的id生成。例如,health端点就被暴露为:org.springframework.boot:type=Endpoint,name=Health

如果应用程序拥有的ApplicationContext不止一个的话,可能会发现名称冲突。若要解决该问题,可以设置属性spring.jmx.unique-namestrue,这样的话,MBean的名字就是唯一的了。

可以自定义暴露端点的JMX作用域,如下例所示:

1
2
3
4
5
6
7
spring:
jmx:
unique-names: true
management:
endpoints:
jmx:
domain: "com.example.myapp"

禁用JMX端点(Disabling JMX Endpoints)

如果不想通过JMX暴露端点,可以设置management.endpoints.jmx.exposure.exclude属性为*,如下所示:

1
2
3
4
5
management:
endpoints:
jmx:
exposure:
exclude: "*"

通过HTTP访问基于Jolokia的JMX端点(Using Jolokia for JMX over HTTP)

Jolokia作为一个JMX-HTTP桥接器提供了访问JMX bean的替代方案。如果想要使用Jolokia,需要添加依赖:org.jolokia:jolokia-core。以Maven为例,配置如下所示:

1
2
3
4
<dependency>
<groupId>org.jolokia</groupId>
<artifactId>jolokia-core</artifactId>
</dependency>

可以通过添加jolokia*management.endpoints.web.exposure.include属性中开启Jolokia端点。然后可以使用/actuator/jolokia该端点。

提示

Jolokia端点将Jolokia的servlet暴露为actuator端点。这意味着它是特定于Spring MVC和Jersey等servlet环境的。并且该端点在WebFlux应用程序中不可用。

自定义Jolokia(Customizing Jolokia)

Jolokia包含许多设置,通常可以是通过设置servlet参数进行配置。而在Spring Boot下中,可以使用application.properties。所有的Jolokia参数都以management.endpoint.jolokia.config作为前缀,举例如下:

1
2
3
4
management:
endpoint:
jolokia:
enabled: false

禁用Jolokia(Disabling Jolokia)

当使用了Jolokia而又不想让Spring Boot对其进行配置,可以将management.endpoint.jolokia.enabled设置为false,如下所示:

1
2
3
4
management:
endpoint:
jolokia:
enabled: false

日志(Loggers)

Spring Boot Actuator具备查看和设置正在运行中的应用程序的日志等级的能力。可以查看完整日志列表或者单独某个日志的配置,这些配置由两部分组成:显式配置的日志等级和日志框架给定的有效日志等级。这些日志等级可以是如下任意一个:

  • TRACE
  • DEBUG
  • INFO
  • WARN
  • ERROR
  • FATAL
  • OFF
  • null

null代表没有显示地配置日志

配置日志(Configure a Logger)

如果要配置一个日志,可以向资源URL POST一个特定的请求体,如下所示:

1
2
3
{
"configuredLevel": "DEBUG"
}

提示

如果想重置之前设置的日志等级(并且使用默认配置),可以将configuredLevel设置为null

指标(Metrics)

Spring Boot Actuator提供了对 Micrometer的依赖管理和自动装配。Spring内部提供了指标门面(an application metrics facade)模式,该模式提供了对于众多监控系统的支持,包括:

提示

要了解更多关于Micrometer的功能,请参考它的参考文档,特别是概念部分

入门(Getting started)

Spring Boot自动装配了一个组合类型(composite)的指标注册表(MeterRegistry),对于在classpath下发现的任何指标注册表(MeterRegistery)的实现,Spring Boot会将其加入到这个组合类型的指标注册表中。实际上,在运行期(runtime)的classpath下增加micrometer-registry-{system}(笔者注:此处的system可以替换成上述列表中的监控系统)依赖后,Spring Boot就会自动配置对应类型的注册表(registery)。

大多数注册表都有相同的特性。例如,即使(Micrometer的)注册表的实现类在classpath内,也可以将其禁用。例如,要禁用Datadog:

1
2
3
4
5
management:
metrics:
export:
datadog:
enabled: false

也可以禁用所有注册表,除非某注册表已经通过自己的配置单独启用,如下例所示:

1
2
3
4
5
management:
metrics:
export:
defaults:
enabled: false

Spring Boot将自动装配的注册器添加到Metrics类(笔者注:全类限定名为:io.micrometer.core.instrument.Metrics)的全局静态组合注册表中,触发显示禁用:

1
2
3
management:
metrics:
use-global-registry: false

可以注册任意数量的类型为MeterRegistryCustomizer的bean,这些bean可以实现对注册表的自定义化配置,例如当任意指标注册到指标注册表中时,每个为这些指标增加通用的Tag标记:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
public class MyMeterRegistryConfiguration {

@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return (registry) -> registry.config().commonTags("region", "us-east-1");
}
}

也可以通过更具体的泛型类型的支实现对特定注册表的自定义化配置:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration(proxyBeanMethods = false)
public class MyMeterRegistryConfiguration {

@Bean
public MeterRegistryCustomizer<GraphiteMeterRegistry> graphiteMetricsNamingConvention() {
return (registry) -> registry.config().namingConvention(this::name);
}

private String name(String name, Meter.Type type, String baseUnit) {
return ...
}
}

Spring Boot还内置的很多指标和仪表,可以通过配置文件或专用注解来控制这些指标。

支持的监控系统(Supported Monitoring Systems)

AppOptics

默认情况下,AppOptics注册表负责将指标周期性地推送到 api.appoptics.com/v1/measurements。为了把指标推送到Saas AppOptics平台,需要在配置里提供API令牌(API token):

1
2
3
4
5
6
management:
metrics:
export:
appoptics:
api-token: "YOUR_TOKEN"

Atlas

默认情况下, Atlas 指标暴露给本机正在运行的Atlas服务器。 Atlas服务器 的地址可以使用如下配置进行修改:

1
2
3
4
5
management:
metrics:
export:
atlas:
uri: "https://atlas.example.com:7101/api/v1/publish"

Datadog

Datadog注册表负责周期性地将指标推送给datadoghq。为了把指标推送到 Datadog,需要在配置里提供API秘钥(API key):

1
2
3
4
5
management:
metrics:
export:
datadog:
api-key: "YOUR_KEY"

Dynatrace

Dynatrace注册表负责将指标周期性地推送给配置的URL。为了把指标推送到Dynatrace,需要提供API令牌(API token)、设备ID(device ID)以及URL:

1
2
3
4
5
6
7
management:
metrics:
export:
dynatrace:
api-token: "YOUR_TOKEN"
device-id: "YOUR_DEVICE_ID"
uri: "YOUR_URI"

也可以修改推送指标到Dynatrace的间隔时间:

1
2
3
4
5
management:
metrics:
export:
dynatrace:
step: "30s"

Elastic

默认情况下,指标会被推送到本机运行的Elastic服务器上,而Elastic服务器的地址可以通过如下配置进行修改:

1
2
3
4
5
management:
metrics:
export:
elastic:
host: "https://elastic.example.com:8086"

Ganglia

默认情况下,指标会被推送到本机运行的Ganglia服务器上,Ganglia服务器的地址和端口可以通过如下配置进行修改:

1
2
3
4
5
6
management:
metrics:
export:
ganglia:
host: "ganglia.example.com"
port: 9649

Graphite

默认情况下,指标会被推送到本机运行的 Graphite服务器上, Graphite服务器的地址和端口可以通过如下配置进行修改:

1
2
3
4
5
6
management:
metrics:
export:
graphite:
host: "graphite.example.com"
port: 9004

Micrometer提供了一个默认的HierarchicalNameMapper,用于管理如何在多维度的指标名称扁平化指标名称之间进行映射转换

提示

如果想实现自定义的名称映射管理,需要自己实现GraphiteMeterRegistry并且提供自定义的HierarchicalNameMapperGraphiteConfigClock不需要自定义,它们会被Spring自动装配,除非用户已经自己定义。

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration(proxyBeanMethods = false)
public class MyGraphiteConfiguration {

@Bean
public GraphiteMeterRegistry graphiteMeterRegistry(GraphiteConfig config, Clock clock) {
return new GraphiteMeterRegistry(config, clock, this::toHierarchicalName);
}

private String toHierarchicalName(Meter.Id id, NamingConvention convention) {
return ...
}
}

Humio

默认情况下,指标被周期性地推送到 cloud.humio.com,为了把指标推送到Saas Humio平台,需要提供API令牌(API token):

1
2
3
4
5
management:
metrics:
export:
humio:
api-token: "YOUR_TOKEN"

也可以为每个要推送的指标配置一个或多个Tag标记,用来区分数据源:

1
2
3
4
5
6
7
management:
metrics:
export:
humio:
tags:
alpha: "a"
bravo: "b"

Influx

默认情况下,指标被推送(exported)给运行于本机的v1版本的 Influx,如果想把指标推送给InfluxDB v2,需要配置orgbucket和认证用的tokenInflux服务器的地址可以通过如下配置进行修改:

1
2
3
4
5
management:
metrics:
export:
influx:
uri: "https://influx.example.com:8086"

JMX

Micrometer提供了向JMX的层级映射,主要用来当做一种低成本且便携的本地查看指标的方式。默认情况下,指标被推送给JMX的metrics作用域(domain),而作用域也可以通过如下配置进行修改:

1
2
3
4
5
management:
metrics:
export:
jmx:
domain: "com.example.app.metrics"

Micrometer提供了一个默认的HierarchicalNameMapper,用于管理如何在多维度的指标名称扁平化指标名称之间进行映射转换

提示

如果想实现自定义的名称映射管理,需要自己实现JmxMeterRegistry并且提供自定义的HierarchicalNameMapperJmxConfig Clock不需要自定义,它们都被Spring自动装配,除非用户已经自己定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
>@Configuration(proxyBeanMethods = false)
>public class MyJmxConfiguration {

@Bean
public JmxMeterRegistry jmxMeterRegistry(JmxConfig config, Clock clock) {
return new JmxMeterRegistry(config, clock, this::toHierarchicalName);
}

private String toHierarchicalName(Meter.Id id, NamingConvention convention) {
return ...
}

>}

KairosDB

默认情况下,指标会被推送到本机运行的 KairosDB 服务器上, KairosDB 服务器的地址可以通过如下配置进行修改:

1
2
3
4
5
management:
metrics:
export:
kairos:
uri: "https://kairosdb.example.com:8080/api/v1/datapoints"

New Relic

New Relic注册表负责周期性地将指标推送给New Relic。为了把指标推送到 New Relic,需要在配置里提供API秘钥(API key)和账号id(account id):

1
2
3
4
5
6
management:
metrics:
export:
newrelic:
api-key: "YOUR_KEY"
account-id: "YOUR_ACCOUNT_ID"

也可以修改推送指标到New Relic的间隔时间:

1
2
3
4
5
management:
metrics:
export:
newrelic:
step: "30s"

默认情况下,指标以REST的形式推送。也可以使用Java Agent API,如果响应的类库在classpath路径内的情况下:

1
2
3
4
5
management:
metrics:
export:
newrelic:
client-provider-type: "insights-agent"

最后,也可以通过自定义实现NewRelicClientProvider bean获得完全的控制权。

Prometheus

Prometheus获取指标的方式是向应用程序拉取或轮询的指标数据。Spring Boot提供了一个可用的端点/actuator/prometheus,并以适当的方式提供给Prometheus指标采集器(Prometheus scrape)

提示

端点默认是禁用的,并且还需要开放暴露,可用查看暴露端点(Exposing Endpoints)获取更详细信息

以下是prometheus.yml配置文件中的scrape_config配置示例:

1
2
3
4
5
scrape_configs:
- job_name: 'spring'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['HOST:PORT']

对于某些无法被拉取的短暂任务或批量任务, Prometheus的Pushgateway组件提供了将指标推送给Prometheus的方式。如果要启用Pushgateway的支持,需要增加如下依赖:

1
2
3
4
<dependency>
<groupId>io.prometheus</groupId>
<artifactId>simpleclient_pushgateway</artifactId>
</dependency>

当Pushgateway 组件存在于classpath下,并且management.metrics.export.prometheus.pushgateway.enabled被设置为true时,Spring Boot会自动装配一个PrometheusPushGatewayManager bean。这个bean就复杂将指标推送到Pushgateway

可以通过management.metrics.export.prometheus.pushgateway.属性对PrometheusPushGatewayManager进一步配置,当然,用户也可以自定义PrometheusPushGatewayManager bean。

SignalFx

SignalFx注册表负责周期性地将指标推送给 SignalFx。为了把指标推送到 SignalFx,需要在配置里提供访问令牌(access token):

1
2
3
4
5
management:
metrics:
export:
signalfx:
access-token: "YOUR_ACCESS_TOKEN"

也可以修改推送指标到SignalFx的间隔时间:

1
2
3
4
5
management:
metrics:
export:
signalfx:
step: "30s"

Simple

Micrometer内置了一个简易的、在内存中的(in-memory)注册表。如果其他的注册表都没有被配置,它会将当做默认的指标监控系统。通过该系统,可以在指标端点中查看系统收集了哪些指标。

只要启用了其他任意监控系统,simple系统就会被自动禁用。当然也可以显式将其关闭:

1
2
3
4
5
management:
metrics:
export:
simple:
enabled: false

Stackdriver

Stackdriver注册表负责周期性地将指标推送给Stackdriver。为了把指标推送到SaaS Stackdriver平台,需要在配置里提供谷歌云的project id:

1
2
3
4
5
management:
metrics:
export:
stackdriver:
project-id: "my-project"

也可以修改推送指标到Stackdriver的间隔时间:

1
2
3
4
5
management:
metrics:
export:
stackdriver:
step: "30s"

StatsD

StatsD注册表通过UDP将指标推送到StatsD代理。默认情况下,指标将被推送到本机运行的StatsD代理。StatsD代理的主机(host)、端口(port)和协议(协议)可以通过以下方式修改:

1
2
3
4
5
6
7
management:
metrics:
export:
statsd:
host: "statsd.example.com"
port: 9125
protocol: "udp"

也可以修改StatsD所使用的line协议(line protocol)(默认为Datadog):

1
2
3
4
5
management:
metrics:
export:
statsd:
flavor: "etsy"

Wavefront

Wavefront注册表负责周期性地将指标推送给Wavefront。为了把指标推送到Wavefront,需要在配置里提供API令牌(API token):

1
2
3
4
5
management:
metrics:
export:
wavefront:
api-token: "YOUR_API_TOKEN"

你可能会使用一个Wavefront边车代理(Wavefront sidecar)或设置一个内部代理,用于将指标数据转发到Wavefront API主机:

1
2
3
4
5
management:
metrics:
export:
wavefront:
uri: "proxy://localhost:2878"

指标

如果是将指标发布到Wavefront代理(如文档所述),主机的配置格式必须为:proxy://HOST:PORT

也可以修改推送指标到Wavefront的间隔时间:

1
2
3
4
5
management:
metrics:
export:
wavefront:
step: "30s"

支持的指标和仪表(Supported Metrics and Meters)

对于(系统使用的)各种的技术组件,Spring Boot都提供了指标自动注册。大多数情况下,这些开箱即用的指标注册表会选择适当的指标,并将其发布到任意所支持的监控系统上。

JVM指标(JVM Metrics)

自动装配机制会使用Micrometer的核心代码开启JVM指标。JVM指标被发布在jvm.名称下。如下JVM指标都是支持的:

  • 各种内存和缓冲池详细信息
  • 垃圾收集相关统计
  • 线程利用率
  • 被加载/卸载的class数量

系统指标(System Metrics)

自动装配机制会使用Micrometer的核心代码开启系统指标。系统指标被发布在system.process.名称下。如下系统指标都是支持的:

  • CPU指标
  • 文件指标(File descriptor metrics)
  • 运行时间指标(包括系统的运行时间以及启动时的绝对时间)

日志指标(Logger Metrics)

自动装配机制支持Logback和Log4J2的日志事件。这些指标被发布在log4j2.events.logback.events.下面。

Spring MVC指标( Spring MVC Metrics)

自动装配机制会开启对 Spring MVC控制器(Spring MVC controllers)和请求处理器(functional handlers)的所有请求的采集。默认情况下,指标的名称前缀为 http.server.requests。可以通过设置 management.metrics.web.server.request.metric-name 来自定义该前缀。

@Controller类和@RequestMapping方法支持@Timed注解(详情参考:[@Timed注解支持(@Timed Annotation Support)](##@Timed注解支持(@Timed Annotation Support)))。如果不想开启所有的Spring MVC请求,可以将management.metrics.web.server.request.autotime.enabled设置为false,而仅使用@Timed注解。

默认情况下,Spring MVC相关的指标会被增加如下tag标记:

Tag 详情
exception 当处理请求出现异常时,被抛出的异常类的简短类名
method 请求方法(例如:GETPOST
outcome 基于响应状态码的返回值描述,1xx:INFORMATIONAL;2xx:SUCCESS;3xx:REDIRECTION;4xx:CLIENT_ERROR;5xx:SERVER_ERROR
status 响应状态码(例如200500
url 在变量替换之前使用请求的URI模板(例如,/api/person/{id})

如果想添加默认的tag标记,可以提供一个或多个实现了WebMvcTagsContributor@Bean,如果想替换(上述)tag标记,提供一个实现了WebMvcTagsProvider@Bean

提示

在某些情况下,控制器和请求处理器内处理的异常并不会以指标tag标记的方式(如上表)被记录。此时,应用程序可以通过将异常设置为请求的属性来确保该异常处理不会被遗漏。

Spring WebFlux指标(Spring WebFlux Metrics)

自动装配机制会开启对 Spring WebFlux控制器(Spring WebFlux controllers)和请求处理器(functional handlers)的所有请求的采集。默认情况下,指标的名称前缀为 http.server.requests。可以通过设置 management.metrics.web.server.request.metric-name 来自定义该前缀。

@Controller类和@RequestMapping方法支持@Timed注解(详情参考:[@Timed注解支持(@Timed Annotation Support)](##@Timed注解支持(@Timed Annotation Support)))。如果不想开启所有的Spring WebFlux请求,可以将management.metrics.web.server.request.autotime.enabled设置为false,而仅使用@Timed注解。

默认情况下,Spring WebFlux相关的指标会被增加如下tag标记:

Tag 详情
exception 当处理请求出现异常时,被抛出的异常类的简短类名
method 请求方法(例如:GETPOST
outcome 基于响应状态码的返回值描述,1xx:INFORMATIONAL;2xx:SUCCESS;3xx:REDIRECTION;4xx:CLIENT_ERROR;5xx:SERVER_ERROR
status 响应状态码(例如200500
url 在变量替换之前使用请求的URI模板(例如,/api/person/{id})

如果想添加默认的tag标记,可以提供一个或多个实现了WebFluxTagsContributor@Bean,如果想替换(上述)tag标记,提供一个实现了WebFluxTagsProvider@Bean

提示

在某些情况下,控制器和请求处理器内处理的异常并不会以指标tag标记的方式(如上表)被记录。此时,应用程序可以通过将异常设置为请求的属性来确保该异常处理不会被遗漏。

Jersey Server指标(Jersey Server Metrics)

只要Micrometer的micrometer-jersey2模块在classpath下,自动装配机制会开启所有由Jersey JAX-RS实现的请求。默认情况下,指标的名称前缀为 http.server.requests。可以通过设置 management.metrics.web.server.request.metric-name 来自定义该前缀。

默认情况下,Jersey server相关的指标会被增加如下tag标记:

Tag 详情
exception 当处理请求出现异常时,被抛出的异常类的简短类名
method 请求方法(例如:GETPOST
outcome 基于响应状态码的描述,1xx:INFORMATIONAL;2xx:SUCCESS;3xx:REDIRECTION;4xx:CLIENT_ERROR;5xx:SERVER_ERROR
status 响应状态码(例如200500
url 在变量替换之前使用请求的URI模板(例如,/api/person/{id})

如果想自定义这些tag标记,提供一个实现了JerseyTagsProvider接口的@Bean即可

HTTP Client指标(HTTP Client Metrics)

Spring Boot Actuator提供了对RestTemplateWebClient的监测管理。不过使用需要通过注入自动装配的builder来创建实例:

  • 通过RestTemplateBuilder创建RestTemplate
  • 通过WebClient.Builder创建WebClient

也可以使用自定义的MetricsRestTemplateCustomizerMetricsWebClientCustomizer实现同相同目的。

默认情况下,指标的名称前缀为 http.client.requests,可以通过修改management.metrics.web.client.request.metric-name来自定义该前缀

默认情况下,相关指标会增加如下tag标记:

Tag 详情
clientName URI的主机地址
method 请求方法(例如:GETPOST
outcome 基于响应状态码的返回值描述,1xx:INFORMATIONAL;2xx:SUCCESS;3xx:REDIRECTION;4xx:CLIENT_ERROR;5xx:SERVER_ERROR
status 正常情况下为HTTP响应状态码(例如200500),出现I/O异常时为IO_ERROR,其他情况为CLIENT_ERROR
url 在变量替换之前使用请求的URI模板(例如,/api/person/{id})

可以提供一个实现了RestTemplateExchangeTagsProviderWebClientExchangeTagsProvider接口的@Bean,实现对tag标记的自定义。RestTemplateExchangeTags and WebClientExchangeTags内也包含方便使用的静态函数。

Tomcat指标( Tomcat Metrics)

MBeanRegistry注册表被启用时,自动装配机制会开启对Tomcat的监测。默认情况下,MBeanRegistry是关闭的,可以通过将server.tomcat.mbeanregistry.enabled设置为true来开启。

Tomcat指标以tomcat.为前缀。

缓存指标(Cache Metrics)

当应用启动时,自动装配机制会开启所有已生效Caches的监测,并以cache.为前缀。这是基础监测指标的标准化配置(Cache instrumentation is standardized for a basic set of metrics.)。此外,某些缓存专属指标也是生效的。如下缓存库已经被支持:

  • Caffeine
  • EhCache 2
  • Hazelcast
  • Any compliant JCache (JSR-107) implementation
  • Redis

这些指标会被附加两个tag标记,它们分别是以缓存命名的tag,以及实现了CacheManager的bean的名字。

提示

只有启动时被配置的缓存会被绑定到指标注册表中。对于那些没有在配置中定义的缓存,例如当启动后,实时地或编程方式创建的缓存,则需要显示地进行注册。CacheMetricsRegistrar bean提供了简单易用的注册机制。

数据源指标(DataSource Metrics)

自动装配会启用对所有可用DataSource对象的监测,这些指标以jdbc.connections为前缀。数据源测量结果以gauges的方式显示连接池内当前活动连接数、空闲连接数、最大连接数和最小连接数。

这些指标也会被附加tag标记,这些tag标记的名字就是DataSource bean的名字。

提示

默认情况下,对于所有支持的数据源,Spring Boot都提供了元数据(metadata)。如果使用的数据源不支持这个开箱即用的特性,可以额外提供DataSourcePoolMetadataProvider,可以到DataSourcePoolMetadataProvidersConfiguration查看使用示例。

Hibernate指标( Hibernate Metrics)

如果org.hibernate:hibernate-micrometer在classpath内,所有生效且开启统计EntityManagerFactory实例都会被监测,指标名为:hibernate

这些指标也会被附加tag标记,tag标记的名字就是EntityManagerFactory bean的名字。

如果要开启统计功能,JPA属性hibernate.generate_statistics必须要被设置为true,可以通过如下示例将EntityManagerFactory开启:

1
2
3
4
spring:
jpa:
properties:
"[hibernate.generate_statistics]": true

Spring Data Repository指标(Spring Data Repository Metrics)

自动装配机制会开启Spring Data Repository的所有函数调用的监测。默认情况下,指标名为spring.data.repository.invocations,可以通过management.metrics.data.repository.metric-name对其修改。

Repository类及其方法支持@Timed(查看@Timed注解支持了解更多细节)注解。如果不想对所有的Repository调用都开启指标监控,可以将management.metrics.data.repository.autotime.enabled设置为false,并且单独使用@Timed

默认情况下,Repository相关的指标会被附加如下tag标记:

Tag 详情
repository Repository的简单类名
method 被调用的Repository方法名
state 状态(SUCCESS, ERROR, CANCELED or RUNNING
exception 当调用出现异常时,异常类的简单类名

如果想修改这些默认的tag标记,配置一个实现了RepositoryTagsProvider接口的@Bean

RabbitMQ指标(RabbitMQ Metrics)

自动装配机制会开启所有可用的RabbitMQ 连接的监控,指标名为rabbitmq

Spring Integration指标(Spring Integration Metrics)

任何时候,当MeterRegistry bean可用时,Spring Integration会自动提供基于Micrometer的监测支持。并且指标会被发布在spring.integration.下面。

Kafka指标(Kafka Metrics)

自动装配机制会分别为自动装配的消费者工厂(consumer factory)和生产者工厂(producer factory)注册一个 MicrometerConsumerListenerMicrometerProducerListener。也会为StreamsBuilderFactoryBean注册一个KafkaStreamsMicrometerListener。更多详情,请参考Spring Kafka文档的 Micrometer原生指标

MongoDB指标(MongoDB Metrics)

命令指标(Command Metrics)

自动装配机制会为自动装配的MongoClient注册一个MongoMetricsCommandListener

对于mongoDB driver下的每一条指令,都会创建一个名为mongodb.driver.commands的计时器指标(timer metric)。每个指标默认会附加如下tag标记:

Tag 详情
command 命令的名称
cluster.id 执行命令的monoDB集群的id
server.address 执行命令的mongoDB的的服务器地址
status 命令返回结果(SUCCESSFAILED中的一个)

如果想替换默认的指标tag标记,可以定义一个MongoCommandTagsProvider bean,如下所示:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
public class MyCommandTagsProviderConfiguration {

@Bean
public MongoCommandTagsProvider customCommandTagsProvider() {
return new CustomCommandTagsProvider();
}
}

如果想禁用上述命令指标,可以按照如下指令进行设置:

1
2
3
4
5
management:
metrics:
mongo:
command:
enabled: false

连接池指标(Connection Pool Metrics)

自动装配机制会为自动装配的MongoClient注册一个MongoMetricsConnectionPoolListener

连接池会创建如下gauge指标:

  • mongodb.driver.pool.size上报当前连接池内的连接数量,包括空闲和使用者的连接。
  • mongodb.driver.pool.checkedout上报连接池内正在使用的连接数量
  • mongodb.driver.pool.waitqueuesize上报连接池等待队列的大小

默认情况下,每个指标都会被附加如下tag标记:

Tag 详情
cluster.id 当前连接池对应的monoDB集群的id
server.address 当前连接池对应的monoDB集群的服务器地址

如果想替换默认的指标tag标记,可以定义一个MongoConnectionPoolTagsProvider bean,如下所示:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
public class MyConnectionPoolTagsProviderConfiguration {

@Bean
public MongoConnectionPoolTagsProvider customConnectionPoolTagsProvider() {
return new CustomConnectionPoolTagsProvider();
}
}

如果想禁用上述连接池指标,可以按照如下指令进行设置:

1
2
3
4
5
management:
metrics:
mongo:
connectionpool:
enabled: false

@Timed注解支持(@Timed Annotation Support)

io.micrometer.core.annotation包内的@Timed注解可以被上述列出的某些指标支持。一旦被支持,这个注解既可以用在类层级,也可以用在函数层级上。

例如,如下代码展示了这个注解如何监测@RestController类的所有请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@Timed
public class MyController {

@GetMapping("/api/addresses")
public List<Address> listAddress() {
return ...
}

@GetMapping("/api/people")
public List<Person> listPeople() {
return ...
}
}

如果只想监测某一个请求,可以将注解从类层级移到方法层级上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
public class MyController {

@GetMapping("/api/addresses")
public List<Address> listAddress() {
return ...
}

@GetMapping("/api/people")
@Timed
public List<Person> listPeople() {
return ...
}
}

如果想修改方法层级的指标信息,可以对该注解组合使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@Timed
public class MyController {

@GetMapping("/api/addresses")
public List<Address> listAddress() {
return ...
}

@GetMapping("/api/people")
@Timed(extraTags = { "region", "us-east-1" })
@Timed(value = "all.people", longTask = true)
public List<Person> listPeople() {
return ...
}
}

提示

带有longTask = true@Timed注解为方法启用一个长时间任务计时器指标(long task timer)。该指标需要单独的名称,并且可以与短时间任务计时器指标进行叠加。(译者注:此处没有说明叠加内容,如何叠加,需要后续进一步研究)

注册自定义指标(Registering Custom Metrics)

如果想注册自定义指标,可以将MeterRegistry注入到自己的组件中,如下面例子所示:

1
2
3
4
5
6
7
8
9
10
@Component
public class MyBean {

private final Dictionary dictionary;

public MyBean(MeterRegistry registry) {
this.dictionary = Dictionary.load();
registry.gauge("dictionary.size", Tags.empty(), this.dictionary.getWords().size());
}
}

如果要注册的指标需要依赖其他bean,建议使用MeterBinder来注册,如下面的例子所示:

1
2
3
4
5
6
7
public class MyMeterBinderConfiguration {

@Bean
public MeterBinder queueSize(Queue queue) {
return (registry) -> Gauge.builder("queueSize", queue::size).register(registry);
}
}

使用MeterBinder可以确保依赖的正确性,并且在指标被检索时,这个bean是可用的。如果经常重复性跨组件或应用监测一组指标,MeterBinder会非常有用。

提示

默认情况下,所有的MeterBinder bean会自动绑定到由Spring管理的MeterRegistery中。

自定义特定指标(Customizing Individual Metrics)

如果想对特定的Meter实例进行自定义,可用使用io.micrometer.core.instrument.config.MeterFilte接口。

例如,如果想对所有以com.example开头的指标进行自定义:将mytag.region改为mytag.area,可用按照如下示例操作:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
public class MyMetricsFilterConfiguration {

@Bean
public MeterFilter renameRegionTagMeterFilter() {
return MeterFilter.renameTag("com.example", "mytag.region", "mytag.area");
}
}

提示

默认情况下,所有MeterFilter bean会自动绑定到由Spring管理的MeterRegistery中。要确保将自定义指标注册到由Spring管理的MeterRegistery上,并且在Metrics中不能含有静态函数。而全局的注册表并没有被Spring管理!

自定义通用Tag标记(Common Tags)

通用Tag标记常用来对诸如主机名(host)、实例名(instance)、地区(region)、集群(stack)等操作环境进行更细粒度的划分。通用Tag标记会被附加到所有的指标上,可以按照如下示例进行配置:

1
2
3
4
5
management:
metrics:
tags:
region: "us-east-1"
stack: "prod"

以上示例中,会分别为每个指标增加两个tag标记:region:us-east-1stack:prod

提示

如果使用Graphite,通用tag标记的顺序非常重要。因为这个方式并不能保证通用tag标记的的属性,Graphite用户请使用MeterFilter的方式。

精确指标配置(Per-meter Properties)

除了MeterFilter之外,也可以通过配置对每一个指标进行调整。可以在配置中指定指标的名称,所有以该名称为前缀的指标都会被调整。下面示例中,所有以example.remote开头的指标都会被禁用:

1
2
3
4
5
management:
metrics:
enable:
example:
remote: false

如下配置都可以应用到指标的调整:

属性 详情
management.metrics.enable 是否禁止指标
management.metrics.distribution.percentiles-histogram 是否使用于百分比直方图(Whether to publish a histogram suitable for computing aggregable (across dimension) percentile approximations.)
management.metrics.distribution.minimum-expected-value, management.metrics.distribution.maximum-expected-value 通过设置监测有效范围可以减少直方图中桶的数量(Publish less histogram buckets by clamping the range of expected values.)
management.metrics.distribution.percentiles 需要被统计的百分位数(Publish percentile values computed in your application)
management.metrics.distribution.slo 额外自定义的应用级的直方图(Publish a cumulative histogram with buckets defined by your service-level objectives)

译者提示

  • 关于上述配置可以参考IBM developerWorks的一篇文章《使用 Micrometer 记录 Java 应用性能指标》(可惜网站已经关闭,该链接为github的备份)
  • management.metrics.enable是一个map,key为指标ID前缀(最长的优先匹配),value为Boolean
  • management.metrics.distribution.percentiles 代表要统计的百分位数,这是一个map,key为指标ID前缀(最长的优先匹配),value为double型且取值区间为[0,1]的数组,代表某指标要统计的百分位数。百分位数是统计学指标,详情可以参考wikipedia词条
  • management.metrics.distribution.slo 针对的是整个应用,而无法针对某一个指标进行单独配置

更多关于percentiles-histogrampercentiles的详情,可以参考micrometer文档的 “直方图和百分位数”

指标端点(Metrics Endpoint)

Spring Boot提供了一个metrics端点,作为诊断性的目的,可以用来检查应用程序收集到的指标。这个端点默认没有被暴露出来,可以查看暴露端点(Exposing Endpoints)获得更多细节。

/actuator/metrics列出了所有可用的端点的名称,可以通过指定某指标的名称进一步查看更详细信息,例如:/actuator/metrics/jvm.memory.max

提示

此处使用的指标名称应该和代码中的名字相匹配,而不是将指标名称进行映射转换后,特定监控系统的指标名称。例如,Prometheus使用的是蛇形命名法,jvm.memory.max会被映射转换成jvm_memory_max。此时,在metrics端点中,仍然应该使用jvm.memory.max

可以在查询语句上增加任意数量的查询参数:tag=KEY:VALUE,从而获得某一维度上的详细指标,例如:/actuator/metrics/jvm.memory.max?tag=area:nonheap

提示

measurements字段是某个指标下所有tag的统计数据的总和,在上面的例子中,这个数据是堆内存中“Code Cache”、“Compressed Class Space”和“Metaspace”各自最大值的总和,如果只想查看“Metaspace”的的最大值,可用在查询语句上增加额外的tag标记:tag=id:Metaspace,例如:/actuator/metrics/jvm.memory.max?tag=area:nonheap&tag=id:Metaspace

审计(Auditing)

一旦Spring Security被启用,Spring Boot Actuator就有了一个灵活的审计框架可以发布事件(默认情况下,包括“认证成功”(authentication success)、“失败”(failure)和“拒绝访问”(access denied)的异常)。该特性对于报告和实现基于身份验证失败的锁定策略非常有用。

可以通过定义一个AuditEventRepository类型的bean开启审计功能。为了方便用户,Spring Boot内置了一个InMemoryAuditEventRepository。不过它的能力有限,我们建议只在开发环境下使用。至于生产环境,需要创建自己的AuditEventRepository

自定义审计(Custom Auditing)

如果想自定义安全事件,可以提供自定义的AbstractAuthenticationAuditListenerAbstractAuthorizationAuditListener的实现。

您还可以对自己的业务事件使用审计服务。要做到这一点,可以将AuditEventRepository bean注入到自己的组件中并直接使用它,或者使用Spring ApplicationEventPublisher(实现ApplicationEventPublisherAware接口)发布一个AuditApplicationEvent

HTTP追踪(HTTP Tracing)

可以在应用中提供一个HttpTraceRepository类型的bean开启HTTP追踪功能。为了方便用户,Spring Boot内置了一个InMemoryHttpTraceRepository 用于存储最近100条请求和响应。相比于其他最终方案,默认提供的InMemoryHttpTraceRepository功能有限,我们建议仅在开发环境下使用。对于生产环境,请使用具备生产就绪特性的追踪、观测方案。例如ZipKin或Spring Cloud Sleuth。此外,也可以提供一个自定义的HttpTraceRepository满足业务需求。

httptrace端点可以用来获取存储在HttpTraceRepository中的请求响应的信息。

自定义HTTP追踪(Custom HTTP tracing)

如果想自定义追踪数据,使用management.trace.http.include。对于更加深入的自定义,可以考虑注册自定义实现的HttpExchangeTracer bean。

进程监控(Process Monitoring)

在Spring Boot中,有两个类可以用来创建文件,这在监控进程时非常有用:

  • ApplicationPidFileWriter 可以创建一个包含应用PID的文件(默认情况下,文件位于应用目录,文件名为:application.pid
  • WebServerPortFileWriter创建一个(或多个)包含当前web服务器的端口的文件

这两个类默认是没有被激活,可以通过如下方式将其开启:

扩展配置(Extending Configuration)

META-INF/spring.factories中,你可以通过如下方式激活一个或多个监听,从而实现PID文件的写入:

1
2
3
org.springframework.context.ApplicationListener=\
org.springframework.boot.context.ApplicationPidFileWriter,\
org.springframework.boot.web.context.WebServerPortFileWriter

编程方式支持(Programmatically)

也可以通过调用SpringApplication.addListeners(…)来激活一个监听器,并传入一个Writer对象,这种方式允许你在Writer的构造函数中自定义文件名、文件路径。

Cloud Foundry 支持(Cloud Foundry Support)

Spring Boot Actuator对Cloud Foundry提供了额外支持,当应用被部署到兼容的Cloud Foundry实例时,该特性会被激活。/cloudfoundryapplication提供了另外一种到所有端点@Endpoint bean安全路由。

提示

对于常规用户来说,/cloudfoundryapplication路径不能被直接访问。如果想使用该端点,需要在请求中提供一个UAA令牌(UAA token)。

禁用Cloud Foundry Actuator支持(Disabling Extended Cloud Foundry Actuator Support)

如果想完全禁用/cloudfoundryapplication端点,可以进行如下配置:

1
2
3
management:
cloudfoundry:
enabled: false

Cloud Foundry自签名证书(Cloud Foundry Self-signed Certificates)

默认情况下,/cloudfoundry端点的安全认证会对Cloud Foundry服务进行SSL调用。如果您的Cloud Foundry UAA或Cloud Controller服务使用自签名证书,您需要设置以下属性:

1
2
3
management:
cloudfoundry:
skip-ssl-validation: true

自定义上下文路径(Custom Context Path)

如果服务器的上下文路径并非/,而是其他路径,那么根路径下的Cloud Foundry端点会不可用。例如,对于配置server.servlet.context-path=/app,Cloud Foundry端点路径为:/app/cloudfoundryapplication/*

如果想实现这个需求:不管服务器的上下文路径如何配置, Cloud Foundry端点的路径都位于:/cloudfoundryapplication/*,需要在应用中进行配置,至于配置方式,根据所使用的web服务器的不同而不尽相同。以Tomcat为例,可以使用如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Configuration(proxyBeanMethods = false)
public class MyCloudFoundryConfiguration {

@Bean
public TomcatServletWebServerFactory servletWebServerFactory() {
return new TomcatServletWebServerFactory() {

@Override
protected void prepareContext(Host host, ServletContextInitializer[] initializers) {
super.prepareContext(host, initializers);
StandardContext child = new StandardContext();
child.addLifecycleListener(new Tomcat.FixContextListener());
child.setPath("/cloudfoundryapplication");
ServletContainerInitializer initializer = getServletContextInitializer(getContextPath());
child.addServletContainerInitializer(initializer, Collections.emptySet());
child.setCrossContext(true);
host.addChild(child);
}

};
}

private ServletContainerInitializer getServletContextInitializer(String contextPath) {
return (classes, context) -> {
Servlet servlet = new GenericServlet() {

@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
ServletContext context = req.getServletContext().getContext(contextPath);
context.getRequestDispatcher("/cloudfoundryapplication").forward(req, res);
}

};
context.addServlet("cloudfoundry", servlet).addMapping("/*");
};
}

}

接下来要读什么(What to Read Next)

你可能想读一些关于图形工具的文档,例如Graphite](https://graphiteapp.org/)

或者,如果想继续深挖,可以阅读部署选项相关知识,或者提前了解有关Spring Boot构建工具插件的知识。

问题的引出

今天上午收到之前项目同事发过来的微信留言:

我看到现在他(指的是《深入理解Java虚拟机:JVM高级特性与最佳实践》作者)还没有解释card table为何一个元素占用1个byte,而不是我理解的1bit

我们看看该问题的上下文是怎样的,在《深入理解Java虚拟机:JVM高级特性与最佳实践》第三版第3章第3.4.5节中,作者写到:

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代 码是HotSpot默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0

此处确实写的是字节数组,那么为什么不是布尔数组呢?经过一番短暂电话沟通和后,我们并没有得出一个明确结论。

问题的排查

正好最近空闲时间比较多,先去深圳湾体育场打个疫苗,然后按照侯捷大神的名言:源码面前,了无秘密,我倒要看看源码里究竟是怎么实现的。在jdk8中card table的实现类为: cardTableModRefBS ,通过一顿分享后,答案基本清晰,抽取出和本文相关的代码并进行相应的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
class CardTableModRefBS: public ModRefBarrierSet {

public:
// Constants
enum SomePublicConstants {
card_shift = 9,
card_size = 1 << card_shift,
card_size_in_words = card_size / sizeof(HeapWord)
};

//卡页的状态等相关值,这里很有意思,之所以没用CardStatus,推测是因为这里面的定义不仅仅包含
//卡页的状态,还有其他维度上的定义,比如标记某卡页为最后一个的:last_card
enum CardValues {
//某卡页是否干净
clean_card = -1,
// The mask contains zeros in places for all other values.
clean_card_mask = clean_card - 31,

dirty_card = 0,
precleaned_card = 1,
claimed_card = 2,
deferred_card = 4,
last_card = 8,
CT_MR_BS_last_reserved = 16
};

//封装了上述枚举值
static int clean_card_val() { return clean_card; }
static int clean_card_mask_val() { return clean_card_mask; }
static int dirty_card_val() { return dirty_card; }
static int claimed_card_val() { return claimed_card; }
static int precleaned_card_val() { return precleaned_card; }
static int deferred_card_val() { return deferred_card; }

//本卡表所管理的堆内存区域
const MemRegion _whole_heap; // the region covered by the card table
//卡表数组的长度(也即堆内存中内存块的个数,每一个内存块的大小为:2^card_shift = 2^9 = 256字节)
size_t _byte_map_size; // in bytes
//卡表数组
jbyte* _byte_map; // the card marking array
//关键点1:
//原注释翻译:卡表数组相对于堆内存起始位置基址的偏移,
//如果堆的首地址为0x0,那么这将是_byte_map的第0个元素。而实际上,堆是从某个较高的地址开始的,
//因此该指针实际上指向了_byte_map数组前面的某个位置
//上面这么直接翻译实在难以理解,一语道破:hotspot在实现堆内存和卡表数组之间相互寻址时,和8086/8088中
//的寄存器相对寻址方式极其相似!(甚至说原理上是完全一致的)该寻址的方式为:
//有效地址 = 基址寄存器 + 偏移量,变换成公式为:EA = BX/BP + 8位或16位位移量
//而此处的byte_map_base正是公式中的偏移量,更详细解释参考下面的初始化函数:initialize中的注释
// Card marking array base (adjusted for heap low boundary)
// This would be the 0th element of _byte_map, if the heap started at 0x0.
// But since the heap starts at some higher address, this points to somewhere
// before the beginning of the actual _byte_map.
jbyte* byte_map_base;

void CardTableModRefBS::initialize() {

//找到所管理的堆内存的起始和结束位置
HeapWord* low_bound = _whole_heap.start();
HeapWord* high_bound = _whole_heap.end();

//为卡表数组申请堆内存
ReservedSpace heap_rs(_byte_map_size, rs_align, false);

//将base()返回的地址作为数组地址(数组的地址就是数组首元素的地址)(推测base()应该代表这块堆内存的首地址)
_byte_map = (jbyte*) heap_rs.base();
//关键点2:
//下面这行代码中,uintptr_t(low_bound) >> card_shift的含义是:计算堆起始位置的基址(并且间隔为256个字节)
//而整个表达式的含义是:计算卡表数组地址到堆起始位置基址的偏移
//当我们把这个表达式切换一下:_byte_map = (uintptr_t(low_bound) >> card_shift) + byte_map_base;
//那么表达的含义是:给定任意的堆内存地址p,它映射到卡表数组中的地址都可以用自己的基址加上byte_map_base算出来,
//即:(uintptr_t(p) >> card_shift) + byte_map_base,也即:byte_map_base[(uintptr_t(p) >> card_shift)]
//而这正是byte_for函数的实现
byte_map_base = _byte_map - (uintptr_t(low_bound) >> card_shift);
//断言:堆内存的起始位置和结束位置应该都在卡表数组内
assert(byte_for(low_bound) == &_byte_map[0], "Checking start of map");
assert(byte_for(high_bound-1) <= &_byte_map[_last_valid_index], "Checking end of map");

//省略...
}

//计算任意堆内存地址映射到卡表数组的地址
// Mapping from address to card marking array entry
jbyte* byte_for(const void* p) const {
assert(_whole_heap.contains(p),
err_msg("Attempt to access p = "PTR_FORMAT" out of bounds of "
" card marking array's _whole_heap = ["PTR_FORMAT","PTR_FORMAT")",
p2i(p), p2i(_whole_heap.start()), p2i(_whole_heap.end())));
//这个表达式实际上就是上面initialize中分析的结果
jbyte* result = &byte_map_base[uintptr_t(p) >> card_shift];
assert(result >= _byte_map && result < _byte_map + _byte_map_size,
"out of bounds accessor for card marking array");
return result;
}

//计算任意堆内存地址到卡表数组的索引
// Mapping from address to card marking array index.
size_t index_for(void* p) {
assert(_whole_heap.contains(p),
err_msg("Attempt to access p = "PTR_FORMAT" out of bounds of "
" card marking array's _whole_heap = ["PTR_FORMAT","PTR_FORMAT")",
p2i(p), p2i(_whole_heap.start()), p2i(_whole_heap.end())));
//先计算该地址映射到卡表数组内的位置,然后再减去卡表数组的地址(也即数组首元素的地址),就是相对于数组地址的
//偏移,也即数组索引
return byte_for(p) - _byte_map;
}

//某个卡页是否脏了
// These are used by G1, when it uses the card table as a temporary data
// structure for card claiming.
bool is_card_dirty(size_t card_index) {
return _byte_map[card_index] == dirty_card_val();
}

}

问题的结论

通过源码分析可知,被管理的堆中每一个内存地址都可以被映射到卡表数组中,而实际上不需要精细到每一个内存都做映射,这将导致极大的内存浪费,因此hotspot将每256个字节划分为一块内存(为这块内存取个好听的名字:卡页),这一块内存都映射到数组的某个元素上,而实际上每一个卡页不仅仅只有脏(dirty_card)和干净(clean_card)两种状态,还包括其他状态,例如:precleaned_card、claimed_card、deferred_card等,至于这些状态的含义嘛,这又是另外一篇博客的事情了。

简述一下spring的启动流程,省得每次都要翻看源码,流程图如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SpringApplication->SpringApplication#SpringApplication: 1-构造函数
SpringApplication#SpringApplication->SpringApplication#SpringApplication: 检测服务器类型
SpringApplication#SpringApplication->SpringApplication#SpringApplication: 2-META-INF/spring.factories
SpringApplication#SpringApplication->SpringApplication#SpringApplication: 3-实例化初始化器、监听器等
SpringApplication#SpringApplication->SpringApplication:
SpringApplication->SpringApplication#run:
SpringApplication#run->DefaultBootstrapContext: new DefaultBootstrapContext()
SpringApplication#run->SpringApplicationRunListener:starting
SpringApplication#run->SpringApplication#run:prepareEnvironment
SpringApplication#run->SpringApplicationRunListener:environmentPrepared
SpringApplication#run->AbstractApplicationContext:createApplicationContext
SpringApplication#run->SpringApplication#run:4-prepareContext
SpringApplication#run->ApplicationContextInitializer:5-initialize(ConfigurableApplicationContext applicationContext)
SpringApplication#run->SpringApplicationRunListener:contextPrepared
SpringApplication#run->SpringApplication#run:load
SpringApplication#run->SpringApplicationRunListener:contextLoaded
SpringApplication#run->AbstractApplicationContext:refresh
AbstractApplicationContext->AbstractApplicationContext: prepareBeanFactory
AbstractApplicationContext->AbstractApplicationContext: 6-invokeBeanFactoryPostProcessors
AbstractApplicationContext->AbstractApplicationContext: XXX一大堆东西,偷懒不写,改天补上
SpringApplication#run->SpringApplicationRunListener:started
SpringApplication#run->SpringApplication#run:7-callRunners

对于上图的解释:

  • 1-SpringApplication(ResourceLoader, Class<?>[])
  • 2-读取classpath下所有jar包内META-INF/spring.factories文件
  • 3-实例化:Bootstrapper、ApplicationContextInitializer、ApplicationListener
  • 5-这一步的最佳实践是:调用入参applicationContext的addBeanFactoryPostProcessor函数,也即注册自定义的BeanFactoryPostProcessor
  • 6-这一步中,会遍历AbstractApplicationContext内的列表:beanFactoryPostProcessors,并且当且仅当某一个BeanFactoryPostProcessor同时还是BeanDefinitionRegistryPostProcessor时,才会执行BeanDefinitionRegistryPostProcessor#postProcessBeanDefinitionRegistry,这两个接口的继承关系如图所示(beanFactoryPostProcessors列表的添加时机可以看上面第5步):

  • 7-先调用所有的ApplicationRunner,再调用所有的CommandLineRunner

至于spring bean生命周期,直接摘抄官网文档(来源:spring docs):

  1. BeanNameAware’s setBeanName
  2. BeanClassLoaderAware’s setBeanClassLoader
  3. BeanFactoryAware’s setBeanFactory
  4. EnvironmentAware’s setEnvironment
  5. EmbeddedValueResolverAware’s setEmbeddedValueResolver
  6. ResourceLoaderAware’s setResourceLoader (only applicable when running in an application context)
  7. ApplicationEventPublisherAware’s setApplicationEventPublisher (only applicable when running in an application context)
  8. MessageSourceAware’s setMessageSource (only applicable when running in an application context)
  9. ApplicationContextAware’s setApplicationContext (only applicable when running in an application context)
  10. ServletContextAware’s setServletContext (only applicable when running in a web application context)
  11. postProcessBeforeInitialization methods of BeanPostProcessors
  12. InitializingBean’s afterPropertiesSet
  13. a custom init-method definition
  14. postProcessAfterInitialization methods of BeanPostProcessors

当bean factory关闭时,如下生命周期函数将被调用:

  1. postProcessBeforeDestruction methods of DestructionAwareBeanPostProcessors
  2. DisposableBean’s destroy
  3. a custom destroy-method definition

  • 给出了画出干净的曲线轮廓的方法:不管是垂直方向还是水平方向上的像素,其过渡是渐进的,例如一个干净的倒角的曲线中(如本文中第4个对话气泡中的曲线),垂直方向的像素个数分别是:4、3、2、1、2、3、4,另外一个曲线举例(如本文中第5个对话气泡中的曲线):5、4、4、3、2、2、1、1、1、1、1,也就是说前后两个像素不能出现大的跳跃(来源:RHLPixels的像素画教程)。

  • 对于IsoMetric风格正方体上方的菱形,顶部和底部的角使用3个像素,这样的情况下,两个角之间的棱线就可以画在中间像素上了,详情可以参考本文的第4段描述(来源:RHLPixels的像素画教程

  • Isometric风格人物的其他的Isometric风格的原则一样(来源:RHLPixels的像素画教程):

    • 水平线和垂直线保持2:1像素的坡度
    • 有3个面可以被看到,因此头顶、肩膀都要画出厚度
  • 颜色渐变技巧:一个分成5个色带,左右两侧为分别为纯色A和纯色B,中间色带为A和B混合后的纯色X,左侧第2个色带为A和X的抖动颜色,右侧第2个色带为B和X的抖动颜色(来源:RHLPixels的像素画教程

本文翻译自:Pixels and voxels, the long answer(已获得作者翻译许可),原文作者:Matej ‘Retro’ Jan


今年早些时候,有人在Quora上问:

像素和体素有什么区别?

这个问题很让人抓狂,相比于给出直接答案,最终我写了一篇关于这个主题的文章。

我能想到提问者为什么会问这个问题。因为我们经常会看到一些像素电影海报之类的东西,这会让人很困惑。这是一个像素画呢,还是一副体素画呢?这是画的一只鸟呢,还是一架飞机呢?所有的这些疑问都会吧我们搞得非常狼狈。

我仍然无法决定是处于对怀旧游戏的热爱,(导致)我应该去看这部电影还是绝对看这部电影。

不用担心,你马上就会解脱了。当你读完这篇文章的时候,你就会知道关于像素、体素以及两者之间的一切。不过不用不着急,我们先吃块饼干。

(这是一篇很长的文章,但我保证:你的阅读体验就像是在翻阅《Retronator》杂志,因为图片要远远多于文字。)


我们先铺垫一点背景知识,这样就能了解全貌。在计算机中表示图形主要有两种方法:矢量图和光栅图(译者注:也称作位图,以下统称为光栅图)。

矢量图的数学精度(左)和光栅图的离散性(右)。

矢量图通过数学方程式描述图像,通常用直线,曲线和形状等形式表示。相反,光栅图通过一组颜色描述图像,这些颜色被连续地放置在一个个网格中。

在计算机图形学中的第二个区别是2D和3D空间上的表现。如果包含矢量图与光栅图在内,一共可以得到四个象限:

每个人都喜欢象限

矢量图

在2D矢量图中,直线或图形上的每个点都用一个包含两个分量(x和y)的矢量来描述。这就是为什么它是2D的(两个分量也即是二维)。

这就是二维向量描述二维向量图形中所有点的方式。

下面是一个被称为低多边形二维矢量图像的例子。

Uluru the Mighty Dreamer, Anh Tran, 2015

它完全由2D多边形(在本例中为三角形)构成。术语“低多边形”是指用于制作图像的多边形数量相对较少。这使得三角形很容易被观察到。

现在我们添加一个维度。在3D矢量图中,原理是相同的,但是每个矢量使用三个分量(x, y和z)。

让我们看一下3D低多边形的作品。

Racetrack iOS Game Concept, Timothy J. Reynolds, 2013

上面的艾尔斯岩(Ayers Rock)的2D图像与此处的3D赛道之间的最大区别是:可以从我们需要的任何位置查看赛道。

Racetrack iOS Game Concept, Timothy J. Reynolds, 2013

为了在屏幕(2D表面)上显示跑道,我们必须选择一个特定的视点并将3D几何体从该视点投影到2D。

从3D到2D的转换称为投影

这也是我们获取一张特定角度2D图像的方式。

但是,即使在2D中,我们也可以使用一个技巧来展示3D物体具有的体积特性——我们可以制作一个动画,围绕物体移动视点(或保持视点不变的同时旋转物体本身,如下所示)。

Wagon, Timothy J. Reynolds, 2013

耶,我们可以看到它确实是3D的,并不需要3D眼镜!

光栅图

刚刚只是一个热身。搞定矢量图后,我们看看光栅图如何处理2D和3D。

在2D光栅图中,图像被分成若干大小相等的行和列:

Turbo Esprit Sprite, Matej ‘Retro’ Jan, 2014

每个单元格称为像素(来自于图像中的一个元素)。除了包含在网格(x,y)内的2D坐标外,每个像素的主要属性是位于该坐标上的颜色。

我们已经看到了低多边形矢量作品如何使用众多的多边形。如果我们在光栅图中做同样的操作(使用大量像素),我们将获得像素风格作品。

Turbo Esprit Sprite, Matej ‘Retro’ Jan, 2014

对于2D像素作品,即使它们试图表示三维对象(Lotus Esprit或X-wing),它们也会直接绘制到2D像素网格上。你不能像上面的3D旅行车那样旋转这个图像。同样,文章开头的艾尔斯岩(Ayers Rock)图像也不能旋转。尽管它是由多边形构成的,但它们不是被放置在3D空间,而是直接放置在2D空间。


到目前为止,我们已经介绍了2D和3D矢量图以及2D光栅图。最后一步是3D光栅图。

前方就是激动人心的时刻!

在3D光栅图中,整个体块被均匀地划分成行和列,覆盖所有三个不同的方向(上下、左右、内外)。这将三维空间划分为一个个小立方体,也称为体素(体块元素或体积像素)。每个体素由3D坐标和颜色定义。

就像像素作品(这是一种精心布局的像素艺术)一样,现在我们有了体素作品,每个立方体的设计都经过了仔细的考虑。

星际大战场景,@Sir_Carma,2015年

这很像乐高积木,你不觉得吗?

需要指出的是,因为是在3D空间中,体素也可以从任何角度被观察。下面是对体素塔图因(译者注:塔图因是《星球大战》中天行者家族的故乡行星)另外一个视角的观察:

星球大战场景(另一个视角),@Sir_Carma,2015

我们甚至可以做成动画!以下是 Sir Carma的一个体素动画角色:

骑士奔跑,Sir_Carma,2015年

与2D像素角色进行比较:

Final Element中的精灵,Glauber Kotaki,2015年

你可以看到在体素作品中,动画是如何改变每个小立方体——体素的状态(颜色),而在像素作品中,颜色的变化是发生在小正方形也即像素上。

现在你知道像素和体素之间的区别了(其实还有更多……哈哈,抱歉)。

但是现在还不是停下来的时候。我之所以解释了矢量图/光栅图,2D/3D特性,是因为在现代显示器上,每种图形最终都最终显示为2D光栅图。

我们在像素作品杂志中讨论这一点原因是,我们可以利用这些类型变换,使用非像素风格的资源创建现代风格的像素作品。

你可能会说:“我可以用体素或3D模型制作像素作品?当然可以!巧妙的着色和渲染技术使我们能够创建独特的视觉风格,从而将像素艺术作品带入未来。

矢量图显示和投影

上面的图表并不完全正确。有一种方法可以直接显示2D矢量图作品,不过有一点需要注意。

当你有一个2D矢量图像时,它只能直接显示在矢量显示器上,如雅达利的街机游戏《Asteroids》所使用的显示器。

以下是它的实际效果(游戏邦注:这是一款在示波器上显示的类似于《asteroids》的游戏):

Space Rocks (game), Autopilot, via Wikimedia Commons [CC BY-SA 3.0]

我们也可以用这种方式显示3D矢量图像(通常称为3D模型)。

如前所述,3D模型首先需要投影到2D,从而生成可以在矢量显示器上显示的2D矢量图像。

VEC9, Andrew Reitano & Todd Bailey, 2013

我强烈建议看一看VEC9的预告片,也要看大量的80年代的恶搞电影:

光栅化

如今,您很难在博物馆外找到矢量监视器。取而代之的是,我们使用由像素组成的显示器!

RGB LCD, Luís Flávio Loureiro dos Santos, 图片来源:Wikimedia Commons [CC BY 3.0]

现代LCD显示器通过打开和关闭(或介于两者之间)红色,绿色和蓝色元素来创建不同的颜色。这和在CRT(阴极射线管)时代的原理很类似,只是他们使用了三种不同类型的荧光粉类型,当受到电子束撞击时发出的是红色,绿色或蓝色的光。

CRT phosphor dots, 图片来源:Wikimedia Commons [CC-SA]

那么,如果我们有一个矢量图像,并且只有一个光栅显示器来显示它,我们该怎么办呢?从2D矢量图像到2D光栅图,需要对图形进行渲染或光栅化。每个多边形(通常是三角形)被渲染成到一个像素网格中。

使用采样进行光栅化, 幻灯片来源:Making WebGL Dance, Steven Wittens, 2013

这种机制可以扩展到在光栅显示器上渲染3D模型。首先,将3D三角形投影到2D三角形中。然后二维矢量三角形被光栅化为像素。

星际火狐,任天堂 1993

三角测量

那么体素要如何处理呢?如今最常见的方法是将每个体素表示为3D矢量立方体。我们通过创建一个沿体素立方体边缘放置三角形的3D模型来做到这一点。

世界上最激动人心的3D模型, Matej ‘Retro’ Jan, 2016

就像之前一样,3D三角形被投射到2D图像空间中,并最终光栅化并显示为2D光栅图。

旋转立方体(技术演示), Matej ‘Retro’ Jan, 2016

这就是获得最常见体素艺术风格的方式,几乎只使用免费的建模工具MagicaVoxel就能搞定。

Rapunzel tower, Thibault Simar, 2016

untitled, Argo San, 2016

Pokemon Voxel, Playiku, 2016

Cat vs Voxel, Stefan Smiljkovic, 2016

Trench Run, Gabriel de Laubier, 2015

Voxair balloon, Gabriel de Laubier, 2015

Sky Chase, Sir Carma, 2015

Talaak village, Sir Carma, 2016

Latica Cliffs, Sir Carma, 2015

射线检测

不过没有必要采用多维数据集方法。每个体素都可以看作是三维空间中的一个点,是那个位置上的一个体块。你可以通过在2D位置放置一个(或几个)像素直接在2D空间中绘制每个体素。或者反过来——你在屏幕上取一个像素,然后查看场景中的哪个体素出现在那个位置。

这种相反的方法叫做射线检测。你从视点投射一束光线到场景中,看看你击中了哪个体素。事实上,你投射了很多光线来扫过整个视野。

Simple raycasting with fisheye correction, Kieff, 源自Wikimedia Commons [public domain]

该技术由《德军总部3D》首创,它的体素是所有的房间块,所以这只是另一种呈现体素即盒子的方法。这种方式效率很高,因为你只需要在屏幕上投射一条射线就可以得到一整列像素。这使得它本质上成为一个的2D过程,这也是为什么我们有时也称这种类型的3D图形为2.5D(第三个维度是假的,因为它只允许拉伸2D表面)。

德军总部 3D, id Software, 1992

我们通常不会认为《德军总部》是在绘制体素。我们必须让绘制单元足够小并且允许它们有不同的高度。这就是90年代开始使用的经典体素图形引擎的方式。

Comanche: Maximum Overkill, NovaLogic, 1992

体素最初只用于地形中。这么做的目的是简化操作,这样可以把所有的体积相关的信息就是存储在二维图像(也称为高度图)的高度信息中。

通过高度图(左)来标定体素的高度是多少(黑色表示低,白色表示高)

将体素信息限制在高度地图上意味着你不能拥有悬垂的悬崖。不过也不得不说,地形上有非常多类似的细节,这是我们之前在游戏中都没有见过的。

Delta Force, NovaLogic, 1998

Outcast, Appeal, 1999

体素的终结

射线检测并不是90年代中游戏渲染体素信息的唯一方式。也出现了其他解决方案,每一种都有自己的优势,如可破坏的地形或支持车辆和角色渲染。这可是最前沿的技术!但是具有讽刺意味的是,正是这种创造性的多样性成为该技术被终结的另外一个原因。

Vangers, K-D Lab, 1998

时间来到2000年,图形加速卡开始普及。这些图形加速卡是用于投影和光栅化3D多边形的专用硬件(今天我们将这些芯片称为图形处理单元或GPU)。图形加速卡绘制三角形的速度非常快,但是它们能做的也仅限于此。包括射线检测在内的自定义体素渲染算法都超出了它们的能力范围。

Hexplore, Doki Denki Studio, 1998

体素引擎仍然需要在CPU(中央处理单元)中实现,但是CPU也需要处理其他内容,如物理、游戏玩法和AI。在GPU上进行图形计算的目的是将渲染剥离到专用芯片上,使渲染更快,并释放CPU去做更复杂的模拟计算。而体素引擎无法跟上多边形图形的性能,最终体素的历史终于画上了句号……

直到大约十年后,一款游(译者注:《我的世界》)戏让体素的人气达到了一个全新的水平。这款游戏抛弃了旧的方法,为“体素即立方体”的方法铺设了道路。而到目前为止,这都可以用GPU进行有效地渲染,其余的都已经成为历史。

我的世界, Mojang, 2009–present

定义

让我们回顾一下我们学过的知识,并使用数学的方式回答最初的问题:什么是像素,什么是体素?

像素是将二维空间划分为离散的、均匀(大小相等)的区域时的最小单位。

每个像素都可以由两个分量的向量寻址,其中x和y都是整数。与矢量图的连续特性相比,像素空间是离散的,而矢量图的每个坐标都是一个实数(用浮点数表示)。

同样,体素是将三维空间划分为离散的、均匀的区域时最小的体积单位。

这就是答案。

这就完了?很显然,并没有。

有了这样的一般性的定义,像素和体素可以以许多不同的方式出现,我们可以创造性地将这些概念转化为不同的视觉外观。尤其是我们还可以组合了光栅图/矢量图、2D/3D形成四个象限。

纯2D图形

在过去,如果你想在屏幕上绘制一个2D精灵,你必须直接将存储精灵颜色的内存复制到存储屏幕上显示的颜色的内存中(这种复制也被称为bit blit或bit BLT,缩写为位块传输)。今天几乎没有人像这样渲染二维图形。PICO-8作为使用现代渲染技术的应用,表达了对过去的美好时光的敬意,在那时位块传输是唯一的途径。

PICO-8, Lexallofle Games (and respective authors of featured carts), 2014–present

3D图形中的纹理

如今,大多数图形引擎都是在最底层使用向量,因为GPU就是这样工作的。在这个系统中,使二维图像出现在屏幕上的主要方法是使用一种称为纹理映射texture mapping)的技术在多边形上绘制它们。

纹理是放置(或映射)到三维多边形上的2D光栅图。

这就是绝大多数3D视频游戏(以及一般的3D图像)是如何以最简单的方式创建的。

例如,这是一个当我们有一个高多边形3D模型(译者注:也即是高精度模型)并向其添加高分辨率纹理时的外观:

镜之边缘:催化剂 Keyart, Per Haagensen, 2016

因为我们使用平滑多边形着色和纹理映射,所以我们甚至不需要那么多三角形来创建好看的角色。以下是具有高分辨率纹理的低多边形3D模型:

Low Poly Peon, Mark Henriksen, 2015

当我们将高分辨率纹理换成低分辨率的版本时,我们会得到一种线条类似于像素风格的低多边形3D模型:

Drift Stage, 2014–present (work in progress)

最典型的例子当然是《我的世界》。虽然《我的世界》的中方块就是上文定义一节中的体素(它们是游戏中最小的,离散的体积单位),但它们有各种各样的类型,由带有像素风格纹理的低多边形模型表示。尽管它们是块状的,但其中许多都不是简单的立方体。

我的世界, Mojang, 2009–present

目前已经涵盖了所有的3D模型案例(不需要讨论高多边形模型和低分辨率纹理的组合,但如果我搞错了,请纠正我)。

镜像边缘(左上),马克思·佩恩(左下)和我的世界(右下)

2D图形中的纹理

轮到2D了,当我们将纹理映射应用于2D矩形时,我们得到了常规的现代2D游戏。在今天的硬件中,每个2D图像(通常被称为精灵)都被放置在由2个三角形组成的矩形上。这两个三角形(合起来也称为四边形)通过映射到它们上的精灵进行渲染,使图像出现在它们的位置上。

角色的图像部分(左)被纹理映射到动画四边形(右) Badminton, Matej ‘Retro’ Jan, 2006

在高分辨率图像中,这事也是非常直接……

Braid, Number None, 2008

Limbo, Playdead, 2010

但是对于低分辨率的像素风格纹理来说,情况就复杂了。这完全取决于我们渲染精灵的显示分辨率。

Braid (top-left), Path to the Sky (top-right), Kingdom (bottom-right)

我们已经看到我们可以将像素风格纹理应用到低多边形的3D模型上,但仍然以非常高的分辨率渲染它。想一下《我的世界》:它是低多边形模型,16×16像素的低分辨率纹理,可以放在以1920×1080的显示分辨率进行渲染的场景中。

我的世界, Mojang, 2009–present

同样的操作也适用于2D多边形。我们可以将像素风格图像放在2D四边形上,并将其渲染到高分辨率屏幕上,使得源图像中的每个像素覆盖显示器中的多个像素。

Hotline Miami, Dennaton Games, 2012

我们称之为大像素(big pixel)美术风格。每个精灵像素的渲染比显示器中的像素更大,所以它在图像中呈现为一个更大的正方形。

源精灵中的每个像素被渲染到3x3显示像素上, Moonman, Ben Porter, 2011–present (work in progress)

当精灵旋转或倾斜时,大像素风格会比较明显:

Path to the Sky, Johannes ‘Dek’ Märtterer, 2011–present (work in progress)

观察上图的树叶,并且和下面以低分辨率渲染的旋转精灵进行比较:

Kingdom, Noio & Licorice, 2015

你看到水车的像素是如何水平/垂直对齐的,而在《Path to the Sky》中,树叶、小鸟和桥的“大像素”是如何倾斜和转换的了吗?(译者注:注意观察倾斜时的树叶,会发现锯齿感明显要小)

《Kingdom》通过低分辨率渲染游戏,然后将渲染后的图像放大到显示分辨率来实现这一目标。而《Path to the Sky》、《Hotline Miami》和《Moonman》则将精灵直接渲染到高分辨率的屏幕上。

回到3D

《Kingdom》是一个2D游戏,但是它的制作方法也可以被延伸到3D中。

我们也可以使用像素风格材质的3D模型,但是将其渲染到低分辨率上,类似这样:

Pixel Art Academy tech demo, Matej ‘Retro’ Jan, 2016

你可以看到我们可以得到有正确3D阴影投影的着色。尽管看起来像是某种2D像素风格,但实际上却是使用了像素风格纹理且低分辨率渲染的3D场景:

Pixel Art Academy tech demo (scene view), Matej ‘Retro’ Jan, 2016

基于矢量的动画(使用绑定)也可以利用这一点。以下是它们在大像素风格下的样子:

Animation rig with reference, Matej ‘Retro’ Jan, 2016

但当它们以低分辨率渲染时,它们看起来更像像素风格,像素风格类似于上面的《Kingdom》。

Pixel Art Academy animation test, Matej ‘Retro’ Jan, 2016

这可远远不是那种可爱的手绘的逐帧动画,但是仍然保持某种美术风格,让人想起90年代的动画。

波斯王子, Jordan Mechner, 1989

3D特效

回到高分辨率的话题上,Odd Tales的《The Last Night》便是一款充分利用了3D元素的游戏。

The Last Night, Odd Tales, 2014–present (work in progress)

它们的美术资源本质上是2D的,但却被分层构建到一个3D世界中,并且使用了你所能想到的所有现代图形特效(如动态光照,全屏泛光,动态模糊,景深,电影运镜、反射等等)。

WIP scene from The Last Night, Tim Soret, 2016

通过这种方式,他们构建了一个非常3D的世界,你可以从不同的角度看这个世界。

3D scene construction in The Last Night, Tim Soret, 2016

另一个动态3D光照的例子是氛围超赞,但命运多舛的《Confederate Express》:

Confederate Express, Maksym Pashanin, 2013–2014 (unreleased)

虽然美术资源仍然是2D的,但《Confederate Express》也包含了来自多个方向的阴影贴图。这些都可以通过Sprite Lamp等工具进行处理,并从光源的任何位置生成精灵的平滑光照。

体素的像素风格

上述方法的问题在于,仅可以精确地对精灵进行着色,而它们所投射的阴影由于缺少必要的3D几何图形而不能正确生成。要想做到这一点,最终就必须有大量的信息。那我们就来谈谈体素吧!

这个方法的一个很好的例子就是最近发布的《Pathway》:

Pathway, Robotality, 2016 (work in progress)

图像看起来像是在使用像素风格的2D资源,但这些资源实际上都是有3D体积的。不像90年代的体素引擎试图尽可能地让效果起来很现代和真实,开发者Robotality不需要做额外的工作,他只需要将体素匹配到显示像素的大小。这样子很巧妙地创造了一个看起来像像素风格的假象,但是在幕后,他们包含完整的3D信息来创建正确的动态光照。

使用体素来创造像素风格并不是一种很新的方法。这方面的先驱是使用了被叫做trixels(3D像素)的FEZ。而trixels只是一组由16×16×16 triles(3d贴图)的组合。

Development screenshots from FEZ, Polytron, 2007

当FEZ场景在游戏中渲染时,通常会使用2D正交投影,这是它们实现传统像素风格的方式,但是也允许进行FEZ风格的旋转。

FEZ GDC ’09 trailer, Polytron Corporation, 2009

FEZ, Polytron Corporation, 2012

纯粹体素

最后,转了一圈后我们回到了体素,所以我们可以抛开像素风格不谈,只在3D空间中渲染纯粹的、离散的体素(使用没有任何纹理的立方体)。

Lexallofle的Voxatron正是这个领域的主角。

Voxatron, Lexallofle Games, 2010–present (work in progress)

您是否注意到Lexallofle的虚拟控制台中的主题?(译者注:这句不知道怎么翻译,原文为:Do you notice a theme in Lexallofle’s virtual consoles?)Pico-8具有纯2D的图形引擎,而对于3D体素,Voxatron也是如此。他们真是完美的伴侣。

Voxatron, Lexallofle Games, 2010–present (work in progress)

Voxatron是为数不多的(如果不是唯一的话)在3D离散空间内使用纯粹体素的示例之一。但是它有一个类似于2D下的大像素风格的兄弟,在许多游戏中都占有一席之地,尤其是在移动领域。

Crossy Road, Hipster Whale, 2014
Shooty Skies, Mighty Games, 2016

PAC-MAN 256, Hipster Whale, 2015

绕了一圈后让我们再次回到Sir Carma。在成为最知名的体素艺术家之一之后,他现在正在使用Unity将纯粹体素的美学推向更高的高度,并提供大量的视觉效果,就像是《Odd Tales》(译者注:参考上文)在像素风格中所做的那样。

The Way Back, Sir Carma, 2016 (work in progress)

The Way Back, Sir Carma, 2016 (work in progress)

体素风格的《塞尔达》/《Atic Atac》?(译者注:原文为:Voxel Zelda/Atic Atac anyone?)

The Way Back, Sir Carma, 2016 (work in progress)

到目前为止,我们涵盖了我能想到的所有2D / 3D /光栅图/矢量图/低分辨率/高分辨率的组合。我能确定我会遗漏某些细节,也确定在将来会出现更多有趣的方法,但是就目前而言,这已经足够了。

0%