日志级别
用于区分日志的重要程度,不同框架的级别定义略有差异,但核心级别一致。从高到低通常包括:ERROR、WARN、INFO、DEBUG、TRACE。
日志门面
定义日志操作的标准接口,不涉及具体实现,实现日志接口与实现的解耦。典型代表是 SLF4J。
日志实现
具体的日志输出方案,负责日志的格式化、输出目的地管理等。常见的有 Logback、Log4j2、JUL(Java Util Logging)。
日志桥接器
用于适配旧的日志框架到新的日志门面。例如log4j-over-slf4j可以将 Log4j 的日志输出到 SLF4J。
| 框架名称 | 特点 | 性能 | 推荐指数 |
|---|---|---|---|
| Logback | SLF4J 作者开发,原生支持 SLF4J,配置灵活,性能优秀 | 高 | ★★★★★ |
| Log4j2 | Log4j 的升级版,支持异步日志,性能极佳,功能丰富 | 极高 | ★★★★★ |
| JUL | JDK 内置,无需额外依赖,功能简单 | 中 | ★★★☆☆ |
| Log4j | 经典框架,但已停止维护,存在安全漏洞 | 中 | ★☆☆☆☆ |
选型建议:新项目优先选择SLF4J + Logback或SLF4J + Log4j2组合。其中 Logback 配置更简洁,适合中小型项目;Log4j2 异步性能更优,适合高并发场景。
SLF4J(Simple Logging Facade for Java)作为日志门面的事实标准,几乎所有主流 Java 框架都采用它作为日志输出接口。掌握 SLF4J 的正确用法,是写出规范日志的第一步。
SLF4J 采用门面模式(Facade Pattern),为各种日志实现框架提供统一的接口。其核心优势在于:
解耦
业务代码只依赖 SLF4J 接口,不依赖具体日志实现,方便后期切换日志框架。
简洁
接口设计简洁明了,学习成本低。
扩展性
支持各种日志实现框架,通过绑定不同的实现包即可切换。
SLF4J 的核心 API 非常简单,主要包括Logger接口和LoggerFactory类。
通过LoggerFactory.getLogger()方法获取 Logger 实例,推荐使用当前类的Class对象作为参数,便于日志分类。
importorg.slf4j.Logger; importorg.slf4j.LoggerFactory; public class OrderService{ // 正确:使用当前类的Class对象获取Logger private static final Logger logger = LoggerFactory.getLogger(OrderService.class); // 错误:不建议使用字符串作为名称,不利于日志分类 // private static final Logger badLogger = LoggerFactory.getLogger("OrderService"); }
阿里巴巴规约要求:Logger 对象必须是private static final修饰的,避免频繁创建 Logger 实例,同时保证线程安全。
SLF4J 定义了 5 个常用日志级别,每个级别对应一个输出方法,使用时需根据场景选择合适的级别。
public class LogLevelDemo{ private static final Logger logger =LoggerFactory.getLogger(LogLevelDemo.class); public void processOrder(Long orderId){ // TRACE:最详细的日志,通常用于开发调试,生产环境禁用 logger.trace("开始处理订单,进入processOrder方法,参数:orderId={}", orderId); try{ // DEBUG:详细的调试信息,用于开发和测试环境,生产环境可选择性开启 logger.debug("验证订单有效性,orderId={}", orderId); validateOrder(orderId); // INFO:关键业务流程节点,生产环境必须开启,记录重要操作 logger.info("订单验证通过,开始支付流程,orderId={}", orderId); payOrder(orderId); // WARN:不影响系统运行但需要关注的异常情况 if(isOrderTimeout(orderId)){ logger.warn("订单支付超时,将自动取消,orderId={}", orderId); cancelOrder(orderId); } }catch(OrderNotFoundException e){ // ERROR:影响业务流程的错误,必须记录完整堆栈信息 logger.error("处理订单失败,订单不存在,orderId={}", orderId, e); } } // 以下为示例方法,实际业务中需根据需求实现 private void validateOrder(Long orderId){} private void payOrder(Long orderId){} private boolean isOrderTimeout(Long orderId){returnfalse;} private void cancelOrder(Long orderId){} }
级别使用原则:
ERROR
影响用户操作的错误,如订单创建失败、支付异常等。
WARN
不影响当前操作但需要注意的情况,如参数不规范、资源即将耗尽等。
INFO
核心业务流程节点,如用户登录、订单提交成功等。
DEBUG
开发调试用的详细信息,如方法调用参数、返回值等。
TRACE
比 DEBUG 更详细的日志,如循环内部的变量变化等。
SLF4J 支持使用{}作为占位符,自动替换为参数值,相比字符串拼接有明显优势。
public class LogFormatDemo{ private static final Logger logger =LoggerFactory.getLogger(LogFormatDemo.class); public void userLogin(String username,String ip){ // 正确:使用占位符,性能更优,代码更简洁 logger.info("用户登录成功,用户名:{},IP地址:{}", username, ip); // 错误:字符串拼接在日志级别未启用时仍会执行拼接操作,浪费性能 // logger.info("用户登录成功,用户名:" + username + ",IP地址:" + ip); // 正确:多个参数时按顺序对应占位符 logger.debug("用户登录验证,尝试次数:{},耗时:{}ms",3,150); // 正确:支持任意类型参数,自动调用toString()方法 User user =newUser("zhangsan",25); logger.info("用户信息:{}", user); } static class User{ private String name; private int age; public User(String name,int age){ this.name = name; this.age = age; } @Override public String toString(){ return "User{name='"+ name +"', age="+ age +"}"; } } }
性能优势:当日志级别未启用时(例如在生产环境关闭 DEBUG 级别),占位符方式不会执行参数的字符串转换操作,而字符串拼接会始终执行,造成性能浪费。
异常日志是排查问题的关键,必须记录完整的堆栈信息,同时补充足够的上下文。
public class ExceptionLogDemo{ private static final Logger logger =LoggerFactory.getLogger(ExceptionLogDemo.class); public void transferMoney(Long fromUserId,Long toUserId,BigDecimal amount){ try{ // 业务逻辑... throw new InsufficientBalanceException("余额不足"); }catch(InsufficientBalanceException e){ // 正确:将异常对象作为最后一个参数传入,会自动打印堆栈信息 logger.error("转账失败,转出用户:{},转入用户:{},金额:{}",fromUserId, toUserId, amount, e); // 错误:只打印异常消息,丢失堆栈信息,无法定位问题位置 // logger.error("转账失败:" + e.getMessage()); // 错误:异常对象未作为参数传入,堆栈信息不会打印 // logger.error("转账失败,用户:{},原因:{}", fromUserId, e.getMessage()); }catch(Exception e){ // 正确:通用异常捕获,记录详细上下文 logger.error("转账发生未知错误,转出用户:{},转入用户:{},金额:{}",fromUserId, toUserId, amount, e); } } static class InsufficientBalanceException extends Exception{ public InsufficientBalanceException(String message){ super(message); } } }
异常日志原则:
永远不要只打印异常消息(e.getMessage()),必须打印完整堆栈。
异常对象必须作为最后一个参数传递给日志方法。
补充足够的上下文信息(如用户 ID、订单号等),方便问题定位。
SLF4J 本身不实现日志功能,需要绑定具体的日志实现框架。以SLF4J + Logback组合为例,讲解如何在 Maven 项目中配置依赖。
<!-- SLF4J API --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.9</version> </dependency> <!-- Logback核心依赖 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.8</version> </dependency> <!-- 可选:Logback访问日志模块 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-access</artifactId> <version>1.4.8</version> </dependency>
依赖冲突解决: 当项目中存在多个日志框架时,可能会出现依赖冲突。可通过mvn dependency:tree命令查看依赖树,使用exclusion排除冲突依赖。
<!-- 排除冲突的日志依赖 --> <dependency> <groupId>某第三方框架</groupId> <artifactId>第三方框架 artifactId</artifactId> <version>版本号</version> <exclusions> <exclusion> <groupId>log4j</groupId> <artifactId>log4j</artifactId> </exclusion> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </exclusion> </exclusions> </dependency>
Logback 作为 SLF4J 的原生实现,具有配置灵活、性能优秀、功能丰富等特点。掌握 Logback 的配置技巧,能让日志系统更贴合业务需求。
Logback 的配置文件通常命名为logback.xml或logback-spring.xml(Spring Boot 项目),放在src/main/resources目录下。其核心结构包括:
<configuration>
根元素,包含整个配置。
<appender>
定义日志输出目的地,如控制台、文件等。
<logger>
定义特定包或类的日志行为。
<root>
根 Logger,所有 Logger 的默认配置。
以下是一个基础的 Logback 配置,实现日志同时输出到控制台和文件,并按级别过滤。
<?xml version="1.0" encoding="UTF-8"?> <configurationscan="true"scanPeriod="30 seconds"debug="false"> <!-- 上下文名称,用于区分不同应用的日志 --> <contextName>java-log-demo</contextName> <!-- 定义变量,方便后续引用 --> <propertyname="LOG_HOME"value="./logs"/> <propertyname="FILE_NAME"value="app"/> <propertyname="ENCODING"value="UTF-8"/> <!-- 控制台输出Appender --> <appendername="CONSOLE"class="ch.qos.logback.core.ConsoleAppender"> <!-- 日志格式 --> <encoderclass="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> <!-- 过滤器:只输出INFO及以上级别日志 --> <filterclass="ch.qos.logback.classic.filter.ThresholdFilter"> <level>INFO</level> </filter> </appender> <!-- 普通文件输出Appender --> <appendername="FILE"class="ch.qos.logback.core.FileAppender"> <!-- 日志文件路径 --> <file>${LOG_HOME}/${FILE_NAME}.log</file> <!-- 日志格式 --> <encoderclass="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> <!-- 追加模式,true表示日志追加到文件末尾 --> <append>true</append> </appender> <!-- 根Logger配置 --> <rootlevel="INFO"> <appender-refref="CONSOLE"/> <appender-refref="FILE"/> </root> </configuration>
当日志文件不断增长时,需要通过滚动策略将大文件分割成多个小文件,方便管理和归档。
<!-- 按时间滚动的Appender(每天生成一个日志文件) --> <appendername="ROLLING_FILE_DAILY"class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 当前日志文件路径 --> <file>${LOG_HOME}/${FILE_NAME}_daily.log</file> <!-- 滚动策略:按时间滚动 --> <rollingPolicyclass="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 归档文件命名格式,%d{yyyy-MM-dd}表示每天一个文件 --> <fileNamePattern>${LOG_HOME}/${FILE_NAME}_%d{yyyy-MM-dd}.log</fileNamePattern> <!-- 日志文件保留天数 --> <maxHistory>30</maxHistory> <!-- 总日志大小限制,超过后删除旧文件 --> <totalSizeCap>10GB</totalSizeCap> </rollingPolicy> <!-- 日志格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> </appender>
<!-- 按大小和时间混合滚动的Appender --> <appender name="ROLLING_FILE_SIZE_AND_TIME" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${FILE_NAME}_size_time.log</file> <!-- 滚动策略:时间+大小混合 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档文件命名格式:每天一个目录,每个文件最大100MB --> <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${FILE_NAME}_%i.log</fileNamePattern> <!-- 每个文件的最大大小 --> <maxFileSize>100MB</maxFileSize> <!-- 日志文件保留天数 --> <maxHistory>30</maxHistory> <!-- 总日志大小限制 --> <totalSizeCap>20GB</totalSizeCap> </rollingPolicy> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> </appender>
日志格式的设计直接影响日志的可读性和实用性,一个好的日志格式应包含必要的上下文信息。
| 转换符 | 含义 | 示例 |
|---|---|---|
| %d | 日期时间 | %d{yyyy-MM-dd HH:mm.SSS} → 2023-10-01 15:30:22.123 |
| %thread | 线程名 | [http-nio-8080-exec-1] |
| %level | 日志级别 | INFO, ERROR |
| %logger | Logger 名称 | com.example.service.OrderService |
| %msg | 日志消息 | 用户登录成功 |
| %n | 换行符 | 平台无关的换行 |
| %C | 类名 | OrderService |
| %M | 方法名 | processOrder |
| %L | 行号 | 45 |
| %X{key} | MDC 中的键值 | %X{traceId} → a1b2c3d4 |
<!-- 开发环境日志格式:包含详细调试信息 --> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}(%C:%M:%L) - %msg%n</pattern> <!-- 生产环境日志格式:包含关键上下文,简洁高效 --> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{traceId} - %msg%n</pattern>
4.5 按包名 / 类名配置日志级别
实际开发中,可能需要为不同的包或类设置不同的日志级别。例如对第三方框架设置 WARN 级别,避免日志过多;对自己的业务包设置 DEBUG 级别,方便调试。
<!-- 对Spring框架设置WARN级别,减少日志输出 --> <logger name="org.springframework" level="WARN" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对MyBatis设置DEBUG级别,查看SQL执行情况 --> <logger name="org.apache.ibatis" level="DEBUG" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对业务包设置INFO级别,生产环境默认级别 --> <logger name="com.example.business" level="INFO" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对特定类设置DEBUG级别,方便调试 --> <logger name="com.example.business.service.OrderService" level="DEBUG" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 根Logger配置 --> <root level="INFO"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </root>
additivity 属性: 设置为false表示当前 Logger 的日志不会传递给父 Logger,避免日志重复输出。
同步日志在高并发场景下可能成为性能瓶颈,因为日志输出(尤其是文件 IO)是阻塞操作。Logback 的异步日志可以将日志输出操作放入单独的线程,不阻塞业务线程。
<!-- 异步日志Appender --> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <!-- 队列大小,默认256,高并发场景可适当增大 --> <queueSize>1024</queueSize> <!-- 当队列满时,是否阻塞生产者线程,false表示丢弃日志 --> <neverBlock>false</neverBlock> <!-- 引用实际的Appender --> <appender-refref="ROLLING_FILE_DAILY"/> </appender> <!-- 在Logger中引用异步Appender --> <rootlevel="INFO"> <appender-refref="CONSOLE"/> <appender-refref="ASYNC"/> </root>
异步日志注意事项:
控制台输出不建议使用异步日志,因为控制台 IO 本身性能较差。
异步日志的队列大小需根据业务并发量调整,过小可能导致日志丢失。
结合neverBlock=false和适当的队列大小,可以在保证性能的同时减少日志丢失风险。
多年的开发经验表明,80% 的日志问题都是由于不规范的使用习惯导致的。掌握这些最佳实践,能让你的日志系统更专业、更高效。
禁止使用 ERROR 级别记录正常业务异常
例如用户输入错误、订单不存在等预期内的异常,应使用 WARN 级别。ERROR 级别只用于记录影响系统运行的错误,如数据库连接失败、缓存服务宕机等。
// 正确:用户输入错误属于预期内异常,使用WARN级别 if(StringUtils.isEmpty(username)){ logger.warn("用户注册失败,用户名为空"); returnResult.fail("用户名不能为空"); } // 正确:系统错误使用ERROR级别 try{ dbConnection = dataSource.getConnection(); }catch(SQLException e){ logger.error("获取数据库连接失败", e); returnResult.error("系统繁忙,请稍后再试"); }
避免过度使用 DEBUG 级别
DEBUG 级别日志应只在开发和测试环境启用,生产环境默认关闭。在高频调用的方法中(如接口调用、数据转换),应减少 DEBUG 日志输出。
日志内容应包含关键上下文信息
一条有价值的日志应包含 “谁(用户 ID)在什么时间做了什么操作(功能模块)结果如何”。例如记录用户登录日志时,应包含用户名、IP 地址、登录时间、登录结果。
// 正确:包含完整上下文信息 logger.info("用户登录成功,用户名:{},IP地址:{},登录时间:{},耗时:{}ms",username, ip,newDate(), costTime); // 错误:缺少关键信息,无法定位具体用户 // logger.info("用户登录成功");
禁止在日志中包含敏感信息
用户密码、银行卡号、身份证号等敏感信息严禁记录到日志中。可以通过脱敏处理保留必要信息,同时保护用户隐私。
// 正确:密码进行脱敏处理 logger.info("用户登录尝试,用户名:{},密码:{}",username,maskPassword(password)); // 错误:日志中包含明文密码 // logger.info("用户登录尝试,用户名:{},密码:{}", username, password); // 密码脱敏方法示例 private String maskPassword(String password){ if(StringUtils.isEmpty(password)){ return ""; } return "******" + password.substring(Math.max(0, password.length()-2)); }
避免在日志中执行耗时操作
日志参数中的方法调用应避免包含耗时操作,因为即使日志级别未启用,这些方法也会被执行。
// 错误:日志参数中执行了耗时的JSON序列化操作 logger.debug("订单信息:{}",JSON.toJSONString(order)); // 正确:使用条件判断,只有当DEBUG级别启用时才执行耗时操作 if(logger.isDebugEnabled()){ logger.debug("订单信息:{}",JSON.toJSONString(order)); }
使用占位符而非字符串拼接
如前文所述,占位符方式在日志级别未启用时不会执行参数的字符串转换,性能更优。
异常日志应只记录一次
在异常传递过程中,应避免多次记录同一异常的日志。通常在异常最终处理处记录一次即可,中间传递过程中无需重复记录。
// Service层:只抛出异常,不记录日志 public Order getOrder throws OrderNotFoundException (Long orderId){ Order order = orderMapper.selectById(orderId); if(order ==null){ throw new OrderNotFoundException("订单不存在,orderId="+ orderId); } return order; } // Controller层:最终处理异常,记录日志 @GetMapping("/orders/{orderId}") public Result<Order> getOrder(@PathVariableLong orderId){ try{ Order order = orderService.getOrder(orderId); return Result.success(order); }catch(OrderNotFoundException e){ // 只在此处记录一次日志 logger.warn(e.getMessage()); returnResult.fail(e.getMessage()); } }
自定义异常应包含足够的上下文信息
自定义异常类应设计必要的字段,记录异常相关的上下文数据,方便问题排查。
// 正确:自定义异常包含关键信息字段 public class OrderException extends RuntimeException{ private Long orderId; private String userId; public OrderException (String message,Long orderId,String userId){ super(message); this.orderId = orderId; this.userId = userId; } // getter方法 public Long getOrderId(){ return orderId;} public String getUserId(){ return userId;} } // 使用自定义异常 logger.error("订单处理失败",newOrderException("库存不足", orderId, userId));
在微服务、分布式系统中,日志分散在多个服务实例中,传统的单机日志查看方式已无法满足需求。需要通过日志追踪和集中收集来解决。
MDC(Mapped Diagnostic Context)是 SLF4J 提供的映射诊断上下文,可在多线程环境中记录上下文信息(如 traceId、userId),并在日志中输出。
public class MdcDemo{ private static final Logger logger =LoggerFactory.getLogger(MdcDemo.class); // 生成全局唯一的traceId private String generateTraceId(){ return UUID.randomUUID().toString().replace("-",""); } public void processRequest(String userId){ // 将traceId和userId放入MDC MDC.put("traceId",generateTraceId()); MDC.put("userId", userId); try{ logger.info("开始处理请求"); validateUser(userId); doBusiness(); logger.info("请求处理完成"); }catch(Exception e){ logger.error("请求处理失败", e); }finally{ // 清除MDC中的数据,避免线程复用导致的信息污染 MDC.clear(); } } private void validateUser(String userId){ logger.debug("验证用户有效性"); // 业务逻辑... } private void doBusiness(){ logger.debug("执行核心业务逻辑"); // 业务逻辑... } }
在 Logback 配置中添加 MDC 字段的输出:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} traceId=%X{traceId} userId=%X{userId} - %msg%n</pattern>
输出日志效果:
2023-10-01 16:20:30.123 [http-nio-8080-exec-1] INFO com.example.MdcDemo traceId=a1b2c3d4e5f6 userId=zhangsan - 开始处理请求
分布式系统推荐使用ELK 栈(Elasticsearch + Logstash + Kibana)进行日志集中管理:
Elasticsearch
存储日志数据,提供全文检索能力。
Logstash
收集、过滤、转换日志数据。
Kibana
可视化日志数据,提供查询、分析界面。
集成步骤:
在应用中配置日志输出为 JSON 格式,方便 Elasticsearch 解析。
使用 Filebeat(轻量级日志收集器)收集服务器上的日志文件。
配置 Logstash 接收 Filebeat 的数据,进行过滤和转换。
将处理后的日志数据存入 Elasticsearch。
通过 Kibana 创建索引模式,查询和分析日志。
日志不仅是问题排查的工具,更能通过分析和监控提前发现系统潜在风险,做到防患于未然。
通过监控以下日志指标,可以及时发现系统异常:
ERROR 级别日志数量
突然增加可能预示系统出现故障。
接口响应时间日志
超过阈值的请求占比升高,可能存在性能问题。
第三方服务调用失败日志
如支付接口、短信接口失败率升高,需及时处理。
结合监控工具(如 Prometheus + Grafana),可以为关键日志指标配置告警:
当 ERROR 日志 5 分钟内超过 10 条时,发送短信告警。
当接口响应时间超过 1 秒的请求占比超过 5% 时,发送邮件告警。
用户行为分析
通过分析用户登录、下单、支付等日志,统计用户活跃度、转化率等指标。
性能瓶颈定位
通过分析方法调用耗时日志,找出系统中的性能瓶颈。
异常模式识别
通过分析历史异常日志,识别异常发生的规律和模式,提前优化。
Java 日志系统的构建是一个 “细节决定成败” 的过程,它看似简单,实则蕴含着丰富的技术细节和最佳实践。一个优秀的日志系统应该具备以下特点:
清晰的日志级别
根据业务场景选择合适的日志级别,避免级别滥用。
完整的上下文信息
日志内容应包含足够的上下文,方便问题定位。
合理的输出策略
结合同步 / 异步日志、滚动策略,平衡性能和可靠性。
规范的日志格式
统一日志格式,包含关键标识(如 traceId),便于集中分析。
完善的安全措施
避免敏感信息泄露,保护用户隐私和系统安全。
本文作者:Allen Tang
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!