一文搞懂基于zipkin的分布式追踪系统原理与实现

栏目: 后端 · 发布时间: 5年前

内容简介:传统单机系统在使用过程中,如果某个请求响应过慢或是响应出错,开发人员可以清楚知道某个请求出了问题,查看日志可以定位到具体方法。但是在分布式系统中,倘若客户端一个请求到达服务器后,由多个服务协作完成。比如:服务A调用服务B,服务B又调用服务C和服务D,服务D又调用服务E,那么想要知道是哪个服务处理时间过长或是处理异常导致这个请求响应缓慢或中断的话,就需要开发人员一个服务接一个服务的去机器上查看日志,先定位到出问题的服务,再定位出问题的具体地方。试想一下,随着系统越来越壮大,服务越来越多,一个请求对应处理的服务

传统单机系统在使用过程中,如果某个请求响应过慢或是响应出错,开发人员可以清楚知道某个请求出了问题,查看日志可以定位到具体方法。但是在分布式系统中,倘若客户端一个请求到达服务器后,由多个服务协作完成。比如:服务A调用服务B,服务B又调用服务C和服务D,服务D又调用服务E,那么想要知道是哪个服务处理时间过长或是处理异常导致这个请求响应缓慢或中断的话,就需要开发人员一个服务接一个服务的去机器上查看日志,先定位到出问题的服务,再定位出问题的具体地方。试想一下,随着系统越来越壮大,服务越来越多,一个请求对应处理的服务调用链越来越长,这种排查方式何其艰难。为了解决这种问题,便诞生了各种分布式场景中追踪问题的解决方案,zipkin就是其中之一。

整体结构长啥样

一个独立的分布式追踪系统,客户端存在于应用中(即各服务中),应具备追踪信息生成、采集发送等功能,而服务端应该包含以下基本的三个功能:

  • 信息收集:用来收集各服务端采集的信息,并对这些信息进行梳理存储、建立索引。
  • 数据存储:存储追踪数据。
  • 查询服务:提供查询请求链路信息的接口。

zipkin 整体结构图如下:

一文搞懂基于zipkin的分布式追踪系统原理与实现

zipkin(服务端)包含四个组件,分别是collector、storage、search、web UI。

  • collector 就是信息收集器,作为一个守护进程,它会时刻等待客户端传递过来的追踪数据,对这些数据进行验证、存储以及创建查询需要的索引。
  • storage 是存储组件。zipkin 默认直接将数据存在内存中,此外支持使用Cassandra、ElasticSearch 和 Mysql。
  • search 是一个查询进程,它提供了简单的JSON API来供外部调用查询。
  • web UI 是zipkin的服务端展示平台,主要调用search提供的接口,用图表将链路信息清晰地展示给开发人员。

zipkin的客户端主要负责根据应用的调用情况生成追踪信息,并且将这些追踪信息发送至zipkin由收集器接收。各语言支持均不同,具体可以查看zipkin官网,java语言的支持就是brave。上面结构图中,有追踪器就是指集成了brave。

基本概念了解下

在使用zipkin之前,先了解一下Trace和Span这两个基本概念。一个请求到达应用后所调用的所有服务所有服务组成的调用链就像一个树结构(如下图),我们 追踪 这个调用 链路 得到的这个树结构可以称之为 Trace

一文搞懂基于zipkin的分布式追踪系统原理与实现
在一次Trace中,每个服务的 每一次调用 ,就是一个 基本工作单元 ,就像上图中的每一个树节点,称之为 span 。每一个span都有一个 id作为唯一标识 ,同样每一次Trace都会生成一个 traceId在span中作为追踪标识 ,另外再通过一个 parentId标明本次调用的发起者 (就是发起者的span-id)。当span有了上面三个标识后,就可以很清晰的将多个span进行梳理串联,最终归纳出一条完整的跟踪链路。此外,span还会有其他数据,比如:名称、节点上下文、时间戳以及K-V结构的tag信息等等(Zipkin v1核心注解如“cs”和“sr”已被Span.Kind取代,详情查看 zipkin-api

,本文会在入门的demo介绍完后对具体的Span数据模型进行说明)。

具体怎么追踪的

追踪器位于应用程序上,负责生成相关ID、记录span需要的信息,最后通过传输层传递给服务端的收集器。我们首先思考下面几个问题:

  • 每个span需要的基本信息何时生成?
  • 哪些信息需要随着服务调用传递给服务提供方?
  • 什么时候发送span至zipkin 服务端?
  • 以何种方式发送span?

一个 span 表示一次服务调用,那么追踪器必定是被服务调用发起的动作触发,生成基本信息,同时为了追踪服务提供方对其他服务的调用情况,便需要传递本次追踪链路的traceId和本次调用的span-id。服务提供方完成服务将结果响应给调用方时,需要根据调用发起时记录的时间戳与当前时间戳计算本次服务的持续时间进行记录,至此这次调用的追踪span完成,就可以发送给zipkin服务端了。但是需要注意的是,发送span给zipkin collector不得影响此次业务结果,其发送成功与否跟业务无关,因此这里需要采用异步的方式发送,防止追踪系统发送延迟与发送失败导致用户系统的延迟与中断。下图就表示了一次http请求调用的追踪流程(基于zipkin官网提供的流程图):

一文搞懂基于zipkin的分布式追踪系统原理与实现

可以看出服务A请求服务B时先被追踪器拦截,记录tag信息、时间戳,同时将追踪标识添加进http header中传递给服务B,在服务B响应后,记录持续时间,最终采取异步的方式发送给zipkin收集器。span从被追踪的服务传送到Zipkin收集器有三种主要的传送方式:http、Kafka以及Scribe(Facebook开源的日志收集系统)。

1分钟安装zipkin

上文对基于zipkin实现分布式追踪系统的原理做了全面的说明,这里简单介绍一下zipkin的安装方法,下载jar包,直接运行。简单粗暴,但要注意必须jdk1.8及以上。其余两种安装方式见官方介绍。

wget -O zipkin.jar 'https://search.maven.org/remote_content?g=io.zipkin.java&a=zipkin-server&v=LATEST&c=exec'
java -jar zipkin.jar
复制代码

启动成功后,打开浏览器访问zipkin的webUI,输入http://ip:9411/,显示页面如下图。具体使用后面介绍。

一文搞懂基于zipkin的分布式追踪系统原理与实现

写个Demo用起来(Spring Boot整合zipkin)

java版客户端 Brave的官方文档很少,都在github里。小白当时找的那叫个头疼啊,网上各路大神写的博客中的代码你扒下来换最新的依赖后都会显示那些类被标记为过时,不建议使用。

  • brave 源码地址: github.com/openzipkin/…
  • 官方demo地址: github.com/openzipkin/…
  • 友情提示:本节代码较多,注释还算详细,介绍文字偏少。 小白写的demo结构如下图,分别创建了service1、service2、service3三个boot应用,将brave整合部分单独作为一个module,这样可以嵌入服务中复用,避免重复编码。
    一文搞懂基于zipkin的分布式追踪系统原理与实现

maven 依赖(zipkin_client)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.ycg</groupId>
    <artifactId>zipkin_client</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>zipkin_client</name>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>6</source>
                    <target>6</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <packaging>jar</packaging>

    <properties>
        <java.version>1.8</java.version>
        <spring-boot.version>2.1.1.RELEASE</spring-boot.version>
        <brave.version>5.6.0</brave.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>io.zipkin.brave</groupId>
                <artifactId>brave-bom</artifactId>
                <version>${brave.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>

        <!-- zipkin客户端依赖 -->
        <dependency>
            <groupId>io.zipkin.brave</groupId>
            <artifactId>brave</artifactId>
        </dependency>
        <dependency>
            <groupId>io.zipkin.reporter2</groupId>
            <artifactId>zipkin-sender-okhttp3</artifactId>
        </dependency>

        <!-- 添加记录MVC的类、方法名到span的依赖 -->
        <dependency>
            <groupId>io.zipkin.brave</groupId>
            <artifactId>brave-instrumentation-spring-webmvc</artifactId>
        </dependency>
        <!-- 添加brave的httpclient依赖 -->
        <dependency>
            <groupId>io.zipkin.brave</groupId>
            <artifactId>brave-instrumentation-httpclient</artifactId>
        </dependency>
        <!-- 集成Brave上下文的log -->
        <dependency>
            <groupId>io.zipkin.brave</groupId>
            <artifactId>brave-context-slf4j</artifactId>
        </dependency>
    </dependencies>

</project>
复制代码

配置类编写(zipkin_client)

package com.ycg.zipkin_client;

import brave.CurrentSpanCustomizer;
import brave.SpanCustomizer;
import brave.Tracing;
import brave.context.slf4j.MDCScopeDecorator;
import brave.http.HttpTracing;
import brave.httpclient.TracingHttpClientBuilder;
import brave.propagation.B3Propagation;
import brave.propagation.ExtraFieldPropagation;
import brave.propagation.ThreadLocalCurrentTraceContext;
import brave.servlet.TracingFilter;
import brave.spring.webmvc.SpanCustomizingAsyncHandlerInterceptor;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import zipkin2.Span;
import zipkin2.reporter.AsyncReporter;
import zipkin2.reporter.Sender;
import zipkin2.reporter.okhttp3.OkHttpSender;

import javax.servlet.Filter;

/**
 * 针对mvc controller 和 restTemplate 的 zipkin客户端配置
 */
@Configuration
@Import(SpanCustomizingAsyncHandlerInterceptor.class)
public class ZipkinClientConfiguration implements WebMvcConfigurer {

    /**
     * 配置如何向 zipkin 发送 span
     */
    @Bean
    Sender sender() {
        // 注意这里更换为自己安装的zipkin所在的主机IP
        return OkHttpSender.create("http://10.150.27.36:9411/api/v2/spans");
    }

    /**
     * 配置如何把 span 缓冲到给 zipkin 的消息
     */
    @Bean
    AsyncReporter<Span> spanReporter() {
        return AsyncReporter.create(sender());
    }

    /**
     * 配置跟踪过程中的Trace信息
     */
    @Bean
    Tracing tracing(@Value("${spring.application.name}") String serviceName) {
        return Tracing.newBuilder()
                .localServiceName(serviceName)  // 设置节点名称
                .propagationFactory(ExtraFieldPropagation.newFactory(B3Propagation.FACTORY, "user-name"))
                .currentTraceContext(ThreadLocalCurrentTraceContext.newBuilder()
                        .addScopeDecorator(MDCScopeDecorator.create()) // puts trace IDs into logs
                        .build()
                )
                .spanReporter(spanReporter()).build();
    }

    /** 注入可定制的Span */
    @Bean
    SpanCustomizer spanCustomizer(Tracing tracing) {
        return CurrentSpanCustomizer.create(tracing);
    }

    /** 决定如何命名和标记span。 默认情况下,它们的名称与http方法相同 */
    @Bean
    HttpTracing httpTracing(Tracing tracing) {
        return HttpTracing.create(tracing);
    }

    /** 导入过滤器,该过滤器中会为http请求创建span */
    @Bean
    Filter tracingFilter(HttpTracing httpTracing) {
        return TracingFilter.create(httpTracing);
    }

    /**
     * 导入 zipkin 定制的 RestTemplateCustomizer
     */
    @Bean
    RestTemplateCustomizer useTracedHttpClient(HttpTracing httpTracing) {
        final CloseableHttpClient httpClient = TracingHttpClientBuilder.create(httpTracing).build();
        return new RestTemplateCustomizer() {
            @Override public void customize(RestTemplate restTemplate) {
                restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient));
            }
        };
    }

    @Autowired
    SpanCustomizingAsyncHandlerInterceptor webMvcTracingCustomizer;

    /** 使用应用程序定义的Web标记装饰服务器span */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(webMvcTracingCustomizer);
    }
}
复制代码

boot 服务模块

  1. maven依赖 :boot+web起步依赖,另引入上面封装的zipkin_client模块依赖。
<dependency>
    <groupId>com.ycg</groupId>
    <artifactId>zipkin_client</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
复制代码
  1. 启动类导入 zipkin_client模块 的配置类 ZipkinClientConfiguration
@SpringBootApplication
@Import(ZipkinClientConfiguration.class)
public class Service1Application {
    public static void main(String[] args) {
        SpringApplication.run(Service1Application.class, args);
    }
}
复制代码
  1. 编写Controller,service2和service3的代码类似。由于zipkin配置类那边向IOC容器注入zipkin定制的RestTemplateCustomizer,注意这里 使用注入的RestTemplateBuilder创建restTemplate
@EnableAutoConfiguration
@RestController
public class Service1Controller {

    private RestTemplate restTemplate;

    @Autowired Service1Controller(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder.build();
    }

    @GetMapping(value = "/service1")
    public String getService() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "service1 sleep 100ms ->" + restTemplate.getForObject("http://localhost:8882/service2",String.class);
    }
}
复制代码
  1. 设置三个boot服务的内置tomcat端口号分别为8881、8882、8883。

启动验证

到这里,就完成了一个springboot整合zipkin简单的demo,分别启动三个boot应用后,在浏览器访问http://localhost:8881/service1,浏览器显示如下图:

一文搞懂基于zipkin的分布式追踪系统原理与实现

打开zipkin-webUI,点击查询,便可以查到刚才请求的追踪链路,如下图。

一文搞懂基于zipkin的分布式追踪系统原理与实现

继续点击查到的链路信息,便可查看该条追踪链路的详细信息。这里采用缩进的形式展示了整条调用链路,并且再每个调用后表明了所花费时间。点击右上角json按钮,便能看到本次trace的json数据。

一文搞懂基于zipkin的分布式追踪系统原理与实现

span数据结构详解

json结构概览与各字段含义

一次追踪链路会包含很多个span,因此一个trace便是一个数组,其标准的json结构如下:

[
  {
    "traceId": "string",    // 追踪链路ID
    "name": "string",       // span名称,一般为方法名称
    "parentId": "string",   // 调用者ID
    "id": "string",         // spanID
    "kind": "CLIENT",       // 替代zipkin v1的注解中的四个核心状态,详细介绍见下文
    "timestamp": 0,         // 时间戳,调用时间
    "duration": 0,          // 持续时间-调用的服务所消耗的时间
    "debug": true,          
    "shared": true,
    "localEndpoint": {      // 本地网络节点上下文
      "serviceName": "string",
      "ipv4": "string",
      "ipv6": "string",
      "port": 0
    },
    "remoteEndpoint": {    // 远端网络节点上下文
      "serviceName": "string",
      "ipv4": "string",
      "ipv6": "string",
      "port": 0
    },
    "annotations": [      // value通常是缩写代码,对应的时间戳表示代码标记事件的时间
      {
        "timestamp": 0,
        "value": "string"
      }
    ],
    "tags": {        // span的上下文信息,比如:http.method、http.path
      "additionalProp1": "string",
      "additionalProp2": "string",
      "additionalProp3": "string"
    }
  }
]
复制代码

聊聊 annotation 和 kind 的前世姻缘

zipkin V1 之 AnnotationV1 时Annotation 用于记录一个事件,事件由value标识,事件发生时间则记录对应的时间戳。一些核心注解核心注解用于定义一个请求的开始和结束。主要是如下四种注解:

  • cs - Client Send,表示客户端发起请求.
  • sr - Server Receive,表示服务端收到请求。使用sr的时间减去cs的时间便可得到网络传输的时间。
  • ss - Server Send,表示服务端完成处理,并将结果发送给客户端。使用ss的时间减去sr的时间便是服务端处理请求的时间。
  • cr - Client Received,表示客户端获取到服务端返回信息。使用cr的时间减去cs的时间便是整个请求所消耗的时间。

zipkin V2 之 KindV2 使用Span.Kind替代了V1的几个表示请求开始与结束的核心注解。kind一共有四种状态,其为不同状态时,timestamp、duration、remoteEndpoint代表的意义均不相同。

  • CLIENT:

    timestamp是请求被发送的时刻,相当于v1中注解 cs。

    duration代表发送请求后,接收到服务端响应前的持续时间,也就是整个请求所消耗的时间。

    remoteEndpoint表示被调用方的网络节点信息。

  • SERVER:

    timestamp是服务端接到请求并准备开始处理它的时间,相当于v1中的sr。

    duration代表服务端接到请求后、发送响应前的持续时间,也就是服务端的净处理时间。

    remoteEndpoint表示调用方的网络节点信息。

  • PRODUCER:

    timestamp是消息被发送的时刻。

    duration代表发送方发送后,消息队列结束到消息前的延迟时间,比如批处理的场景。

    remoteEndpoint表示消息队列的网络节点信息。

  • CONSUMER:

    timestamp是消息被消息队列接收到的时刻。

    duration代表消息被消息队列接收到,被消费者消费前的持续时间,比如消息积压的场景。

    remoteEndpoint表示消费者节点信息,未知则表示service name。

V1 针对消息队列也有ms、mr等注解,这里就不再详细介绍了。小白觉得kind这种替换后,整个追踪链路更为清晰直观,或许这也是zipkin的考虑之一吧。

再看Demo中追踪链路的JSON数据

相信看到这里的小伙伴回头再看demo中链路的json数据,应该可以明白具体的意思了。小白这里再梳理一下。追踪链路的JSON数据如下(建议直接跳过数据看下面分析):

[
  {
    "traceId": "3857b4a56c99e9f8",
    "parentId": "7dd11a047eb02622",
    "id": "e5427222edb62a7c",
    "kind": "SERVER",
    "name": "get /service3",
    "timestamp": 1547458424863333,
    "duration": 409599,
    "localEndpoint": {
      "serviceName": "server3",
      "ipv4": "172.30.22.138"
    },
    "remoteEndpoint": {
      "ipv4": "127.0.0.1",
      "port": 52845
    },
    "tags": {
      "http.method": "GET",
      "http.path": "/service3",
      "mvc.controller.class": "Service3Controller",
      "mvc.controller.method": "getService"
    },
    "shared": true
  },
  {
    "traceId": "3857b4a56c99e9f8",
    "parentId": "7dd11a047eb02622",
    "id": "e5427222edb62a7c",
    "kind": "CLIENT",
    "name": "get",
    "timestamp": 1547458424756985,
    "duration": 520649,
    "localEndpoint": {
      "serviceName": "server2",
      "ipv4": "172.30.22.138"
    },
    "tags": {
      "http.method": "GET",
      "http.path": "/service3"
    }
  },
  {
    "traceId": "3857b4a56c99e9f8",
    "parentId": "3857b4a56c99e9f8",
    "id": "7dd11a047eb02622",
    "kind": "SERVER",
    "name": "get /service2",
    "timestamp": 1547458424446556,
    "duration": 880044,
    "localEndpoint": {
      "serviceName": "server2",
      "ipv4": "172.30.22.138"
    },
    "remoteEndpoint": {
      "ipv4": "127.0.0.1",
      "port": 52844
    },
    "tags": {
      "http.method": "GET",
      "http.path": "/service2",
      "mvc.controller.class": "Service2Controller",
      "mvc.controller.method": "getService"
    },
    "shared": true
  },
  {
    "traceId": "3857b4a56c99e9f8",
    "parentId": "3857b4a56c99e9f8",
    "id": "7dd11a047eb02622",
    "kind": "CLIENT",
    "name": "get",
    "timestamp": 1547458424271786,
    "duration": 1066836,
    "localEndpoint": {
      "serviceName": "server1",
      "ipv4": "172.30.22.138"
    },
    "tags": {
      "http.method": "GET",
      "http.path": "/service2"
    }
  },
  {
    "traceId": "3857b4a56c99e9f8",
    "id": "3857b4a56c99e9f8",
    "kind": "SERVER",
    "name": "get /service1",
    "timestamp": 1547458424017344,
    "duration": 1358590,
    "localEndpoint": {
      "serviceName": "server1",
      "ipv4": "172.30.22.138"
    },
    "remoteEndpoint": {
      "ipv6": "::1",
      "port": 52841
    },
    "tags": {
      "http.method": "GET",
      "http.path": "/service1",
      "mvc.controller.class": "Service1Controller",
      "mvc.controller.method": "getService"
    }
  }
]
复制代码

我们从下往上看,这才是请求最开始的地方。首先看最下面的span(3857b4a56c99e9f8)。请求( http://localhost:8881 )是由浏览器发出,那么当请求到达服务1时,作为服务端便会生成kind为SERVER的span,其中duration便是本次请求到后端后的净处理时间,localEndpoint是server1的节点信息,remoteEndpoint的调用方也就是浏览器的节点信息。

接着服务1需要调用服务2的服务,这时服务1是作为客户端发出请求的。因此会记录出从下往上第二个span(7dd11a047eb02622),一个客户端span,也就是kind=CLIENT。localEndpoint还是自己,同时tag里添加了发出的请求信息,duration表示发出/service2的请求后,到接收到server2的响应所消耗的时间。再往上span(7dd11a047eb02622),就是server2接收到server1的请求后记录的SERVER span。剩下的同理,小白就不多说了。

结束语

到这里小白就介绍完了基于zipkin实现分布式追踪系统的基本原理与实现,当然这只是一个入门,追踪信息是全量收集还是采样收集,设置什么样的采样频率,异步发送span使用http还是kafka,这些问题都是需要在生产环境中根据实际场景综合考量的。就本文而言,小白觉得只要你仔细阅读了,认真思考了,一定还是收获不少的,当然有深入研究的小伙伴除外。后续小白会深入Brave的源码了解具体的追踪实现,如有错误,也请多多拍砖多多交流。另,画图、码字、梳理知识不易, 如要转载,请注明出处


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

现代操作系统(第3版)

现代操作系统(第3版)

Andrew S. Tanenbaum / 陈向群、马洪兵 / 机械工业出版社 / 2009-7 / 75.00元

本书是操作系统领域的经典之作,与第2版相比,增加了关于Linux、Windows Vista和Symbian操作系统的详细介绍。书中集中讨论了操作系统的基本原理,包括进程、线程、存储管理、文件系统、输入/输出、死锁等,同时还包含了有关计算机安全、多媒体操作系统、掌上计算机操作系统、微内核、多核处理机上的虚拟机以及操作系统设计等方面的内容。此外,还在第2版的基础上对部分习题进行了增删,更有助于读者学......一起来看看 《现代操作系统(第3版)》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试