wffger

wffger 查看完整档案

广州编辑广东外语外贸大学  |  计算机科学与技术 编辑CLPS  |  数据工程师 编辑 my.oschina.net/wffger 编辑
编辑

Oracle Database, Mysql, Android, KRONOS, OBIEE, Informatica PowerCenter, WEB, Powershell, Python, R, Docker, Jupyter, Zeppelin, PDI

个人动态

wffger 提出了问题 · 10月23日

为何Windows共享目录下一些子目录不显示?

主机A的一个目录dirA已设置为共享目录,主机B可以通过网络路径访问。但是dirA下的一个子目录dir1不显示在共享文件夹中。子目录dir1已经设置为共享目录。

关注 1 回答 0

wffger 提出了问题 · 9月29日

v2ray客户端failed to read http request

v2ray客户端遇到下面错误

v2ray.com/core/app/proxyman/inbound: connection ends > v2ray.com/core/proxy/http: failed to read http request > malformed HTTP request "x05x01x00"

关注 2 回答 1

wffger 提出了问题 · 7月17日

浏览器某些页面将“演”显示为“漠”

发现一些网页将“演”显示为“漠”,但是右键搜索又显示正常。
这是字符集还是字体的问题?
2020-07-17 17-18-21 的屏幕截图.png

关注 2 回答 1

wffger 提出了问题 · 7月17日

ssh远程登录时能不能切换shell?

问题描述

服务器是AIX系统,默认shell是ksh,无法使用TAB补全功能。我想在ssh远程登录时切换shell为bash。能不能实现?

问题出现的平台版本及自己尝试过哪些方法

AIX 7
使用ssh -t 选项可以切换bash,但是无法使用一些命令,如sqlplus。

ssh root@myhost -t bash

关注 3 回答 4

wffger 提出了问题 · 7月17日

荣耀MagicBook Pro有没有Fedora指纹识别驱动?

问题描述

官方只提供Windows的驱动,请问有没有小伙伴找到Fedora平台下的电源键指纹识别驱动?

平台

Fedora 32

关注 1 回答 0

wffger 回答了问题 · 2月23日

Linux下如何匹配硬件和驱动?

启动消息可以使用dmesg获取

dmesg | grep error
sudo modprobe -r i2c_hid && sudo modprobe i2c_hid

I2C(Inter-Integrated Circuit)总线是一种由PHILIPS公司开发的两线式串行总线,用于连接微控制器及其外围设备。
HID(Human Interface Devices)人机交互设备。

关注 1 回答 1

wffger 提出了问题 · 2月5日

Linux下如何匹配硬件和驱动?

系统信息

OS: Fedora 31 (Workstation Edition) x86_64
Kernel: 5.4.13-201.fc31.x86_64
DE: GNOME 3.34.3

问题描述

有时启动后无法使用触摸板

跟踪信息

我对比正常与非正常时的硬件差异:
正常时能检测到触摸板硬件

[ydx@ydx-mf drivers]$ sudo libinput list-devices | grep -i touchpad
Device:           ELAN2204:00 04F3:30F5 Touchpad

查看鼠标驱动

[ydx@ydx-mf drivers]$ ls $(find /lib/modules/$(uname -r) -type d -name mouse)
appletouch.ko.xz  cyapatp.ko.xz   sermouse.ko.xz       synaptics_usb.ko.xz
bcm5974.ko.xz     elan_i2c.ko.xz  synaptics_i2c.ko.xz  vsxxxaa.ko.xz

已加载模块

[ydx@ydx-mf drivers]$ lsmod | grep elan_i2c #无结果
[ydx@ydx-mf drivers]$ lsmod | grep touch
hid_multitouch         32768  0

推测触摸板使用的驱动是elan_i2c。
为什么lsmod却搜索不到elan_i2c?
i2chid的联系是什么?

关注 1 回答 1

wffger 赞了文章 · 1月6日

惊讶!我定的日志规范被CTO在全公司推广了

打印日志是一门艺术,但长期被开发同学所忽视。日志就像车辆保险,没人愿意为保险付钱,但是一旦出了问题都又想有保险可用。我们打印日志的时候都很随意,可是用的时候会吐槽各种 SB 包括自己!写好每一条日志吧,与君共勉!

日志

日志是什么?

日志,维基百科的定义是记录服务器等电脑设备或软件的运作。

日志文件提供精确的系统记录,根据日志最终定位到错误详情和根源。日志的特点是,它描述一些离散的(不连续的)事件。 例如:应用通过一个滚动的文件输出 INFO 或 ERROR 信息,并通过日志收集系统,存储到一些存储引擎(Elasticsearch)中方便查询。

日志有什么用?

  • 在上文中我们解释了日志的作用是提供精准的系统记录方便根因分析。那么具体在哪些具体方面它可以发挥作用?
  • 打印调试:即可以用日志来记录变量或者某一段逻辑。记录程序运行的流程,即程序运行了哪些代码,方便排查逻辑问题。
  • 问题定位:程序出异常或者出故障时快速的定位问题,方便后期解决问题。因为线上生产环境无法 debug,在测试环境去模拟一套生产环境,费时费力。所以依靠日志记录的信息定位问题,这点非常重要。还可以记录流量,后期可以通过 ELK(包括 EFK 进行流量统计)。
  • 用户行为日志:记录用户的操作行为,用于大数据分析,比如监控、风控、推荐等等。这种日志,一般是给其他团队分析使用,而且可能是多个团队,因此一般会有一定的格式要求,开发者应该按照这个格式来记录,便于其他团队的使用。当然,要记录哪些行为、操作,一般也是约定好的,因此,开发者主要是执行的角色。
  • 根因分析(甩锅必备:即在关键地方记录日志。方便在和各个终端定位问题时,别人说时你的程序问题,你可以理直气壮的拿出你的日志说,看,我这里运行了,状态也是对的。这样,对方就会乖乖去定位他的代码,而不是互相推脱。

什么时候记录日志?

上文说了日志的重要性,那么什么时候需要记录日志。

  • 系统初始化:系统或者服务的启动参数。核心模块或者组件初始化过程中往往依赖一些关键配置,根据参数不同会提供不一样的服务。务必在这里记录 INFO 日志,打印出参数以及启动完成态服务表述。
  • 编程语言提示异常:如今各类主流的编程语言都包括异常机制,业务相关的流行框架有完整的异常模块。这类捕获的异常是系统告知开发人员需要加以关注的,是质量非常高的报错。应当适当记录日志,根据实际结合业务的情况使用 WARN 或者 ERROR 级别。
  • 业务流程预期不符:除开平台以及编程语言异常之外,项目代码中结果与期望不符时也是日志场景之一,简单来说所有流程分支都可以加入考虑。取决于开发人员判断能否容忍情形发生。常见的合适场景包括外部参数不正确,数据处理问题导致返回码不在合理范围内等等。
  • 系统核心角色,组件关键动作:系统中核心角色触发的业务动作是需要多加关注的,是衡量系统正常运行的重要指标,建议记录 INFO 级别日志,比如电商系统用户从登录到下单的整个流程;微服务各服务节点交互;核心数据表增删改;核心组件运行等等,如果日志频度高或者打印量特别大,可以提炼关键点 INFO 记录,其余酌情考虑 DEBUG 级别。
  • 第三方服务远程调用:微服务架构体系中有一个重要的点就是第三方永远不可信,对于第三方服务远程调用建议打印请求和响应的参数,方便在和各个终端定位问题,不会因为第三方服务日志的缺失变得手足无措。

日志打印

Slf4j & Logback

Slf4j 英文全称为 “ Simple Logging Facade for Java ”,为 Java 提供的简单日志门面。Facade 门面,更底层一点说就是接口。它允许用户以自己的喜好,在工程中通过 Slf4j 接入不同的日志系统。

Logback 是 Slf4j 的原生实现框架,同样也是出自 Log4j 一个人之手,但拥有比 Log4j 更多的优点、特性和更做强的性能,Logback 相对于 Log4j 拥有更快的执行速度。基于我们先前在 Log4j 上的工作,Logback 重写了内部的实现,在某些特定的场景上面,甚至可以比之前的速度快上 10 倍。在保证 Logback 的组件更加快速的同时,同时所需的内存更加少。

日志文件

日志文件放置于固定的目录中,按照一定的模板进行命名,推荐的日志文件名称:

当前正在写入的日志文件名:<应用名>[-<功能名>].log
如:example-server-book-service-access.log
已经滚入历史的日志文件名:<应用名>[-<功能名>].yyyy-MM-dd-hh.[滚动号].log

如:example-server-book-service-access.2019-12-01-10.1.log

日志变量定义

推荐使用 lombok(代码生成器) 注解 @lombok.extern.slf4j.Slf4j 来生成日志变量实例。

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.10</version>
    <scope>provided</scope>
</dependency>

代码示例

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LogTest {

    public static void main(String[] args) {
        log.info("this is log test");
    }
    
}

日志配置

日志记录采用分级记录,级别与日志文件名相对应,不同级别的日志信息记录到不同的日志文件中。如有特殊格式日志,如 access log,单独使用一个文件,请注意避免重复打印(可使用 additivity="false" 避免 )。

参数占位格式

使用参数化形式 {} 占位,[] 进行参数隔离,这样的好处是可读性更高,而且只有真正准备打印的时候才会处理参数。

// 正确示例,必须使用参数化信息的方式
log.debug("order is paying with userId:[{}] and orderId : [{}]",userId, orderId);
// 错误示例,不要进行字符串拼接,那样会产生很多 String 对象,占用空间,影响性能。及日志级别高于此级别也会进行字符串拼接逻辑。
log.debug("order is paying with userId: " + userId + " and orderId: " + orderId);

日志的基本格式

日志时间

作为日志产生的日期和时间,这个数据非常重要,一般精确到毫秒。

yyyy-MM-dd HH:mm:ss.SSS

日志级别

日志的输出都是分级别的,不同的设置不同的场合打印不同的日志。

主要使用如下的四个级别:

  • DEBUG:DEUBG 级别的主要输出调试性质的内容,该级别日志主要用于在开发、测试阶段输出。该级别的日志应尽可能地详尽,开发人员可以将各类详细信息记录到 DEBUG 里,起到调试的作用,包括参数信息,调试细节信息,返回值信息等等,便于在开发、测试阶段出现问题或者异常时,对其进行分析。
  • INFO:INFO 级别的主要记录系统关键信息,旨在保留系统正常工作期间关键运行指标,开发人员可以将初始化系统配置、业务状态变化信息,或者用户业务流程中的核心处理记录到INFO日志中,方便日常运维工作以及错误回溯时上下文场景复现。建议在项目完成后,在测试环境将日志级别调成 INFO,然后通过 INFO 级别的信息看看是否能了解这个应用的运用情况,如果出现问题后是否这些日志能否提供有用的排查问题的信息。
  • WARN:WARN 级别的主要输出警告性质的内容,这些内容是可以预知且是有规划的,比如,某个方法入参为空或者该参数的值不满足运行该方法的条件时。在 WARN 级别的时应输出较为详尽的信息,以便于事后对日志进行分析。
  • ERROR:ERROR 级别主要针对于一些不可预知的信息,诸如:错误、异常等,比如,在 catch 块中抓获的网络通信、数据库连接等异常,若异常对系统的整个流程影响不大,可以使用 WARN 级别日志输出。在输出 ERROR 级别的日志时,尽量多地输出方法入参数、方法执行过程中产生的对象等数据,在带有错误、异常对象的数据时,需要将该对象一并输出。

DEBUG / INFO 的选择

DEBUG 级别比 INFO 低,包含调试时更详细的了解系统运行状态的东西,比如变量的值等等,都可以输出到 DEBUG 日志里。 INFO 是在线日志默认的输出级别,反馈系统的当前状态给最终用户看的。输出的信息,应该对最终用户具有实际意义的。从功能角度上说,INFO 输出的信息可以看作是软件产品的一部分,所以需要谨慎对待,不可随便输出。如果这条日志会被频繁打印或者大部分时间对于纠错起不到作用,就应当考虑下调为 DEBUG 级别。

  • 由于 DEBUG 日志打印量远大于 INFO,出于前文日志性能的考虑,如果代码为核心代码,执行频率非常高,务必推敲日志设计是否合理,是否需要下调为 DEBUG 级别日志。
  • 注意日志的可读性,不妨在写完代码 review 这条日志是否通顺,能否提供真正有意义的信息。
  • 日志输出是多线程公用的,如果有另外一个线程正在输出日志,上面的记录就会被打断,最终显示输出和预想的就会不一致。

WARN / ERROR 的选择

当方法或者功能处理过程中产生不符合预期结果或者有框架报错时可以考虑使用,常见问题处理方法包括:

  • 增加判断处理逻辑,尝试本地解决:增加逻辑判断吞掉报警永远是最优选择抛出异常,交给上层逻辑解决
  • 抛出异常,交给上层逻辑解决
  • 记录日志,报警提醒
  • 使用返回码包装错误做返回

一般来说,WARN 级别不会短信报警,ERROR 级别则会短信报警甚至电话报警,ERROR 级别的日志意味着系统中发生了非常严重的问题,必须有人马上处理,比如数据库不可用,系统的关键业务流程走不下去等等。错误的使用反而带来严重的后果,不区分问题的重要程度,只要有问题就error记录下来,其实这样是非常不负责任的,因为对于成熟的系统,都会有一套完整的报错机制,那这个错误信息什么时候需要发出来,很多都是依据单位时间内 ERROR 日志的数量来确定的。

强调ERROR报警

  • ERROR 级别的日志打印通常伴随报警通知。ERROR的报出应该伴随着业务功能受损,即上面提到的系统中发生了非常严重的问题,必须有人马上处理。

ERROR日志目标

  • 给处理者直接准确的信息:ERROR 信息形成自身闭环。

问题定位:

  • 发生了什么问题,哪些功能受到影响
  • 获取帮助信息:直接帮助信息或帮助信息的存储位置
  • 通过报警知道解决方案或者找何人解决

线程名称

输出该日志的线程名称,一般在一个应用中一个同步请求由同一线程完成,输出线程名称可以在各个请求产生的日志中进行分类,便于分清当前请求上下文的日志。

opentracing 标识

在分布式应用中,用户的一个请求会调用若干个服务完成,这些服务可能还是嵌套调用的,因此完成一个请求的日志并不在一个应用的日志文件,而是分散在不同服务器上不同应用节点的日志文件中。该标识是为了串联一个请求在整个系统中的调用日志。

  • 唯一字符串(trace id)
  • 调用层级(span id)

通过搜索 trace id 就可以查到这个 trace id 标识的请求在整个系统中流转(处理)过程中产生的所有日志。

biz 标识

在业务开发中,我们的日志都是和业务相关联的,有时候是需要根据用户或者业务做聚类的,因此一次请求如果可以通过某项标识做聚类的时候,可以将聚类标识打印到日志中。

  • 用户标识(user id)
  • 业务标识(biz id)

日志记录器名称

日志记录器名称一般使用类名,日志文件中可以输出简单的类名即可,看实际情况是否需要使用包名和行号等信息。主要用于看到日志后到哪个类中去找这个日志输出,便于定位问题所在。

日志内容

禁用 System.out.println 和 System.err.println

变参替换日志拼接

输出日志的对象,应在其类中实现快速的 toString 方法,以便于在日志输出时仅输出这个对象类名和 hashCode

预防空指针:不要在日志中调用对象的方法获取值,除非确保该对象肯定不为 null,否则很有可能会因为日志的问题而导致应用产生空指针异常。

异常堆栈

异常堆栈一般会出现在 ERROR 或者 WARN 级别的日志中,异常堆栈含有方法调用链的系统,以及异常产生的根源。异常堆栈的日志属于上一行日志的,在日志收集时需要将其划至上一行中。

最佳实践

日志格式

2019-12-01 00:00:00.000|pid|log-level|[svc-name,trace-id,span-id,user-id,biz-id]|thread-name|package-name.class-name : log message
  • 时间
  • pid,pid
  • log-level,日志级别
  • svc-name,应用名称
  • trace-id,调用链标识
  • span-id,调用层级标识
  • user-id,用户标识
  • biz-id,业务标识
  • thread-name,线程名称
  • package-name.class-name,日志记录器名称
  • log message,日志消息体

日志模块扩展

日志模块是基于以下技术点做扩展的。

  • Slf4j MDC 实现原理(暂不开展详解,如有兴趣私下沟通)
  • Opentracing Scope 原理(暂不开展详解,如有兴趣私下沟通)

在每个 tracing 链路中,将 Opentracing Scope 中的上下文信息放置 MDC 中,根据 Spring Boot Logging 扩展接口扩展的取值逻辑 logging.pattern.level 的取值逻辑。

相关源码参考

Spring Cloud Sleuth)

[Spring Cloud Sleuth]https://github.com/spring-cloud/spring-cloud-sleuth/blob/master/spring-cloud-sleuth-core/src/main/java/org/springframework/cloud/sleuth/log/Slf4jCurrentTraceContext.java

修改 logback 配置文件中每个 appender 的 pattern 为以下默认值即可实现标准化。

%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}|${PID:- }|%level|${LOG_LEVEL_PATTERN:-%5p}|%t|%-40.40logger{39}: %msg%n

logback.xml 节选

<configuration>
<property name="LOG_PATH"
          value="${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}"/>

<springProperty scope="context" name="APP_NAME"
                source="spring.application.name" defaultValue="spring-boot-fusion"/>
<!-- 全局统一 pattern -->
<property name="LOG_PATTERN"
          value="%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}|${PID:- }|%level|${LOG_LEVEL_PATTERN:-%5p}|%t|%-40.40logger{39}: %msg%n"/>
<!-- 输出模式 file,滚动记录文件,先将日志文件指定到文件,当符合某个条件时,将日志记录到其他文件 -->
<appender name="fileInfo" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <!--被写入的文件名,可以是相对目录,也可以是绝对目录,如果上级目录不存在会自动创建,没有默认值。-->
    <file>${LOG_PATH}/${APP_NAME}-info.log</file>
    <!--滚动策略  基于时间的分包策略 -->
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <!-- yyyy-MM-dd 时间策略则为一天一个文件 -->
        <FileNamePattern>${LOG_PATH}/${APP_NAME}-info.%d{yyyy-MM-dd-HH}.%i.log</FileNamePattern>
        <!--日志文件保留小时数-->
        <MaxHistory>48</MaxHistory>
        <maxFileSize>1GB</maxFileSize>
        <totalSizeCap>20GB</totalSizeCap>
    </rollingPolicy>
    <!--  layout 负责把事件转换成字符串,格式化的日志信息的输出 -->
    <layout class="ch.qos.logback.classic.PatternLayout">
        <pattern>${LOG_PATTERN}</pattern>
    </layout>
    <!--级别过滤器,根据日志级别进行过滤。如果日志级别等于配置级别,过滤器会根据onMath 和 onMismatch接收或拒绝日志-->
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
        <!--设置过滤级别-->
        <level>INFO</level>
        <!--用于配置符合过滤条件的操作-->
        <onMatch>ACCEPT</onMatch>
        <!--用于配置不符合过滤条件的操作-->
        <onMismatch>DENY</onMismatch>
    </filter>
</appender>
</configuration>

代码使用示例:

@Override
public Result<PagingObject<SimpleResponse>> page(@RequestParam(value = "page-num", defaultValue = "1") int pageNum,
                                                 @RequestParam(value = "page-size", defaultValue = "10") int pageSize) {
    LogStandardUtils.putUserId("userId123");
    LogStandardUtils.putBizId("bizId321");
    producerService.sendMsg("xxx");
    simpleClient.page(pageNum, pageSize);
    return new Result<>(simpleService.page(pageNum, pageSize));
}

日志记录

2019-12-04 16:29:08.223|43546|INFO|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.n.u.concurrent.ShutdownEnabledTimer   : Shutdown hook installed for: NFLoadBalancer-PingTimer-example-server-order-service
2019-12-04 16:29:08.224|43546|INFO|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.netflix.loadbalancer.BaseLoadBalancer : Client: example-server-order-service instantiated a LoadBalancer: DynamicServerListLoadBalancer:{NFLoadBalancer:name=example-server-order-service,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:null
2019-12-04 16:29:08.234|43546|INFO|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.n.l.DynamicServerListLoadBalancer     : Using serverListUpdater PollingServerListUpdater
2019-12-04 16:29:08.247|43546|INFO|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.n.l.DynamicServerListLoadBalancer     : DynamicServerListLoadBalancer for client example-server-order-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=example-server-order-service,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:ConsulServerList{serviceId='example-server-order-service', tag=null}
2019-12-04 16:29:08.329|43546|WARN|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.p.f.l.ctl.common.rule.StrategyRule    : No up servers available from load balancer: DynamicServerListLoadBalancer:{NFLoadBalancer:name=example-server-order-service,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:ConsulServerList{serviceId='example-server-order-service', tag=null}
2019-12-04 16:29:08.334|43546|WARN|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.p.f.l.ctl.common.rule.StrategyRule    : No up servers available from load balancer: DynamicServerListLoadBalancer:{NFLoadBalancer:name=example-server-order-service,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:ConsulServerList{serviceId='example-server-order-service', tag=null}
2019-12-04 16:29:08.342|43546|ERROR|[example-server-book-service,ac613cff04bac8b1,4a9adc10fdf0eb5,userId123,bizId321]|XNIO-1 task-4|c.p.f.w.c.advice.ExceptionHandlerAdvice : 当前程序进入到异常捕获器,出错的 url 为:[ http://127.0.0.1:10011/simples ],出错的参数为:[ {"querystring":"{}","payload":""} ]
java.lang.RuntimeException: com.netflix.client.ClientException: Load balancer does not have available server for client: example-server-order-service

日志服务

SLS 阿里云日志服务

阿里云日志服务(简称 SLS)是针对日志类数据的一站式服务,在阿里巴巴集团经历大量大数据场景锤炼而成。您无需开发就能快捷完成日志数据采集、消费、投递以及查询分析等功能,提升运维、运营效率,建立 DT 时代海量日志处理能力。

project

项目、管理日志基础单元,服务日志建议一个环境建为一个 Project,这样日志记录是整体一个闭环,日志记录随整个环境内的服务调用产生。

logstore

日志库,日志库建议按照日志类型分为不同的,如特定格式的 access 日志,以及 info / warn / error 日志,特定格式可以配置更为方面的索引以及告警设置。

注意:请勿按照应用服务区分为不同的 logstore,在微服务架构中,一次请求交叉了多个应用服务,日志是散落在各个应用服务中的,按照服务区分 logstore,需要开发同学十分了解应用运行状况和调用拓扑图,这点往往是不具备的。

实时采集与消费

功能:

  • 通过ECS、容器、移动端、开源软件、JS等接入实时日志数据(例如Metric、Event、BinLog、TextLog、Click等)。
  • 提供实时消费接口,与实时计算及服务对接。

用途:数据清洗(ETL)、流计算(Stream Compute)、监控与报警、 机器学习与迭代计算。

yw3YehDZY5wvb6de.png

查询分析

实时索引、查询分析数据。

  • 查询:关键词、模糊、上下文、范围。
  • 统计:SQL聚合等丰富查询手段。
  • 可视化:Dashboard + 报表功能。
  • 对接:Grafana、JDBC/SQL92。

用途:DevOps / 线上运维,日志实时数据分析,安全诊断与分析,运营与客服系统。

MK3jhG4bm7A4tJ2F.jpg

消费投递

稳定可靠的日志投递。将日志中枢数据投递至存储类服务进行存储。支持压缩、自定义Partition、以及行列等各种存储方式。

用途:数据仓库 + 数据分析、审计、推荐系统与用户画像。

03hsTOCwtAsppzCN.png

告警

日志服务的告警功能基于仪表盘中的查询图表实现。在日志服务控制台查询页面仪表盘页面设置告警规则,并指定告警规则的配置、检查条件和通知方式。设置告警后,日志服务定期对仪表盘的查询结果进行检查,检查结果满足预设条件时发送告警通知,实现实时的服务状态监控。

最佳实践

阿里云的日志服务功能相当强大,想用好日志服务可以参看:

https://help.aliyun.com/document_detail/29090.html?spm=a2c4g.11186623.6.1079.4edd3aabvs50OW

ELK 通用日志解决方案

ELK 是 Elasticsearch、Logstash、Kibana 三大开源框架首字母大写简称。市面上也被成为 Elastic Stack。其中 Elasticsearch 是一个基于 Lucene、分布式、通过 Restful 方式进行交互的近实时搜索平台框架。像类似百度、谷歌这种大数据全文搜索引擎的场景都可以使用 Elasticsearch 作为底层支持框架,可见 Elasticsearch 提供的搜索能力确实强大,市面上很多时候我们简称 Elasticsearch 为 es。Logstash 是 ELK 的中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/ MQ / Redis / Elasticsearch / Kafka 等)。Kibana 可以将 Elasticsearch 的数据通过友好的页面展示出来,提供实时分析的功能。

实践说明

普通格式日志

2019-11-26 15:01:03.332|1543|INFO|[example-server-book-service,28f019d57b8336ab,630697c7f34ca4fa,105,45982043|XNIO-1 task-42]|c.p.f.w.pay.PayServiceImpl     : order is paying with userId: 105 and orderId: 45982043

普通日志前缀是固定的,可以固定分词索引,方便更快的查询分析。

特定格式日志

以 access 日志为例

2019-11-26 15:01:03.332|1543|INFO|[example-server-book-service,28f019d57b8336ab,630697c7f34ca4fa,105,45982043|XNIO-1 task-42]|c.p.f.w.logging.AccessLoggingFilter     : 
> url: http://liweichao.com:10011/actuator/health
> http-method: GET
> request-header: [Accept:"text/plain, text/*, */*", Connection:"close", User-Agent:"Consul Health Check", Host:"liweichao.com:10011", Accept-Encoding:"gzip"]
> request-time: 2019-11-26 15:01:03.309
> querystring: -
> payload: -
> extra-param: -

< response-time: 2019-11-26 15:01:03.332
< take-time: 23
< http-status: 200
< response-header: [content-type:"application/vnd.spring-boot.actuator.v2+json;charset=UTF-8", content-size:"15"]
< response-data: {"status":"UP"}

特定格式日志可按格式创建索引更方便聚焦查询分析和告警,如根据 take-time,http-status,biz-code 等值。

参考文献

作者说明

来自读者投稿,有好的文章欢迎联系我微信 jihuan900

查看原文

赞 55 收藏 37 评论 1

wffger 赞了文章 · 2019-12-10

Vue3.0 新特性全面探索 - 基于 Composition Api 快速构建实战项目

Quick Start

本项目综合运用了 Vue3.0 的新特性,适合新手学习😁

  • 基于 Composition APIFunction-based API 进行改造,配合 Vue Cli,优先体验 Vue3 特性
  • 使用单例对象模式进行组件通信
  • 使用 axios 库进行网络请求,weui 库实现 UI 界面
# 安装依赖
npm install
# 在浏览器打开localhost:8080查看页面,并实时热更新
npm run serve
# 发布项目
npm run build

建议配合 Visual Studio Code 和 Vue 3 Snippets 代码插件食用Ψ( ̄∀ ̄)Ψ。

Dependencies

以下是项目运用到的依赖,@vue/composition-api 配合 vue 模块让我们 Vue2.0 版本可以抢先体验 Vue3.0 的新特性,axios 是辅助我们发送网络请求得到数据的工具库,weui是一套与微信原生视觉一致的基础样式库,方便我们快速搭建项目页面。

"@vue/composition-api": "^0.3.4",
"axios": "^0.19.0",
"core-js": "^3.4.3",
"vue": "^2.6.10",
"weui": "^2.1.3"

Directory Structure

├── src
│   ├── App.vue                          # 组件入口
│   ├── assets                           # 资源目录
│   ├── stores/index.js                  # 状态管理
│   ├── components                       # 组件目录
│   │   ├── Header.vue                   # 头部组件
│   │   ├── Search.vue                   # 搜索框组件
│   │   ├── Panel.vue                    # 列表组件
│   ├── main.js                          # 项目入口
├── public                               # 模板文件
├── vue.config.js                        # 脚手架配置文件
├── screenshot                           # 程序截图

Composition API

npm install @vue/composition-api --save

使用 npm 命令下载了 @vue/composition-api 插件以后,引入该模块后,需要显式调用 Vue.use(VueCompositionApi) ,按照文档在 main.js 引用便开启了 Composition API 的能力。

// main.js
import Vue from 'vue'
import App from './App.vue'
// 1.引入Composition API模块
import VueCompositionApi from '@vue/composition-api'

Vue.config.productionTip = false
// 2.不要漏了显式调用 VueCompositionApi
Vue.use(VueCompositionApi)

new Vue({
  render: h => h(App),
}).$mount('#app')
npm install weui --save

我们同样使用 npm 安装 weui 模块,然后在 main.js 中引入 weui的基础样式库,方便我们可以在全局使用微信基础样式构建项目页面。

// main.js
import Vue from 'vue'
import App from './App.vue'
// 全局引入 `weui` 的基础样式库
import 'weui'
import VueCompositionApi from '@vue/composition-api'

Vue.config.productionTip = false
Vue.use(VueCompositionApi)

new Vue({
  render: h => h(App),
}).$mount('#app')

回到 App.vue,保留 components 属性值清空 <template> 模板的内容,删除 <style> 模板,等待重新引入新的组件。

<template>
  <div id="app">
    Hello World
  </div>
</template>
<script>
export default {
  name: "app",
  components: {}
};
</script>

src/components 目录下新建第一个组件,取名为 Header.vue 写入以下代码,点击查看源代码

<template>
  <header :style="{
    backgroundColor: color?color:defaultColor
  }">{{title}}</header>
</template>
<script>
import { reactive } from "@vue/composition-api";
export default {
  // 父组件传递进来更改该头部组件的属性值
  props: {
    // 标题
    title: String,
    // 颜色
    color: String
  },
  setup() {
    const state = reactive({
      defaultColor: "red"
    });
    return {
      ...state
    };
  }
};
</script>
<style scoped>
header {
  height: 50px;
  width: 100%;
  line-height: 50px;
  text-align: center;
  color: white;
}
</style>

setup

这里运用了一个全新的属性 setup ,这是一个组件的入口,让我们可以运用 Vue3.0 暴露的新接口,它运行在组件被实例化时候,props 属性被定义之后,实际上等价于 Vue2.0 版本的 beforeCreateCreated 这两个生命周期,setup 返回的是一个对象,里面的所有被返回的属性值,都会被合并到 Vue2.0render 渲染函数里面,在单文件组件中,它将配合 <template> 模板的内容,完成 ModelView 之间的绑定,在未来版本中应该还会支持返回 JSX 代码片段。

<template>
  <!-- View -->
  <div>{{name}}</div>
</template>
<script>
import { reactive } from '@vue/composition-api'
export default {
  setup() {
    const state = reactive({ name: 'Eno Yao' });
    // return 暴露到 template 中
    return {
      // Model
      ...state
    }
  }
}
</script>

reactive

setup 函数里面, 我们适应了 Vue3.0 的第一个新接口 reactive 它主要是处理你的对象让它经过 Proxy 的加工变为一个响应式的对象,类似于 Vue2.0 版本的 data 属性,需要注意的是加工后的对象跟原对象是不相等的,并且加工后的对象属于深度克隆的对象。

const state = reactive({ name: 'Eno Yao' })

props

Vue2.0 中我们可以使用 props 属性值完成父子通信,在这里我们需要定义 props 属性去定义接受值的类型,然后我们可以利用 setup 的第一个参数获取 props 使用。

export default {
  props: {
    // 标题
    title: String,
    // 颜色
    color: String
  },
  setup(props) {
    // 这里可以使用父组件传过来的 props 属性值
  }
};

我们在 App.vue 里面就可以使用该头部组件,有了上面的 props 我们可以根据传进来的值,让这个头部组件呈现不同的状态。

<template>
  <div id="app">
    <!-- 复用组件,并传入 props 值,让组件呈现对应的状态 -->
    <Header title="Eno" color="red" />
    <Header title="Yao" color="blue" />
    <Header title="Wscats" color="yellow" />
  </div>
</template>
<script>
import Header from "./components/Header.vue";
export default {
  name: "app",
  components: {
    Header,
  }
};
</script>

1.gif

context

setup 函数的第二个参数是一个上下文对象,这个上下文对象中包含了一些有用的属性,这些属性在 Vue2.0 中需要通过 this 才能访问到,在 vue3.0 中,访问他们变成以下形式:

setup(props, ctx) {
  console.log(ctx) // 在 setup() 函数中无法访问到 this
  console.log(this) // undefined
}

具体能访问到以下有用的属性:

  • root
  • parent
  • refs
  • attrs
  • listeners
  • isServer
  • ssrContext
  • emit

完成上面的 Header.vue 我们就编写 Search.vue 搜索框组件,继续再 src/components 文件夹下面新建 Search.vue 文件,点击查看源代码

<template>
  <div :class="['weui-search-bar', {'weui-search-bar_focusing' : isFocus}]" id="searchBar">
    <form class="weui-search-bar__form">
      <div class="weui-search-bar__box">
        <i class="weui-icon-search"></i>
        <input
          v-model="searchValue"
          ref="inputElement"
          type="search"
          class="weui-search-bar__input"
          id="searchInput"
          placeholder="搜索"
          required
        />
        <a href="javascript:" class="weui-icon-clear" id="searchClear"></a>
      </div>
      <label @click="toggle" class="weui-search-bar__label" id="searchText">
        <i class="weui-icon-search"></i>
        <span>搜索</span>
      </label>
    </form>
    <a @click="toggle" href="javascript:" class="weui-search-bar__cancel-btn" id="searchCancel">取消</a>
  </div>
</template>
<script>
import { reactive, toRefs, watch } from "@vue/composition-api";
import store from "../stores";
export default {
  // setup相当于2.x版本的beforeCreate生命周期
  setup() {
    // reactive() 函数接收一个普通对象,返回一个响应式的数据对象
    const state = reactive({
      searchValue: "",
      // 搜索框两个状态,聚焦和非聚焦
      isFocus: false,
      inputElement: null
    });
    // 切换搜索框状态的方法
    const toggle = () => {
      // 让点击搜索后出现的输入框自动聚焦
      state.inputElement.focus();
      state.isFocus = !state.isFocus;
    };
    // 监听搜索框的值
    watch(
      () => {
        return state.searchValue;
      },
      () => {
        // 存储输入框到状态 store 中心,用于组件通信
        store.setSearchValue(state.searchValue);
        // window.console.log(state.searchValue);
      }
    );
    return {
      // 将 state 上的每个属性,都转化为 ref 形式的响应式数据
      ...toRefs(state),
      toggle
    };
  }
};
</script>

toRefs

可以看到我们上面用了很多的新属性,我们先介绍 toRefs ,函数可以将 reactive() 创建出来的响应式对象,转换为普通的对象,只不过,这个对象上的每个属性节点,都是 ref() 类型的响应式数据,配合 v-model 指令能完成数据的双向绑定,在开发中非常高效。

import { reactive, toRefs } from "@vue/composition-api";
export default {
  setup() {
    const state = reactive({ name: 'Eno Yao' })
  }
  return {
    // 直接返回 state 那么数据会是非响应式的, MV 单向绑定
    // ...state,
    // toRefs 包装后返回 state 那么数据会是响应式的, MVVM 双向绑定
    ...toRefs(state),
  };
}

2.gif

template refs

这里的输入框拥有两个状态,一个是有输入框的状态和无输入框的状态,所以我们需要一个布尔值 isFocus 来控制状态,封装了一个 toggle 方法,让 isFocus 值切换真和假两个状态。

const toggle = () => {
  // isFocus 值取反
  state.isFocus = !state.isFocus;
};

然后配合 v-bind:class 指令,让 weui-search-bar_focusing 类名根据 isFocus 值决定是否出现,从而更改搜索框的状态。

<div :class="['weui-search-bar', {'weui-search-bar_focusing' : isFocus}]" id="searchBar">

这里的搜索输入框放入了 v-model 指令,用于接收用户的输入信息,方便后面配合列表组件执行检索逻辑,还放入了 ref 属性,用于获取该 <input/> 标签的元素节点,配合state.inputElement.focus() 原生方法,在切换搜索框状态的时候光标自动聚焦到输入框,增强用户体验。

<input
  v-model="searchValue"
  ref="inputElement"
/>

3.gif

watch

watch() 函数用来监视某些数据项的变化,从而触发某些特定的操作,使用之前还是需要按需导入,监听 searchValue 的变化,然后触发回调函数里面的逻辑,也就是监听用户输入的检索值,然后触发回调函数的逻辑把 searchValue 值存进我们创建 store 对象里面,方面后面和 Panel.vue 列表组件进行数据通信:

import { reactive, watch } from "@vue/composition-api";
import store from "../stores";
export default {
  setup() {
    const state = reactive({
      searchValue: "",
    });
    // 监听搜索框的值
    watch(
      () => {
        return state.searchValue;
      },
      () => {
        // 存储输入框到状态 store 中心,用于组件通信
        store.setSearchValue(state.searchValue);
      }
    );
    return {
      ...toRefs(state)
    };
  }
};

state management

在这里我们维护一份数据来实现共享状态管理,也就是说我们新建一个 store.js 暴露出一个 store 对象共享 PanelSearch 组件的 searchValue 值,当 Search.vue 组件从输入框接受到 searchValue 检索值,就放到 store.jsstore 对象中,然后把该对象注入到 Search 组件中,那么两个组件都可以共享 store 对象中的值,为了方便调试我们还分别封装了 setSearchValuegetSearchValue 来去操作该 store 对象,这样我们就可以跟踪状态的改变。

// store.js
export default {
    state: {
        searchValue: ""
    },
    // 设置搜索框的值
    setSearchValue(value) {
        this.state.searchValue = value
    },
    // 获取搜索框的值
    getSearchValue() {
        return this.state.searchValue
    }
}

完成上面的 Search.vue 我们紧接着编写 Panel.vue 搜索框组件,继续再 src/components 文件夹下面新建 Panel.vue 文件,点击查看源代码

<template>
  <div class="weui-panel weui-panel_access">
    <div v-for="(n,index) in newComputed" :key="index" class="weui-panel__bd">
      <a href="javascript:void(0);" class="weui-media-box weui-media-box_appmsg">
        <div class="weui-media-box__hd">
          <img class="weui-media-box__thumb" :data-original="n.author.avatar_url" alt />
        </div>
        <div class="weui-media-box__bd">
          <h4 class="weui-media-box__title" v-text="n.title"></h4>
          <p class="weui-media-box__desc" v-text="n.author.loginname"></p>
        </div>
      </a>
    </div>
    <div @click="loadMore" class="weui-panel__ft">
      <a href="javascript:void(0);" class="weui-cell weui-cell_access weui-cell_link">
        <div class="weui-cell__bd">查看更多</div>
        <span class="weui-cell__ft"></span>
      </a>
    </div>
  </div>
</template>
<script>
import { reactive, toRefs, onMounted, computed } from "@vue/composition-api";
import axios from "axios";
import store from "../stores";
export default {
  setup() {
    const state = reactive({
      // 页数
      page: 1,
      // 列表数据
      news: [],
      // 通过搜索框的值去筛选劣列表数据
      newComputed: computed(() => {
        // 判断是否输入框是否输入了筛选条件,如果没有返回原始的 news 数组
        if (store.state.searchValue) {
          return state.news.filter(item => {
            if (item.title.indexOf(store.state.searchValue) >= 0) {
              return item;
            }
          });
        } else {
          return state.news;
        }
      }),
      searchValue: store.state
    });
    // 发送 ajax 请求获取列表数据
    const loadMore = async () => {
      // 获取列表数据
      let data = await axios.get("https://cnodejs.org/api/v1/topics", {
        params: {
          // 每一页的主题数量
          limit: 10,
          // 页数
          page: state.page
        }
      });
      // 叠加页数
      state.page += 1;
      state.news = [...state.news, ...data.data.data];
    };
    onMounted(() => {
      // 首屏加载的时候触发请求
      loadMore();
    });
    return {
      // 让数据保持响应式
      ...toRefs(state),
      // 查看更多事件
      loadMore
    };
  }
};
</script>

lifecycle hooks

Vue3.0 的生命周期钩子和之前不一样,新版本都是以 onXxx() 函数注册使用,同样需要局部引入生命周期的对应模块:

import { onMounted, onUpdated, onUnmounted } from "@vue/composition-api";
export default {
  setup() {
    const loadMore = () => {};
    onMounted(() => {
      loadMore();
    });
    onUpdated(() => {
      console.log('updated!')
    })
    onUnmounted(() => {
      console.log('unmounted!')
    })
    return {
      loadMore
    };
  }
};

以下是新旧版本生命周期的对比:

  • <s>beforeCreate</s> -> use setup()
  • <s>created</s> -> use setup()
  • beforeMount -> onBeforeMount
  • mounted -> onMounted
  • beforeUpdate -> onBeforeUpdate
  • updated -> onUpdated
  • beforeDestroy -> onBeforeUnmount
  • destroyed -> onUnmounted
  • errorCaptured -> onErrorCaptured

同时新版本还提供了两个全新的生命周期帮助我们去调试代码:

  • onRenderTracked
  • onRenderTriggered

Panel 列表组件中,我们注册 onMounted 生命周期,并在里面触发请求方法 loadMore 以便从后端获取数据到数据层,这里我们使用的是 axios 网络请求库,所以我们需要安装该模块:

npm install axios --save

封装了一个请求列表数据方法,接口指向的是 Cnode 官网提供的 API ,由于 axios 返回的是 Promise ,所以配合 asyncawait 可以完美的编写异步逻辑,然后结合onMounted 生命周期触发,并将方法绑定到视图层的查看更多按钮上,就可以完成列表首次的加载和点击查看更多的懒加载功能。

// 发送 ajax 请求获取列表数据
const loadMore = async () => {
  // 获取列表数据
  let data = await axios.get("https://cnodejs.org/api/v1/topics", {
    params: {
      // 每一页的主题数量
      limit: 10,
      // 页数
      page: state.page
    }
  });
  // 叠加页数
  state.page += 1;
  // 合并列表数据
  state.news = [...state.news, ...data.data.data];
};
onMounted(() => {
  // 首屏加载的时候触发请求
  loadMore();
});

4.gif

computed

接下来我们就使用另外一个属性 computed 计算属性,跟 Vue2.0 的使用方式很相近,同样需要按需导入该模块:

import { computed } from '@vue/composition-api';

计算属性分两种,只读计算属性和可读可写计算属性:

// 只读计算属性
let newsComputed = computed(() => news.value + 1)
// 可读可写
let newsComputed = computed({
  // 取值函数
  get: () => news.value + 2,
  // 赋值函数
  set: val => {
    news.value = news.value - 3
  }
})

5.gif

这里我们使用可读可写计算属性去处理列表数据,还记得我们上一个组件 Search.vue 吗,我们可以结合用户在搜索框输入的检索值,配合 computed 计算属性来筛选对我们用户有用列表数据,所以我们首先从 store 的共享实例里面拿到 Search.vue 搜索框共享的 searchValue ,然后利用原生字符串方法 indexOf 和 数组方法 filter 来过滤列表的数据,然后重新返回新的列表数据 newsComputed,并在视图层上配合 v-for 指令去渲染新的列表数据,这样做既可以在没搜索框检索值的时候返回原列表数据 news ,而在有搜索框检索值的时候返回新列表数据 newsComputed

import store from "../stores";
export default {
  setup() {
    const state = reactive({
      // 原列表数据
      news: [],
      // 通过搜索框的值去筛选后的新列表数据
      newsComputed: computed(() => {
        // 判断是否输入框是否输入了筛选条件,如果没有返回原始的 news 数组
        if (store.state.searchValue) {
          return state.news.filter(item => {
            if (item.title.indexOf(store.state.searchValue) >= 0) {
              return item;
            }
          });
        } else {
          return state.news;
        }
      }),
      searchValue: store.state
    });
  }
}

项目源码

如果文章和笔记能带您一丝帮助或者启发,请不要吝啬你的赞和 Star,你的肯定是我前进的最大动力😁

查看原文

赞 22 收藏 16 评论 0

wffger 赞了文章 · 2019-12-10

数据仓库的对比和选择

整理了一些相关的产品,包括:

商业系统

  • InfoBright
  • Greenplum(已开源)、HP Vertica、TeraData、Palo、ExaData、RedShift、BigQuery(Dremel)

开源实现

  • Impala、Presto、Spark SQL、Drill、Hawq
  • Druid、Pinot
  • Kylin

presto、druid、sparkSQL、kylin的对比

  • presto和spark sql都是解决分布式查询问题,提供SQL查询能力,但数据加载不一定能保证实时;
  • Druid是保证数据实时写入,但查询上不支持SQL,或者说目前只支持部分SQL,我个人觉得适合用于工业大数据,比如一堆传感器实时写数据的场景;
  • Kylin是MOLAP,就是将数据先进行预聚合,然后把多维查询变成了key-value查询。基本思路是预先对数据作多维索引,查询时只扫描索引而不访问原始数据从而提速;

  presto:facebook开源的一个java写的分布式数据查询框架,原生集成了Hive、Hbase和关系型数据库,Presto背后所使用的执行模式与Hive有根本的不同,它没有使用MapReduce,大部分场景下比hive快一个数量级,其中的关键是所有的处理都在内存中完成。
  Druid:是一个实时处理时序数据的Olap数据库,因为它的索引首先按照时间分片,查询的时候也是按照时间线去路由索引。
  spark SQL:基于spark平台上的一个olap框架,本质上也是基于DAG的MPP, 基本思路是增加机器来并行计算,从而提高查询速度。
  kylin:核心是Cube,cube是一种预计算技术,基本思路是预先对数据作多维索引,查询时只扫描索引而不访问原始数据从而提速。

这几种框架各有优缺点,存在就是合理,如何选型个人看法如下:
从成熟度来讲:kylin > spark sql > Druid > presto
从超大数据的查询效率来看:Druid > kylin > presto > spark sql
从支持的数据源种类来讲:presto > spark sql > kylin > Druid

大数据查询目前来讲可以大体分为三类:

  1. 基于hbase预聚合的。适合相对固定的业务报表类需求。需要指定预聚合的指标,在数据接入的时候根据指定的指标进行聚合运算,只需要统计少量维度即可满足业务报表需求。比如Opentsdb,Kylin,Druid等
  2. 基于Parquet列式存储的,基本是完全基于内存的并行计算,Parquet系能降低存储空间,提高IO效率,以离线处理为主,很难提高数据写的实时性,超大表的join支持可能不够好。spark sql也算类似,但它在内存不足时可以spill disk来支持超大数据查询和join。比如Presto, Drill,Impala等
  3. 基于lucene外部索引的,比如ElasticSearch和Solr,能够满足的的查询场景远多于传统的数据库存储,但对于日志、行为类时序数据,所有的搜索请求都也必须搜索所有的分片,另外,对于聚合分析场景的支持也是软肋



欢迎订阅「K叔区块链」 - 专注于区块链技术学习

博客地址:http://www.jouypub.com
简书主页:https://www.jianshu.com/u/756c9c8ae984
segmentfault主页:https://segmentfault.com/blog/jouypub
腾讯云主页:https://cloud.tencent.com/developer/column/72548查看原文

赞 4 收藏 2 评论 0

认证与成就

  • 获得 7 次点赞
  • 获得 128 枚徽章 获得 2 枚金徽章, 获得 33 枚银徽章, 获得 93 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-09-18
个人主页被 991 人浏览