提交 a5784ab9 编写于 作者: 如梦技术's avatar 如梦技术 🐛

开源 mica-logging 组件。

上级 4a69327b
......@@ -4,7 +4,7 @@ plugins {
ext {
javaVersion = JavaVersion.VERSION_1_8
springBootVersion = "2.4.3"
springBootVersion = "2.4.2"
springCloudVersion = "2020.0.1"
micaAutoVersion = "2.0.3"
micaWeiXinVersion = "2.0.5"
......@@ -16,6 +16,7 @@ ext {
xxlJobVersion = "2.2.0"
lombokVersion = "1.18.16"
findbugsVersion = "3.0.2"
logstashVersion = "6.6"
}
configure(subprojects) {
......
......@@ -29,6 +29,7 @@ dependencyManagement {
dependency "net.dreamlu:mica-xss:${VERSION}"
dependency "net.dreamlu:mica-metrics:${VERSION}"
dependency "net.dreamlu:mica-caffeine:${VERSION}"
dependency "net.dreamlu:mica-logging:${VERSION}"
// commons
dependency "com.google.code.findbugs:jsr305:${findbugsVersion}"
dependency "io.swagger:swagger-annotations:${swaggerAnnotationsVersion}"
......@@ -39,5 +40,6 @@ dependencyManagement {
dependency "org.projectlombok:lombok:${lombokVersion}"
dependency "com.alibaba:druid:${druidVersion}"
dependency "com.alibaba:druid-spring-boot-starter:${druidVersion}"
dependency "net.logstash.logback:logstash-logback-encoder:${logstashVersion}"
}
}
# mica-logging(logback 的日志扩展)
## 功能
1. 默认日志配置。
2. logstash 日志收集。
3. 启动完成关闭控制台日志。
## 依赖引用
### maven
```xml
<dependency>
<groupId>net.dreamlu</groupId>
<artifactId>mica-logging</artifactId>
<version>${version}</version>
</dependency>
```
### gradle
```groovy
compile("net.dreamlu:mica-logging:${version}")
```
## 可选依赖
### maven
```xml
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>${version}</version>
</dependency>
```
### gradle
```groovy
compile("net.logstash.logback:logstash-logback-encoder:${version}")
```
## 配置
| 配置项 | 默认值 | 说明 |
| ----- | ------ | ------ |
| mica.logging.console.enabled | false | 是否开启控制台日志,默认关闭,项目启动完成后自动关闭控制台日志 |
| mica.logging.logstash.enabled | false | 是否开启 logstash 日志收集 |
| mica.logging.logstash.host | localhost | logstash host |
| mica.logging.logstash.port | 5000 | logstash port |
| mica.logging.logstash.queue-size | 512 | logstash 队列大小 |
| mica.logging.use-json-format | false | 使用 json 格式化 |
## 参考
- [jhipster](https://github.com/jhipster/jhipster)
\ No newline at end of file
dependencies {
api project(":mica-core")
api "org.springframework.boot:spring-boot-starter-logging"
implementation "org.springframework.boot:spring-boot"
implementation "net.logstash.logback:logstash-logback-encoder:${logstashVersion}"
compileOnly "org.springframework.cloud:spring-cloud-context"
compileOnly "net.dreamlu:mica-auto:${micaAutoVersion}"
annotationProcessor "net.dreamlu:mica-auto:${micaAutoVersion}"
}
/*
* Copyright (c) 2019-2029, Dreamlu 卢春梦 (596392912@qq.com & www.dreamlu.net).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.dreamlu.mica.logging.config;
import net.dreamlu.mica.auto.annotation.AutoContextInitializer;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
/**
* logging 日志初始化
*
* @author L.cm
*/
@AutoContextInitializer
public class LoggingInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {
@Override
public void initialize(ConfigurableApplicationContext context) {
ConfigurableEnvironment environment = context.getEnvironment();
// 读取系统配置的日志目录,默认为项目下 logs
String logBase = environment.getProperty("logging.file.path", "logs");
// 用于 spring boot admin 中展示日志
System.setProperty("logging.file.name", logBase + "/${spring.application.name}/all.log");
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
/*
* Copyright (c) 2019-2029, Dreamlu 卢春梦 (596392912@qq.com & www.dreamlu.net).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.dreamlu.mica.logging.config;
import lombok.RequiredArgsConstructor;
import net.dreamlu.mica.core.utils.ObjectUtil;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.scheduling.annotation.Async;
/**
* 项目启动事件通知
*
* @author L.cm
*/
@RequiredArgsConstructor
public class LoggingStartedEventListener {
private final MicaLoggingProperties properties;
@Async
@Order
@EventListener(WebServerInitializedEvent.class)
public void afterStart() {
// 1. 如果开启 elk,关闭文件日志打印
MicaLoggingProperties.Logstash logStash = properties.getLogstash();
if (ObjectUtil.isTrue(logStash.isEnabled())) {
LoggingUtil.detachAppender(LoggingUtil.FILE_APPENDER_NAME);
LoggingUtil.detachAppender(LoggingUtil.FILE_ERROR_APPENDER_NAME);
}
// 2. 关闭控制台
MicaLoggingProperties.Console console = properties.getConsole();
if (ObjectUtil.isFalse(console.isEnabled())) {
LoggingUtil.detachAppender(LoggingUtil.CONSOLE_APPENDER_NAME);
}
}
}
/*
* Copyright 2016-2020 the original author or authors from the JHipster project.
*
* This file is part of the JHipster project, see https://www.jhipster.tech/
* for more information.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.dreamlu.mica.logging.config;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.LoggerContextListener;
import ch.qos.logback.core.ConsoleAppender;
import ch.qos.logback.core.spi.ContextAwareBase;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import net.logstash.logback.appender.LogstashTcpSocketAppender;
import net.logstash.logback.composite.ContextJsonProvider;
import net.logstash.logback.composite.GlobalCustomFieldsJsonProvider;
import net.logstash.logback.composite.loggingevent.*;
import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder;
import net.logstash.logback.encoder.LogstashEncoder;
import net.logstash.logback.stacktrace.ShortenedThrowableConverter;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
/**
* 参考自 jhipster
*
* Utility methods to add appenders to a {@link LoggerContext}.
*/
@Slf4j
@UtilityClass
public class LoggingUtil {
public static final String CONSOLE_APPENDER_NAME = "CONSOLE";
public static final String FILE_APPENDER_NAME = "FILE";
public static final String FILE_ERROR_APPENDER_NAME = "FILE_ERROR";
private static final String ASYNC_LOG_STASH_APPENDER_NAME = "ASYNC_LOG_STASH";
/**
* detach appender
*
* @param name appender name
*/
public static void detachAppender(String name) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME).detachAppender(name);
}
/**
* <p>addJsonConsoleAppender.</p>
*
* @param context a {@link LoggerContext} object.
* @param customFields a {@link String} object.
*/
public static void addJsonConsoleAppender(LoggerContext context, String customFields) {
log.info("Initializing Console loggingProperties");
// More documentation is available at: https://github.com/logstash/logstash-logback-encoder
ConsoleAppender<ILoggingEvent> consoleAppender = new ConsoleAppender<>();
consoleAppender.setContext(context);
consoleAppender.setEncoder(compositeJsonEncoder(context, customFields));
consoleAppender.setName(CONSOLE_APPENDER_NAME);
consoleAppender.start();
context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME).detachAppender(CONSOLE_APPENDER_NAME);
context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME).addAppender(consoleAppender);
}
/**
* <p>addLogstashTcpSocketAppender.</p>
*
* @param context a {@link LoggerContext} object.
* @param customFields a {@link String} object.
* @param logstashProperties a {@link net.dreamlu.mica.logging.config.MicaLoggingProperties.Logstash} object.
*/
public static void addLogstashTcpSocketAppender(LoggerContext context, String customFields,
MicaLoggingProperties.Logstash logstashProperties) {
log.info("Initializing Logstash loggingProperties");
// More documentation is available at: https://github.com/logstash/logstash-logback-encoder
LogstashTcpSocketAppender logStashAppender = new LogstashTcpSocketAppender();
logStashAppender.addDestinations(new InetSocketAddress(logstashProperties.getHost(), logstashProperties.getPort()));
logStashAppender.setContext(context);
logStashAppender.setEncoder(logstashEncoder(customFields));
logStashAppender.setName(ASYNC_LOG_STASH_APPENDER_NAME);
logStashAppender.setQueueSize(logstashProperties.getQueueSize());
logStashAppender.start();
context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME).addAppender(logStashAppender);
}
/**
* <p>addContextListener.</p>
*
* @param context a {@link LoggerContext} object.
* @param customFields a {@link String} object.
* @param properties a {@link net.dreamlu.mica.logging.config.MicaLoggingProperties} object.
*/
public static void addContextListener(LoggerContext context, String customFields, MicaLoggingProperties properties) {
LogbackLoggerContextListener loggerContextListener = new LogbackLoggerContextListener(properties, customFields);
loggerContextListener.setContext(context);
context.addListener(loggerContextListener);
}
private static LoggingEventCompositeJsonEncoder compositeJsonEncoder(LoggerContext context, String customFields) {
final LoggingEventCompositeJsonEncoder compositeJsonEncoder = new LoggingEventCompositeJsonEncoder();
compositeJsonEncoder.setContext(context);
compositeJsonEncoder.setProviders(jsonProviders(context, customFields));
compositeJsonEncoder.start();
return compositeJsonEncoder;
}
private static LogstashEncoder logstashEncoder(String customFields) {
final LogstashEncoder logstashEncoder = new LogstashEncoder();
logstashEncoder.setThrowableConverter(throwableConverter());
logstashEncoder.setCustomFields(customFields);
return logstashEncoder;
}
private static LoggingEventJsonProviders jsonProviders(LoggerContext context, String customFields) {
final LoggingEventJsonProviders jsonProviders = new LoggingEventJsonProviders();
jsonProviders.addArguments(new ArgumentsJsonProvider());
jsonProviders.addContext(new ContextJsonProvider<>());
jsonProviders.addGlobalCustomFields(customFieldsJsonProvider(customFields));
jsonProviders.addLogLevel(new LogLevelJsonProvider());
jsonProviders.addLoggerName(loggerNameJsonProvider());
jsonProviders.addMdc(new MdcJsonProvider());
jsonProviders.addMessage(new MessageJsonProvider());
jsonProviders.addPattern(new LoggingEventPatternJsonProvider());
jsonProviders.addStackTrace(stackTraceJsonProvider());
jsonProviders.addThreadName(new ThreadNameJsonProvider());
jsonProviders.addTimestamp(timestampJsonProvider());
jsonProviders.setContext(context);
return jsonProviders;
}
private static GlobalCustomFieldsJsonProvider<ILoggingEvent> customFieldsJsonProvider(String customFields) {
final GlobalCustomFieldsJsonProvider<ILoggingEvent> customFieldsJsonProvider = new GlobalCustomFieldsJsonProvider<>();
customFieldsJsonProvider.setCustomFields(customFields);
return customFieldsJsonProvider;
}
private static LoggerNameJsonProvider loggerNameJsonProvider() {
final LoggerNameJsonProvider loggerNameJsonProvider = new LoggerNameJsonProvider();
loggerNameJsonProvider.setShortenedLoggerNameLength(20);
return loggerNameJsonProvider;
}
private static StackTraceJsonProvider stackTraceJsonProvider() {
StackTraceJsonProvider stackTraceJsonProvider = new StackTraceJsonProvider();
stackTraceJsonProvider.setThrowableConverter(throwableConverter());
return stackTraceJsonProvider;
}
private static ShortenedThrowableConverter throwableConverter() {
final ShortenedThrowableConverter throwableConverter = new ShortenedThrowableConverter();
throwableConverter.setRootCauseFirst(true);
return throwableConverter;
}
private static LoggingEventFormattedTimestampJsonProvider timestampJsonProvider() {
final LoggingEventFormattedTimestampJsonProvider timestampJsonProvider = new LoggingEventFormattedTimestampJsonProvider();
timestampJsonProvider.setTimeZone("UTC");
return timestampJsonProvider;
}
/**
* Logback configuration is achieved by configuration file and API.
* When configuration file change is detected, the configuration is reset.
* This listener ensures that the programmatic configuration is also re-applied after reset.
*/
private static class LogbackLoggerContextListener extends ContextAwareBase implements LoggerContextListener {
private final MicaLoggingProperties loggingProperties;
private final String customFields;
private LogbackLoggerContextListener(MicaLoggingProperties loggingProperties, String customFields) {
this.loggingProperties = loggingProperties;
this.customFields = customFields;
}
@Override
public boolean isResetResistant() {
return true;
}
@Override
public void onStart(LoggerContext context) {
if (this.loggingProperties.isUseJsonFormat()) {
addJsonConsoleAppender(context, customFields);
}
if (this.loggingProperties.getLogstash().isEnabled()) {
addLogstashTcpSocketAppender(context, customFields, loggingProperties.getLogstash());
}
}
@Override
public void onReset(LoggerContext context) {
if (this.loggingProperties.isUseJsonFormat()) {
addJsonConsoleAppender(context, customFields);
}
if (this.loggingProperties.getLogstash().isEnabled()) {
addLogstashTcpSocketAppender(context, customFields, loggingProperties.getLogstash());
}
}
@Override
public void onStop(LoggerContext context) {
// Nothing to do.
}
@Override
public void onLevelChange(ch.qos.logback.classic.Logger logger, Level level) {
// Nothing to do.
}
}
}
/*
* Copyright (c) 2019-2029, Dreamlu 卢春梦 (596392912@qq.com & www.dreamlu.net).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.dreamlu.mica.logging.config;
import ch.qos.logback.classic.LoggerContext;
import net.dreamlu.mica.core.constant.MicaConstant;
import net.dreamlu.mica.core.utils.JsonUtil;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import java.util.HashMap;
import java.util.Map;
/**
* logging 日志配置
*
* @author L.cm
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(MicaLoggingProperties.class)
public class MicaLoggingConfiguration {
@Autowired
public MicaLoggingConfiguration(Environment environment,
MicaLoggingProperties loggingProperties) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
String appName = environment.getRequiredProperty(MicaConstant.SPRING_APP_NAME_KEY);
String profile = environment.getRequiredProperty(MicaConstant.ACTIVE_PROFILES_PROPERTY);
Map<String, Object> map = new HashMap<>();
map.put("appName", appName);
map.put("profile", profile);
map.put("timestamp", "%date{\"yyyy-MM-dd'T'HH:mm:ss.SSSZ\"}");
String customFields = JsonUtil.toJson(map);
MicaLoggingProperties.Logstash logStashProperties = loggingProperties.getLogstash();
if (loggingProperties.isUseJsonFormat()) {
LoggingUtil.addJsonConsoleAppender(context, customFields);
}
if (logStashProperties.isEnabled()) {
LoggingUtil.addLogstashTcpSocketAppender(context, customFields, logStashProperties);
}
if (loggingProperties.isUseJsonFormat() || logStashProperties.isEnabled()) {
LoggingUtil.addContextListener(context, customFields, loggingProperties);
}
}
@Bean
public LoggingStartedEventListener loggingStartedEventListener(MicaLoggingProperties loggingProperties) {
return new LoggingStartedEventListener(loggingProperties);
}
}
/*
* Copyright (c) 2019-2029, Dreamlu 卢春梦 (596392912@qq.com & www.dreamlu.net).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.dreamlu.mica.logging.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
/**
* logging 配置
*
* @author L.cm
*/
@Getter
@Setter
@RefreshScope
@ConfigurationProperties("mica.logging")
public class MicaLoggingProperties {
/**
* 使用 json 格式化
*/
private boolean useJsonFormat = false;
private final Console console = new Console();
private final Logstash logstash = new Logstash();
@Getter
@Setter
public static class Console {
/**
* 是否开启控制台日志
*/
private boolean enabled = true;
}
@Getter
@Setter
public static class Logstash {
/**
* 是否开启 logstash 日志收集
*/
private boolean enabled = false;
/**
* logstash host
*/
private String host = "localhost";
/**
* logstash port
*/
private int port = 5000;
/**
* logstash 队列大小
*/
private int queueSize = 512;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<included>
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 过滤 error -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>Error</level>
</filter>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
</encoder>
<file>${LOG_ERROR_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<cleanHistoryOnStart>${LOG_FILE_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
<fileNamePattern>${LOG_ERROR_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
<maxFileSize>${LOG_FILE_MAX_SIZE:-10MB}</maxFileSize>
<maxHistory>${LOG_FILE_MAX_HISTORY:-7}</maxHistory>
<totalSizeCap>${LOG_FILE_TOTAL_SIZE_CAP:-0}</totalSizeCap>
</rollingPolicy>
</appender>
</included>
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<!-- 服务名 -->
<springProperty scope="context" name="appName" source="spring.application.name"/>
<springProperty scope="context" name="logDir" source="logging.file.path" defaultValue="logs"/>
<!-- 默认 jar 包同级目录 logs/appName/all.log, 外部可配置 LOG_PATH -->
<property name="LOG_FILE" value="${logDir:-logs}/${appName}/all.log"/>
<property name="LOG_ERROR_FILE" value="${logDir:-logs}/${appName}/error.log"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<include resource="org/springframework/boot/logging/logback/file-appender.xml" />
<include resource="file-error.xml" />
<root level="${logging.level.root}">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
<appender-ref ref="FILE_ERROR"/>
</root>
<!-- 减少部分debug日志 -->
<logger name="org.springframework.context" level="WARN"/>
<logger name="org.springframework.beans" level="WARN"/>
<logger name="springfox.bean.validators" level="ERROR"/>
<logger name="springfox.documentation" level="ERROR" />
<!-- 关闭 mybatis 默认的 sql 日志 -->
<logger name="log.mybatis" level="INFO"/>
<!-- 基础组件 -->
<logger name="RocketmqClient" level="WARN"/>
<logger name="com.alibaba.nacos" level="ERROR"/>
<!-- 请求日志打印,全部使用info,方便动态调整 -->
<logger name="net.dreamlu.mica.servlet.logger" level="INFO"/>
<logger name="net.dreamlu.mica.reactive.logger" level="INFO"/>
<logger name="net.dreamlu.mica.http.logger" level="INFO"/>
<springProfile name="dev | test">
<!-- mongo no sql -->
<logger name="org.springframework.data.mongodb.core" level="DEBUG"/>
<!-- mica日志 -->
<logger name="net.dreamlu.mica" level="INFO"/>
</springProfile>
<springProfile name="ontest">
<!-- mica日志 -->
<Logger name="net.dreamlu.mica" level="INFO"/>
</springProfile>
<springProfile name="prod">
<!-- mica日志 -->
<Logger name="net.dreamlu.mica" level="ERROR"/>
</springProfile>
<!-- https://logback.qos.ch/manual/configuration.html#shutdownHook and https://jira.qos.ch/browse/LOGBACK-1090 -->
<shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/>
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
<resetJUL>true</resetJUL>
</contextListener>
</configuration>
......@@ -13,3 +13,4 @@ include "mica-ip2region"
include "mica-xss"
include "mica-metrics"
include "mica-caffeine"
include "mica-logging"
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册