前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >日志那些事儿——Logback源码解析

日志那些事儿——Logback源码解析

作者头像
LNAmp
发布于 2018-09-05 07:44:00
发布于 2018-09-05 07:44:00
2.5K00
代码可运行
举报
运行总次数:0
代码可运行

前言

在上篇文章日志漫谈中谈到,日志在监控报警、查错分析等方面有着非常重要的应用。Logback作为目前最火的日志系统,本文就简单分析一下logback日志打印的过程。

logback背景与配置

Logback是一个开源的日志组件,是log4j的作者开发的用来替代log4j的。

logback由三个部分组成,logback-core, logback-classic, logback-access。其中logback-core是其他两个模块的基础。

logback一般不能单独使用需要和slf4j配合使用,slf4j定义日志接口,具体实现由logback提供。关于slf4j怎么和logback配合本文暂不赘述,本文主要讨论logback的日志打印过程。

要使用logback,除了引入logback的相关jar包之外,还应该在classpath下放置logback.xml(不是唯一的做法),logback.xml配置一般如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback Configuration. -->
<configuration debug="false">
   <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">      <target>System.out</target>
      <encoding>${loggingCharset}</encoding>
           <layout class="ch.qos.logback.classic.PatternLayout">
         <pattern><![CDATA[  %d %-4relative [%thread] %-5level %logger{35} - %msg%n ]]>
</pattern>
      </layout>
   </appender>
<appender name="FILE"   class="ch.qos.logback.core.rolling.RollingFileAppender">   <file>${loggingRoot}/some.log</file>
   <encoding>${loggingCharset}</encoding>
     <append>true</append> 
  <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">      <FileNamePattern>${loggingRoot}/some.%d{yyyy-MM-dd}.log      </FileNamePattern>
      <!-- keep 30 days' worth of history -->      <MaxHistory>30</MaxHistory>
   </rollingPolicy>
   <layout class="ch.qos.logback.classic.PatternLayout">
      <pattern>%d %-4relative [%thread] %-5level %logger{35} - %msg%n      </pattern>
   </layout>
</appender>
<logger name="logger1">
   <level value="INFO" />
   <appender-ref ref="FILE" />
</logger>
<root>
   <level value="${loggingLevel}" />
   <appender-ref ref="FILE" />
</root>
</configuration>

配置文件中主要包含了logger、root、appender、rollingPolicy、layout、pattern等元素,这几个元素就是整个logger体系中最最重要的几个组成部分。 其中: Logger: 日志记录器,把它关联到应用对应的context上后,主要用于存放日志对象,定义日志类型,级别。 Appender: 指定日志输出的目的地,目的地可以是控制台,文件,或者数据库等 Layout: 负责把事件转换成字符串,格式化日志信息的输出

logback日志打印流程

当我们配置了上述logback.xml以后,我们通常通过

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Logger logger1 = LoggerFactory.getLogger("logger1");
logger1.info("This is a test for logging,msg:{}","I love xiaojin");

的方式使用,现在我们来看看调用logger1.info(),到底发生了什么事情。下图是logger1.info()的简略流程图:

logback logger流程图.jpg

从图中可以看出,整个过程中起关键作用的几个类为:AppenderAttatchableImpl,OutputStreamAppender、Encoder、Layout、OutputStream。这几类和配置文件中相应元素一一对应。logback通过类的继承和组合层层封装方法,最后通过OutputStream写入到控制台(ConsoleAppender)或者是文件(FileAppender)中。

为了对Appender、encoder、Layout、OutputStream有个整体的认识,先给出一张logback中上述各类的类图。

logback loginfo相关类全景图.jpg

可以看出,Appender、encoder、Layout、OutputStream是其中的核心,上图列出了我们经常使用的一些Appender,例如ConsoleAppender、RollingFileAppender;文件的滚动策略:TimeBasedRollingPolicy;格式化日志输出的PatternLayout,以及相应的pattern“%d %-4relative [%thread] %-5level %logger{35} - %msg%n”。

相关代码分析

上面的两张图列出了日志打印的过程和相关类,下面将通过关键代码分析Logback是如何将上述类串起来的。

关键代码1
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public void callAppenders(ILoggingEvent event) { 
 int writes = 0;
  for (Logger l = this; l != null; l = l.parent) {
    writes += l.appendLoopOnAppenders(event);
    if (!l.additive) {
      break;
    }
  } 
 // No appenders in hierarchy
  if (writes == 0) {
    loggerContext.noAppenderDefinedWarning(this); 
 }}
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public int appendLoopOnAppenders(E e) {
  int size = 0;  r.lock();
  try {
    for (Appender<E> appender : appenderList) {
      appender.doAppend(e);
      size++;
    }
  } finally {
    r.unlock();
  }
  return size;
}

上述第一段代码位于Logger.java中,logger会通过parent组成一条链路,通过每个logger都可以拥有多个Appender,上述代码会沿着Logger链路依次调用logger.appendLoopOnAppenders方法,直到logger.additive属性为false。第二段代码位于AppenderAttachableImpl中,logger.appendLoopOnAppenders实际上就是调用了AppenderAttachableImpl.appendLoopOnAppenders,该代码遍历logger中的所有appender调用Appender.doAppend(e)方法。

关键代码2

因为我们目前主要使用的还是FileAppender,所以下面的代码都是以RollingFileAppender为例。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@Override
protected void subAppend(E event) {
  synchronized (triggeringPolicy) {
    if (triggeringPolicy.isTriggeringEvent(currentlyActiveFile, event)) {
      rollover();
    }
  }
  super.subAppend(event);}
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  protected void subAppend(E event) {
    if (!isStarted()) {
      return;
    }
    try {  
      if (event instanceof DeferredProcessingAware) {
        ((DeferredProcessingAware) event).prepareForDeferredProcessing();
      }
      synchronized (lock) {
        writeOut(event);
      }
    } catch (IOException ioe) {
      this.started = false;
      addStatus(new ErrorStatus("IO failure in appender", this, ioe));
    }
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
protected void writeOut(E event) throws IOException { 
 this.encoder.doEncode(event);
}

前面讲到logger会调用到Appender.doAppend(),经过层层封装最后会调用到Appender.subAppend()。第一段代码在RollingFileAppender中,可以看出先进行了一次triggeringPolicy的校验,然后调用了父类的subAppend();下面一个就是RollingFileAppender的父类OutputStreamAppender的subAppender(),而其又调用了writeOut方法,而writeOut调用了Encoder.doEncode方法。

关键代码3

上面提到了在RollingFileAppender.subAppender中会进行一次triggeringPolicy的校验。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
 public boolean isTriggeringEvent(File activeFile, final E event) {
    long time = getCurrentTime();
    if (time >= nextCheck) {
      Date dateOfElapsedPeriod = dateInCurrentPeriod;
      elapsedPeriodsFileName = tbrp.fileNamePatternWCS
          .convert(dateOfElapsedPeriod);
      setDateInCurrentPeriod(time);
      computeNextCheck();
      return true;
    } else {
      return false;
    }

调用的默认是DefaultTimeBasedFileNamingAndTriggeringPolicy#isTriggeringEvent方法,这个主要是干啥用呢。考虑到我们使用了RollingFileAppender,我们需要日志按天滚动,例如今天是0820,所以今天的日志名应该为some.log,而昨天打的日志将会重命名为some.log.2016-08-19,当然这个是根据配置文件中FileNamePattern元素决定的。而isTriggeringEvent方法就是用来判决当前需不需要对日志名进行rollover(重命名),我们能想到的方法就是去查看日志文件的lastModified的时间与当前时间比较,如果当前时间已经是第二天了,那么应该将some.log重命名为some.log.XXXXXX,XXXX应该是日志修改时间的格式化时间,同时新建一个some.log的文件,并且将日志写入到some.log。logback基本上就是这么做的,但是为了提高效率避免每次都检查,logback会计算出nextChecktime,下次需要check的时间,这个时间有可能是lastModified的第二天凌晨(初始化阶段),有可能是当前时间的第二天凌晨(触发了rollover的时候),这样logback只需要在超过nextChecktime时才需要去rollover。

关键代码3

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  public void doEncode(E event) throws IOException {
    String txt = layout.doLayout(event);
    outputStream.write(convertToBytes(txt));
    outputStream.flush();
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public String doLayout(ILoggingEvent event) {
    if (!isStarted()) {
      return CoreConstants.EMPTY_STRING;
    }
    return writeLoopOnConverters(event);
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
protected String writeLoopOnConverters(E event) {
    StringBuilder buf = new StringBuilder(128);
    Converter<E> c = head;
    while (c != null) {
      c.write(buf, event);
      c = c.getNext();
    }
    return buf.toString();
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public void start() {
    if(pattern == null || pattern.length() == 0) {
      addError("Empty or null pattern.");
      return;
    }
    try { 
      Parser<E> p = new Parser<E>(pattern);
      if (getContext() != null) {
        p.setContext(getContext());
      }
      Node t = p.parse();
      this.head = p.compile(t, getEffectiveConverterMap());
      if (postCompileProcessor != null) {
        postCompileProcessor.process(head);
      }
      setContextForConverters(head);
      ConverterUtil.startConverters(this.head);
      super.start();
    } catch (ScanException sce) {
      StatusManager sm = getContext().getStatusManager();
      sm.add(new ErrorStatus("Failed to parse pattern \"" + getPattern()
          + "\".", this, sce));
    }
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  static {

    defaultConverterMap.put("d", DateConverter.class.getName());
    defaultConverterMap.put("date", DateConverter.class.getName());

    defaultConverterMap.put("r", RelativeTimeConverter.class.getName());
    defaultConverterMap.put("relative", RelativeTimeConverter.class.getName());

    defaultConverterMap.put("level", LevelConverter.class.getName());
    defaultConverterMap.put("le", LevelConverter.class.getName());
    defaultConverterMap.put("p", LevelConverter.class.getName());

    defaultConverterMap.put("t", ThreadConverter.class.getName());
    defaultConverterMap.put("thread", ThreadConverter.class.getName());

    defaultConverterMap.put("lo", LoggerConverter.class.getName());
    defaultConverterMap.put("logger", LoggerConverter.class.getName());
    defaultConverterMap.put("c", LoggerConverter.class.getName());

    defaultConverterMap.put("m", MessageConverter.class.getName());
    defaultConverterMap.put("msg", MessageConverter.class.getName());
    defaultConverterMap.put("message", MessageConverter.class.getName());

    defaultConverterMap.put("C", ClassOfCallerConverter.class.getName());
    defaultConverterMap.put("class", ClassOfCallerConverter.class.getName());

    defaultConverterMap.put("M", MethodOfCallerConverter.class.getName());
    defaultConverterMap.put("method", MethodOfCallerConverter.class.getName());

    defaultConverterMap.put("L", LineOfCallerConverter.class.getName());
    defaultConverterMap.put("line", LineOfCallerConverter.class.getName());

    defaultConverterMap.put("F", FileOfCallerConverter.class.getName());
    defaultConverterMap.put("file", FileOfCallerConverter.class.getName());

    defaultConverterMap.put("X", MDCConverter.class.getName());
    defaultConverterMap.put("mdc", MDCConverter.class.getName());

    defaultConverterMap.put("ex", ThrowableProxyConverter.class.getName());
    defaultConverterMap.put("exception", ThrowableProxyConverter.class
        .getName());
    defaultConverterMap.put("throwable", ThrowableProxyConverter.class
        .getName());

    defaultConverterMap.put("xEx", ExtendedThrowableProxyConverter.class.getName());
    defaultConverterMap.put("xException", ExtendedThrowableProxyConverter.class
        .getName());
    defaultConverterMap.put("xThrowable", ExtendedThrowableProxyConverter.class
        .getName());

    defaultConverterMap.put("nopex", NopThrowableInformationConverter.class
        .getName());
    defaultConverterMap.put("nopexception",
        NopThrowableInformationConverter.class.getName());

    defaultConverterMap.put("cn", ContextNameAction.class.getName());
    defaultConverterMap.put("contextName", ContextNameConverter.class.getName());
    
    defaultConverterMap.put("caller", CallerDataConverter.class.getName());

    defaultConverterMap.put("marker", MarkerConverter.class.getName());

    defaultConverterMap.put("property", PropertyConverter.class.getName());

    
    defaultConverterMap.put("n", LineSeparatorConverter.class.getName());
  }

前面提到输出日志的最终任务会落到Enconder#doEncode身上,第一段代码位于LayoutWrappingEncoder.java中,doEncode会最终调用Layout.doLayout得到格式化的文本,然后使用outputStream输出。第二段代码位于PatternLayout.java中,doLayout会调用writeLoopOnConverters,而writeLoopOnConverters会将日志文件通过Converter链进行格式化。Converter链是怎么产生的呢?请看第三段代码,看来是通过配置的pattern得到的,大体上是通过Parser去解析我们配置的pattern("%d %-4relative [%thread] %-5level %logger{35} - %msg%n"),parser通过识别"%"来判决是关键词还是普通文本,例如[]就是普通文件,relative/msg/n等都是关键字。Node t = p.parse();将pattern转化成Node链,Node有多种例如KeywordNode。通过p.compile(t, getEffectiveConverterMap())得到Convert链,主要是将Node链转化成Convert链,PatternLayout中列出了所有的Convert与pattern中名字的对应关系(最后一段代码),例如d代表DateConverter,level 代码LevelConverter等。

其实我们的日志信息在经过Converter链格式化之前还经过了一次格式化。我们通常会使用logger.info("somemsg:{}",{}),使用"{}"作为占位符,logback会将其替换成后面出现的参数,这个过程是怎么发生的呢?

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  public LoggingEvent(String fqcn, Logger logger, Level level, String message,
      Throwable throwable, Object[] argArray) {
    this.fqnOfLoggerClass = fqcn;
    this.loggerName = logger.getName();
    this.loggerContext = logger.getLoggerContext();
    this.loggerContextVO = loggerContext.getLoggerContextRemoteView();
    this.level = level;

    this.message = message;

    FormattingTuple ft = MessageFormatter.arrayFormat(message, argArray);
    formattedMessage = ft.getMessage();

    if (throwable == null) {
      argumentArray = ft.getArgArray();
      throwable = ft.getThrowable();
    } else {
      this.argumentArray = argArray;
    }

    if (throwable != null) {
      this.throwableProxy = new ThrowableProxy(throwable);
      LoggerContext lc = logger.getLoggerContext();
      if (lc.isPackagingDataEnabled()) {
        this.throwableProxy.calculatePackagingData();
      }
    }

    timeStamp = System.currentTimeMillis();

    // ugly but under the circumstances acceptable
    LogbackMDCAdapter logbackMDCAdapter = (LogbackMDCAdapter) MDC
        .getMDCAdapter();
    mdcPropertyMap = logbackMDCAdapter.getPropertyMap();
  }
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    FormattingTuple ft = MessageFormatter.arrayFormat(message, argArray);
    formattedMessage = ft.getMessage();

IloggingEvent非常重要,是log信息的携带者,其实现类为LoggingEvent,在实例化LoggingEvent时,使用MessageFormatter和FormattingTuple将{}占位符进行了替换。

后记

已经深夜了,很困!感觉还有很多东西没有提到,思绪又很乱。没有提到Appender/Encoder/Layout的初始化过程,其实上述接口都实现了LifeCycle(非常像tomcat),显式调用start完成初始化过程。初始化过程中做了很多事情上面没有提到,没有讲到logback.xml的解析过程,也没有提到slf4j与logback怎么结合。列个todo在这,有时间再写!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2016.08.21 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
浅谈6种流行的API架构风格
API在现代软件开发中扮演着重要的角色,它们是不同应用程序之间的桥梁。编写业务API是日常开发工作中最常见的一部分,选择合适的API框架对项目的成功起到了至关重要的作用。本篇文章将浅谈一下当前6种流行的API架构风格的优点、缺点以及适用场景。
追逐时光者
2024/01/12
6390
浅谈6种流行的API架构风格
常见的API架构风格
在构建应用程序时,选择合适的API架构风格对于实现良好的性能和开发体验至关重要。以下是几种常见的API架构风格:
coderidea
2024/02/22
2630
常见的API架构风格
API协议设计的10种技术
在这个数字时代,我们的日常生活中充斥着各种应用程序和系统之间的交互。无论是社交媒体、在线购物还是智能家居设备,它们都需要通过API(应用程序接口)来实现数据的传输和通信。然而,这些看似简单的操作背后隐藏着复杂的协议。
半吊子全栈工匠
2024/01/29
6810
API协议设计的10种技术
四种主流的API风格介绍与对比
API(Application Programming Interface)是现代软件的构建块之一,它允许不同的应用程序之间进行通信和协作,进而使得开发者能够创建出更为动态、灵活且具有扩展性的软件。随着互联网技术的不断发展,各种API规范也随之涌现,其中最常见的API风格包括:RESTful API、GraphQL API、RPC API和SOAP API。
windealli
2023/10/24
1.9K0
四种主流的API风格介绍与对比
流行的几种API接口模式:RESTful、GraphQL、gRPC、WebSocket、Webhook
当思考使用哪种API接口时,你将会面临一个重要的决策。RESTful、GraphQL、gRPC、WebSocket和Webhook是当前流行的几种API接口模式。在本文中,我们将介绍这些接口的特点、用途和比较,帮助你选择最适合你应用程序需求的接口。
网络技术联盟站
2023/09/01
3.1K0
流行的几种API接口模式:RESTful、GraphQL、gRPC、WebSocket、Webhook
使用Vue3和Node.js开发管理端系统实践
开始之前推荐一篇实用的文章:探索微信小程序的奇妙世界:从入门到进阶原创 ,这篇文章从入门到进阶,全面剖析小程序开发流程与技巧,适合各层次开发者,助力快速掌握并提升技能,推荐大家前往阅读。
flyskyocean
2024/12/02
4640
从cURL到GraphQL:不同API类型概述
API(应用程序编程接口)是现代软件开发的支柱,能够使不同的应用程序进行通信、共享数据并无缝执行任务。了解各种API类型及其实际应用可以为开发人员提供宝贵的见解。本文将探讨不同的API类型、它们的重要性,并通过实际示例说明它们的应用。
用户11531559
2025/03/03
1570
标准化API设计流程!
架构样式定义了应用程序编程接口(API)的不同组件如何相互交互。因此,它们通过提供设计和构建API的标准方法,确保了效率、可靠性和与其他系统的轻松集成。
Tinywan
2024/05/20
4040
标准化API设计流程!
2024年Node.js精选:50款工具库集锦,项目开发轻松上手(三)
大家好,今天,继续我们的Node.js探索之旅,深入了解一系列强大的工具库,它们能够帮助我们在项目开发中提升效率、加固安全、优化性能,甚至更优雅地处理数据和逻辑。
前端达人
2024/03/14
7440
2024年Node.js精选:50款工具库集锦,项目开发轻松上手(三)
架构师该如何为应用选择合适的API
架构师的主要活动是做出正确的技术决策。选择合适的API是一项重要的技术决策。那么今天就看看API的选择问题。
yuanyi928
2020/06/17
1.8K0
4种主流的API架构风格对比
本文讨论了四种主要的 API 架构风格,比较它们的优缺点,并重点介绍每种情况下最适合的 API 架构风格。
深度学习与Python
2021/01/21
2.6K0
常见形式 Web API 的简单分类总结
请求--响应类的API的典型做法是,通过基于HTTP的Web服务器暴露一个/套接口。API定义一些端点,客户端发送数据的请求到这些端点,Web服务器处理这些请求,然后返回响应。响应的格式通常是JSON或XML。
solenovex
2018/10/15
3.3K0
使用Node.js的简单Websocket示例
本文翻译自Simple Websocket Example with Nodejs
ccf19881030
2020/07/16
6.6K0
Agent Toolkit大揭秘:Python实现智能体调用外部API的5种方案
嘿,各位技术探险家们!欢迎来到我们今天充满刺激与惊喜的技术探秘之旅。想象一下,你正在打造一个智能体,它就像你在数字世界中的得力助手,能够上天入地,无所不能。但是等等,它要如何获取那些神奇的能力呢?答案就是 —— 调用外部 API!这就好比给你的智能体配备了各种超级装备,让它在数据的宇宙中自由翱翔。今天,我们就用 Python 这个神奇的魔法棒,来探索实现智能体调用外部 API 的 5 种绝妙方案。在这趟旅程中,我们不仅会深入了解各种技术细节,还会看到有趣的案例和实用的代码,保证让你收获满满!
小白的大数据之旅
2025/03/24
5440
Agent Toolkit大揭秘:Python实现智能体调用外部API的5种方案
一篇文章构建你的 Node.js 知识体系
最近读《重学前端》,开篇就是让你拥有自己的知识体系图谱,后续学的东西补充到相应的模块,既可以加深对原有知识的理解,又可以强化记忆,很不错的学习方案。
五月君
2020/08/28
1.9K0
一篇文章构建你的 Node.js 知识体系
API接口安全问题浅析
随着互联网的快速发展,应用程序接口(API)成为了不同系统和服务之间进行数据交换和通信的重要方式,然而API接口的广泛使用也引发了一系列的安全问题,在当今数字化时代,API接口安全问题的重要性不容忽视,恶意攻击者利用漏洞和不当的API实施,可能导致数据泄露、身份验证问题以及系统的完整性和可用性受到威胁,本文将探讨API接口安全问题的重要性并介绍常见的安全威胁和挑战,还将探讨如何保护API接口免受这些威胁并介绍一些最佳实践和安全措施
Al1ex
2024/02/22
6490
API接口安全问题浅析
深入解析 RESTful API:从设计到实践的完整指南
在当今的互联网世界中,不同系统之间的数据交互和通信是构建现代应用的核心需求。无论是移动应用、Web 平台,还是微服务架构,RESTful API 都扮演着桥梁的角色。它以其简洁性、灵活性和可扩展性,成为开发者构建分布式系统的首选方案。本文将从基础概念到实际应用,一步步拆解 RESTful API 的设计与实现,助你掌握这一关键技术。
DevKevin
2025/02/16
6900
Node.js + Socket.io 实现一对一即时聊天
实现一对一即时聊天应用,重要的一点就是消息能够实时的传递,一种方案就是熟知的使用 Websocket 协议,本文中我们使用 Node.js 中的一个框架 Socket.io 来实现。
五月君
2020/07/22
2.8K0
使用React、Electron、Dva、Webpack、Node.js、Websocket快速构建跨平台应用
目前Electron在github上面的star量已经快要跟React-native一样多了 这里吐槽下,webpack感觉每周都在偷偷更新,很糟心啊,还有Angular更新到了8,Vue马上又要出正
Peter谭金杰
2019/08/02
3.2K0
使用React、Electron、Dva、Webpack、Node.js、Websocket快速构建跨平台应用
【译】如何在 Node.js 中创建安全的 GraphQL API
本文的目的是提供一份快速指南 -- 《如何快速在如何在 Node.js 中创建安全的 GraphQL API》。
腾讯IVWEB团队
2020/06/28
3K0
推荐阅读
相关推荐
浅谈6种流行的API架构风格
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档