当前位置:   article > 正文

面向猴子编程 全链路追踪技术_猴子程

猴子程

最近公司在搭建全链路追踪框架以便对既有接口进行整理、优化。
在这过程中针对全链路追踪技术学习了不少知识,在此归纳整理了一下。如果有不对之处还请各位大大指出~~喜欢的话可以随意转载,记得注明出处就行(o゜▽゜)o☆

全文分别从以下几点介绍全链路追踪技术:

  1. 全链路跟踪
  2. opentracing介绍
  3. 目前主流的两种链路框架zipkin,jaeger

全链路跟踪

为什么要使用全链路追踪

在这里插入图片描述
如图所示,在一个微服务架构的请求周期内,一个请求可能会链式调用多个服务。而对于每个服务而言,每个服务可能由一台服务器或者是一个服务器集群构成。

工作中我们可能会碰到以下问题

  • 需要知道单个请求的响应时间
  • 需要知道单个服务内每个IO耗时
  • 需要知道服务之间的调用关系
  • 需要知道一段时间内对于单个接口的请求数/异常数
  • 需要知道单个接口的健康程度
  • 需要知道当前处理请求的服务器的IP(服务集群下)

以上问题对于单体服务而言,通过日志或许就能基本解决(例如,通过对日志记录的时间进行分析,得出单个接口的响应时间)。但是对于微服务而言,由于服务个数的增加,以上的问题的困难程度也跟着大幅度增加。特别是对于统计服务间的调用关系,单个接口的请求数/异常数,单个接口的健康程度等问题,单单通过日志的功能进行判断,就显得越发困难。这时候如果还需要通过UI的形式,将相关的数据整理并展示出来,就变得更为困难。

所以,一种能够清晰明了的解决以上人们所关心的问题的技术就变得越发重要。而全链路追踪技术就是为此而创造的。

全链路追踪技术功能

因为要解决上述问题,所以全链路追踪技术必须包含以下功能

  • 计算吞吐量,根据拓扑可计算相应组件、平台、物理设备的实时吞吐量。
  • 计算健康度,通过请求数和异常数能够计算出单个接口的健康度。
  • 进行错误记录,根据服务返回统计单位时间异常次数
  • 记录响应时间,包括整体调用的响应时间和各个服务的响应时间等。
  • 记录服务之间的调用关系,单个请求中,所涉及到的所有服务,以及服务之间的调用关系

全链路追踪技术规范——Opentracing

自从 Google Dapper 的论文发布之后,各大互联网公司和开源社区都了解到了全链路追踪技术的优点,并积极响应,开发出各式各样的分布式链路追踪产品。虽然有大量的产品提供用户选择,但同时也给用户带来了一个问题,各个分布式链路追踪产品的 API 并不兼容,如果用户在各个产品之间进行切换,成本非常高。

因此,是否能有一种规范来规范所有的分布式链路追踪产品的 API ,使每个公司的分布式链路追踪产品能够相互兼容,减少用户的学习成本和使用成本?答案是肯定的,Opentracing应运而生。

Opentracing介绍

Opentracing的定义

开放式分布式追踪规范

Opentracing规范了什么

  • 后台无关的一套接口,被跟踪的服务只需要调用这套接口,就可以被任何实现这套接口的跟踪后台(比如Zipkin, Jaeger等等)支持,而作为一个跟踪后台,只要实现了个这套接口,就可以跟踪到任何调用这套接口的服务 (即,服务和跟踪后台通过这套接口可以直接进行数据交换)
  • 标准化了对跟踪最小单位Span的管理:定义了开始Span,结束Span和记录Span耗时的API。
  • 标准化了进程间跟踪数据传递的方式:定义了一套API方便跟踪数据的传递
  • 标准化了进程内当前Span的管理:定义了存储和获取当前Span的API,o

Opentracing 的数据模型

基本概念

  • Opentracing 的最小单位是span
  • 一个链路是一个tracer,一个tracer只有一个traceId
  • 每个链路tracer下有一至多个span
  • span与span之间有同级关系和父子关系,这两种关系
  • span与span之间通过SpanContext(上下文)进行传递数据,并利用其中的数据建立非闭环的父子关系

因果关系图

在这里插入图片描述

如图所示,单个trace中,span之间的关系呈树状结构。Span A是Span B和Span C的父Span,而Span B和Span C是同级Span,Span D是Span B的子Span。

时间关系图

在这里插入图片描述

如图所示,在整个Span的耗时中,包含了SpanB、SpanC、SpanD等Span。可以通过时间轴之间的包含关系来判断Span的父子关系和调用顺序。

Span对象的数据结构

  • Operation name:操作名称 (也可以称作Span name)。
  • Start timestamp:起始时间。
  • Finish timestamp:结束时间。
  • Span tag:一组键值对构成的Span标签集合。键值对中,键必须为String,值可以是字符串、布尔或者数字类型。
  • Span log:一组Span的日志集合。每次Log操作包含一个键值对和一个时间戳。键值对中,键必须为String,值可以是任意类型。
  • SpanContext: Span上下文对象。每个SpanContext包含以下状态:
    • 要实现任何一个OpenTracing,都需要依赖一个独特的Span去跨进程边界传输当前调用链的状态(例如:Trace和Span的ID)。
    • Baggage Items是Trace的随行数据,是一个键值对集合,存在于Trace中,也需要跨进程边界传输。
  • References(Span间关系):相关的零个或者多个Span(Span间通过SpanContext建立这种关系)。

上报机制概述(Zipkin)

在这里插入图片描述
如图所示,主机或者容器将span数据采用Json序列化后通过http接口上报到链路追踪(Collector)服务器;链路追踪服务器会校验Trace、建立索引、执行转换并最终进行存储。存储是一个可插入的组件,现在支持Cassandra和elasticsearch。最后,用户通过Query服务从存储中检索Trace并通过UI界面进行展现。

下面针对主机上报逻辑进行说明。

上报数据到Collector逻辑(Zipkin-PHP)

单个服务中,通过调用tracer->flush()方法将tracer内所有的span上报到链路追踪服务。所以,flush方法一般在响应发送之前。

/**
 * 结束
 * @param Opentracing_Span $span
*/
public function finish( Opentracing_Span $span )
{
    if( self::enable() )
    {
        // 单个span结束的方法
        $span->finish();
        
        // 如果是最上层的span,则将所有的span上报到链路追踪服务器
        if( $span->getKind() == Opentracing_Span::KIND_PARENT )
        {
            // getTracer方法获得的是一个全局的tracer,tracer包含了sampler、report等全局的功能
            Opentracing_Tracing::getInstance()->getTracer()->flush();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

再来看看具体的flush方法

/**
 * Calling this will flush any pending spans to the transport.
 *
 * Make sure this method is called after the request is finished.
 *
 * @see register_shutdown_function()
 * @see fastcgi_finish_request()
 */
public function flush(): void
{
    // 使用tracer的recorder将所有的span上报
    $this->recorder->flushAll();
}

/**
 * @return void
 */
public function flushAll(): void
{
    // 其中$this->spanMap->removeAll()返回的是所有的span数据
    // 调用report将数据上报到链路追踪服务器
    $this->reporter->report($this->spanMap->removeAll());
}

interface Reporter
{
    /**
    * @param MutableSpan[] $spans
    * @return void
    */
    public function report(array $spans): void;
}
  • 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

再让我们看看tracer是如何实现Reporter接口的

/**
 * 构建trace
 */
private function _buildTracing()
{
    // 定义最终上报的服务器地址
    $endpoint = Endpoint::create(
    $this->_getServerName() ,
    $this->_getIp() ,
          null ,
          null
    );

    // 根据服务器地址生成一个reporter(此reporter就是上述Reporter接口的实现)
    $reporter = new Http(
        CurlFactory::create() ,
        [ 'endpoint_url' => $this->_getReportUrl() ]
    ); 
    $sampler = BinarySampler::createAsAlwaysSample();
    
    // 初始化一个tracing
    $this->_tracing = TracingBuilder::create()
          ->havingLocalEndpoint( $endpoint )
          ->havingSampler( $sampler )
          ->havingReporter( $reporter )
          ->build();
}
  • 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

tracer中reporter的report方法又是怎么样的?

/**
* @param ReadbackSpan[] $spans
* @return void
*/
public function report(array $spans): void
{
    if (\count($spans) === 0) {
    return;
    }

    // 将所有的span数据序列化
    $payload = $this->serializer->serialize($spans);
    if ($payload === false) {
        $this->logger->error(
            \sprintf('failed to encode spans with code %d', \json_last_error())
        );
        return;
    }
    
    // 根据传入的endpoint_url数据生成一个client
    // 其中client是一个闭包函数,根据传入的payload生成一个curl,并执行这个curl,将数据通过http的方式发送给上报服务器
    $client = $this->clientFactory->build($this->options);
    try {
        $client($payload);
    } catch (RuntimeException $e) {
        $this->logger->error(\sprintf('failed to report spans: %s', $e->getMessage()));
    }
}
  • 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

以上就是主机上报span数据到Collector的逻辑

Zipkin在ES中存储的数据结构

对于具体ES中存储的数据结构,感兴趣的话,可以参考这篇文章:https://shared-code.com/article/105

单个服务Opentracing使用范例(Jaeger-Go)

func main() {
    // 初始化一个tracer
    tracer := jaeger.InitJaeger()
    // 设置全局tracer
    opentracing.SetGlobalTracer(tracer)  
    
    // 开始一个Span,并提供其Span名
    span := tracer.StartSpan("span_root")
    // 将Span数据放入上下文中
    ctx := opentracing.ContextWithSpan(context.Background(), span)
    // 将向下文传给下一个span
    r1 := foo3("Hello foo3", ctx)
    r2 := foo4("Hello foo4", ctx)
    fmt.Println(r1, r2)
    
    // 结束当前span并将其上报到上报服务器
    span.Finish()
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

通过tracer.StartSpan和span.Finish,将需要采样的函数方法包含在两者之间,用以统计相应的耗时。

再看看foo3中的上下文ctx的作用(foo4与foo3的代码一致)

func foo3(req string, ctx context.Context) (reply string) {
    //1.根据传入的上下文ctx创建子span
    span, _ := opentracing.StartSpanFromContext(ctx, "span_foo3")
    defer func() {
    //4.接口调用完,在tag中设置request和reply
        span.SetTag("request", req)
        span.SetTag("reply", reply)
        span.LogFields(
            log.String("lalla", "hey"),
        )
        span.Finish()
    }()    
    
    println(req)
    //2.模拟处理耗时
    time.Sleep(time.Second / 2)
    //3.返回reply
    reply = "foo3Reply"
    return
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

这样就能生成下图所示的span时序图了

<--------------span_root------------->
<----span_foo3---->
                   <----span_foo4---->
  • 1
  • 2
  • 3

目前主流的两种链路框架Zipkin,Jaeger

因为两者都是基于opentracing规范进行开发的,所以在操作方面基本没有区别——在上文的演示代码可以看出,基本操作如下:

  1. 都是先生成一个全局的tracer
  2. 利用全局tracer生成一个父span
  3. 通过context传递span信息
  4. 利用contex里面的span信息生成子span
  5. 将需要统计的函数放入startSpan和spanFinish之间
  6. 利用tracer内的reportor将span数据上报到上报服务器中

当然,由于opentracing没有强制规范数据格式和上报机制,所以每种框架对这两点的实现还是有所区别的。

在上文我们已经将PHP-Zipkin的上报机制的源码讲解一遍了,总的来说就是——Zipkin会收集所有Span信息,并在程序结束之前通过flush将所有Span信息通过tracer的reporter一同上报到上报服务器。

让我们再来看看Jaeger的上报机制源码。

与Zipkin不同的Jaeger的上报机制

通看官方文档我们发现,Jaeger是没有flush这个方法对Span的数据进行统一上报的,那Jaeger又是如何对数据进行上报的呢??
// 官方文档中,服务端对Span的操作

http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    var serverSpan opentracing.Span
    appSpecificOperationName := ...
    wireContext, err := opentracing.GlobalTracer().Extract(
    opentracing.HTTPHeaders,
    opentracing.HTTPHeadersCarrier(req.Header))
    if err != nil {
    // Optionally record something about err here
    }
    
    // Create the span referring to the RPC client if available.
    // If wireContext == nil, a root span will be created.
    serverSpan = opentracing.StartSpan(
        appSpecificOperationName,
        ext.RPCServerOption(wireContext))
    
    // 最后结束只对serverSpan进行Finish操作
    defer serverSpan.Finish()

   ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
   ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

同样是查看官方文档,我们发现整个对Span的操作中,只有使用Finish这个方法对Span进行关闭。所以可以猜测,是不是在Finish内对Span进行上报了??接下来我们看看Finish的源码。

func (s *Span) Finish() {
    s.FinishWithOptions(opentracing.FinishOptions{})
}

// FinishWithOptions implements opentracing.Span API
func (s *Span) FinishWithOptions(options opentracing.FinishOptions) {
    
    ...
    
    // call reportSpan even for non-sampled traces, to return span to the pool
    // and update metrics counter
    s.tracer.reportSpan(s)
}

func (t *Tracer) reportSpan(sp *Span) {
    if !sp.isSamplingFinalized() {
        t.metrics.SpansFinishedDelayedSampling.Inc(1)
    } else if sp.context.IsSampled() {
        t.metrics.SpansFinishedSampled.Inc(1)
    } else {
        t.metrics.SpansFinishedNotSampled.Inc(1)
    }
    
    // Note: if the reporter is processing Span asynchronously then it needs to Retain() the span,
    // and then Release() it when no longer needed.
    // Otherwise, the span may be reused for another trace and its data may be overwritten.
    // 判断是否采样当前Span,如果是则上报
    if sp.context.IsSampled() {
        t.reporter.Report(sp)
    }
    
    sp.Release()
}
  • 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

如我们所想,Jaeger是通过Span的Finish方法进行上报的。
由于Jaeger中,必须通过Finish结束当前的Span,所以我们可以得出结论——Jaeger与Zipkin不同,Jaeger并不会收集所有的Span信息后进行统一上报,而是每次结束掉当前Span后,对单个Span进行上报。

这样做的好处在于,对于PHP-Zipkin而言,由于Span信息需要先保存到内存中后进行统一上报,如果一次请求响应过程中如果有许多Span就容易把内存撑爆;而对于Go-Jaeger而言,每次结束都对Span进行上报,就不会对内存有太大负担。此外,由于Go拥有goroutine这个概念,可以通过goroutine对Span进行异步上报,对整个接口的性能不会有什么影响。

服务之间传递span数据的方式

服务之间传递span数据有两种方式

  • 通过请求头进行传递
  • 通过gRPC上下文进行传递

Opentracing虽然规范了API接口,但是没有规范具体的传输数据的格式,所以在使用不同的链路跟踪框架时要注意每种框架其传递数据的格式。

传递数据的格式

Zipkin的数据格式

  • X-B3-TraceId
  • X-B3-ParentSpanId
  • X-B3-SpanId
  • X-B3-Sampled

Jaeger的数据格式

  • Uber-Trace-Id:“traceId:spanId:parentSpanId:sampled”

字段含义

  • traceId:当前调用链的traceId
  • parentSpanId:父spanId
  • spanId:spanId (下层服务会使用这个spanId作为父spanId)
  • Sampled:是否采样

服务间span数据传递示意图(请求头模式)在这里插入图片描述

如图所示,在请求头模式中,client将上下文数据通过Inject API将数据存入请求头中;Server收到请求头后,将数据通过Extract API转换回上下文数据。之后Server使用转换回的上下文生成具体的Span。

服务间的胶水代码

请求头模式(Opentracing-Go,相同链路框架下)

客户端
func makeSomeRequest(ctx context.Context) ... {
    if span := opentracing.SpanFromContext(ctx); span != nil {
        // 初始化http客户端
        httpClient := &http.Client{}
        httpReq, _ := http.NewRequest("GET", "http://myservice/", nil)
        
        // 将上下文使用Inject放入请求头内
        opentracing.GlobalTracer().Inject(
        span.Context(),
        opentracing.HTTPHeaders,
        opentracing.HTTPHeadersCarrier(httpReq.Header))
        
        // 发送这个请求并获取响应
        resp, err := httpClient.Do(httpReq)
        ...
    }
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
服务端
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    var serverSpan opentracing.Span
    appSpecificOperationName := ...
    // 从http请求头中获取上下文
    wireContext, err := opentracing.GlobalTracer().Extract(
        opentracing.HTTPHeaders,
        opentracing.HTTPHeadersCarrier(req.Header))
    if err != nil {
        // Optionally record something about err here
    }
    
    // 利用上下文创建serverSpan
    // 如果上下文中不包含Span数据,则将当前span作为root Span,也就是父Span
    serverSpan = opentracing.StartSpan(
        appSpecificOperationName,
        ext.RPCServerOption(wireContext))
    
    // 当所有操作完成后,结束这个span并上报
    defer serverSpan.Finish()

   ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
   ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

客户端和服务端都使用相同链路框架的时候,基本与单服务的链路追踪操作相同。唯一的区别在于,需要将上下文通过请求头(或者grpc)进行传递。服务端在接收到传来的数据后,将传来的spanId作为父spanId。

gRPC模式下通过上下文传递

客户端和服务端采用不同的语言,不同的框架搭建链路追踪系统的时候,Opentracing就显得尤为重要。以下范例就是使用两种不同的语言和框架进行搭建链路追踪系统。

客户端(PHP-Zipkin)

通过php的gRPC接口调用Go服务器

public static function getRoleUsers( $roleId = -1 , $page = 1, $pageSize = 1 )
{
    ...

    // 在于此处请求Go服务器
    $res = $client->getRoleUsers( $roleUsersRequest )->wait();
    
    ...

   return $replyData;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

看看getRoleUsers中具体做了什么

public function getRoleUsers( Permission\GetRoleUsersRequest $argument , $metadata = [] , $options = [] )
{
    // 将tracer数据存入metadata中
    $metadata = \Opentracing_Tracer::injectMetadata( $metadata );
    
    // 构造请求
    return $this->_simpleRequest(
        '/RoleService/GetRoleUsers' ,
        $argument ,
        [ '\Icy\Permission\GetRoleUsersResponse' , 'decode' ] ,
        $metadata ,
        $options
    );
}

/**
 * 向metadata注入参数
 * @param array $metadata
* @return array
 */
public static function injectMetadata( $metadata )
{
    if( self::enable() )
    {
        // 获取span的向下文
        $context = self::$rootSpan->getContext();
        // 将上下文中储存的数据转换格式后放入metadata中
        $meta = \Helper_Tracer_Jaeger::getInstance()
                 ->_setTraceId( $context->getTraceId() )
                 ->_setSpanId( $context->getSpanId() )
                 ->_setParentSpanId( $context->getParentId() )
                 ->_setIsSampled( $context->isSampled() )
                 ->_getMetaData();
        $metadata = array_merge( $metadata , $meta );
    }
    
    return $metadata;
}
  • 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

需要传给服务器的上下文已经准备好了,并且通过gRPC的方式将数据传给了服务器。

服务端(Go-Jaeger)
func NewHandlerWrapper(ot opentracing.Tracer) server.HandlerWrapper {
    return func(h server.HandlerFunc) server.HandlerFunc {
        return func(ctx context.Context, req server.Request, rsp interface{}) error {
            if ot == nil {
                ot = opentracing.GlobalTracer()
            }
            name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
            // 通过上下文中的数据生成具体的Span
            ctx, span, err := StartSpanFromContext(ctx, ot, name)
            if err != nil {
                return err
            }
            global.Ctx = ctx
            defer span.Finish()
            if err = h(ctx, req, rsp); err != nil {
                span.LogFields(opentracinglog.String("error", err.Error()))
                span.SetTag("error", true)
            }
            return err
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

PS:图中的StartSpanFromContext方法是go-micro的方法,具体可以去micro的源码

至此,在两个服务之间建立了联系。服务端成功的利用上下文生成了需要的子Span。

在这个过程中,有一个点需要注意一下——由于客户端和服务端使用的是两种框架,导致其上下文中所需要的数据格式不相同。由于客户端使用的是Zipkin框架,而服务端使用的是Jaeger框架,所以要将X-B3数据格式转换为Jaeger框架所能识别的Uber-Trace-Id。Uber-Trace-Id的数据格式如下:
traceId:spanId:parentSpanId:sampled
下图是在服务器中Debug获取到的客户端传来的链路数据(Uber-Trace-Id)
在这里插入图片描述
具体的公司实践效果就不在这里阐述了~~Jaeger和Zipkin的上报服务器在本地可以直接通过docker进行搭建,有兴趣的可以自己在本地试试!

以上です〜(<ゝω·)☆ kira~

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/黑客灵魂/article/detail/859158
推荐阅读
相关标签
  

闽ICP备14008679号