使用 SpringAOP 获取一次请求流经方法的调用次数和调用耗时

引语

作为工程师,不能仅仅满足于实现了现有的功能逻辑,还必须深入认识系统。一次请求,流经了哪些方法,执行了多少次DB操作,访问了多少次文件操作,调用多少次API操作,总共有多少次IO操作,多少CPU操作,各耗时多少 ? 开发者应当知道这些运行时数据,才能对系统的运行有更深入的理解,更好滴提升系统的性能和稳定性。

完成一次订单导出任务,实际上是一个比较复杂的过程:需要访问ES 来查询订单,调用批量API接口 及访问 Hbase 获取订单详情数据,格式化报表字段数据,写入和上传报表文件,更新数据库,上报日志数据等;在大流量导出的情形下,采用批量并发策略,多线程来获取订单详情数据,整个请求的执行流程会更加复杂。

本文主要介绍使用AOP拦截器来获取一次请求流经方法的调用次数和调用耗时。

总体思路

使用AOP思想来解决。增加一个注解,然后增加一个AOP methodAspect ,记录方法的调用次数及耗时。

methodAspect 内部含有两个变量 methodCount, methodCost ,都采用了 ConcurrentHashMap 。这是因为方法执行时,可能是多线程写入这两个变量。

使用:

(1) 将需要记录次数和耗时的方法加上 MethodMeasureAnnotation 即可;

(2) 将 MethodMeasureAspect 作为组件注入到 ServiceAspect 中,并在 ServiceAspect 中打印 MethodMeasureAspect 的内容。

关注哪些方法

通常重点关注一个任务流程中的如下方法:

IO阻塞操作:文件操作, DB操作,API操作, ES访问,Hbase访问;

同步操作:lock, synchronized, 同步工具所施加的代码块等;

CPU耗时:大数据量的format, sort 等。

一般集中在如下包:

service, core , report, sort 等。根据具体项目而定。

源代码

packagezzz.study.aop;


importzzz.study.util.MapUtil;


importorg.aspectj.lang.ProceedingJoinPoint;

importorg.aspectj.lang.Signature;

importorg.aspectj.lang.annotation.Around;

importorg.aspectj.lang.annotation.Aspect;

importorg.aspectj.lang.reflect.MethodSignature;

importorg.slf4j.Logger;

importorg.slf4j.LoggerFactory;

importorg.springframework.stereotype.Component;


importjava.lang.reflect.Method;

importjava.util.ArrayList;

importjava.util.IntSummaryStatistics;

importjava.util.List;

importjava.util.Map;

importjava.util.concurrent.ConcurrentHashMap;

importjava.util.stream.Collectors;


/**

 * 记录一次请求中,流经的所有方法的调用耗时及次数

 *

 */

@Component

@Aspect

publicclassMethodMeasureAspect {


privatestaticfinalLogger logger = LoggerFactory.getLogger(MethodMeasureAspect.class);


privateMap methodCount =newConcurrentHashMap();


privateMap> methodCost =newConcurrentHashMap();


  @SuppressWarnings(value = "unchecked")

  @Around("@annotation(zzz.study.aop.MethodMeasureAnnotation)")

publicObject process(ProceedingJoinPoint joinPoint) {

Object obj =null;

    String className = joinPoint.getTarget().getClass().getSimpleName();

    String methodName = className + "_" + getMethodName(joinPoint);

longstartTime = System.currentTimeMillis();

try{

      obj = joinPoint.proceed();

}catch(Throwable t) {

      logger.error(t.getMessage(), t);

}finally{

longcostTime = System.currentTimeMillis() - startTime;

      logger.info("method={}, cost_time={}", methodName, costTime);

      methodCount.put(methodName, methodCount.getOrDefault(methodName, 0) + 1);

List costList = methodCost.getOrDefault(methodName,newArrayList<>());

costList.add((int)costTime);

      methodCost.put(methodName, costList);

    }

returnobj;

  }


publicString getMethodName(ProceedingJoinPoint joinPoint) {

    Signature signature = joinPoint.getSignature();

    MethodSignature methodSignature = (MethodSignature) signature;

    Method method = methodSignature.getMethod();

returnmethod.getName();

  }


publicString toString() {


StringBuilder sb =newStringBuilder("MethodCount:\n");

    Map<String,Integer> sorted =  MapUtil.sortByValue(methodCount);

    sorted.forEach(

        (method, count) -> {

          sb.append("method=" + method + ", " + "count=" + count+'\n');

        }

    );

    sb.append('\n');

    sb.append("MethodCosts:\n");

    methodCost.forEach(

        (method, costList) -> {

          IntSummaryStatistics stat = costList.stream().collect(Collectors.summarizingInt(x->x));

          String info = String.format("method=%s, sum=%d, avg=%d, max=%d, min=%d, count=%d", method,

(int)stat.getSum(), (int)stat.getAverage(), stat.getMax(), stat.getMin(), (int)stat.getCount());

          sb.append(info+'\n');

        }

    );


    sb.append('\n');

    sb.append("DetailOfMethodCosts:\n");

    methodCost.forEach(

        (method, costList) -> {

          String info = String.format("method=%s, cost=%s", method, costList);

          sb.append(info+'\n');

        }

    );

returnsb.toString();

  }

}

MethodMeasureAnnotation.java

packagezzz.study.aop;


importjava.lang.annotation.Documented;

importjava.lang.annotation.ElementType;

importjava.lang.annotation.Retention;

importjava.lang.annotation.RetentionPolicy;

importjava.lang.annotation.Target;


/**

 * 记录方法调用

 */

@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.METHOD)

@Documented

public@interface MethodMeasureAnnotation {


}

MapUtil.java

publicclassMapUtil {


publicstaticextendsComparablesuperV>> Map sortByValue(Map map) {

Map result =newLinkedHashMap<>();

    Stream<Map.Entry<K, V>> st = map.entrySet().stream();


    st.sorted(Comparator.comparing(e -> e.getValue())).forEach(e -> result.put(e.getKey(), e.getValue()));


returnresult;

  }


}

AOP基本概念

理解概念至关重要。优雅设计的框架,通常包含一组相互紧密关联的概念。这些概念经过精心抽象和提炼而成。 AOP的基本概念主要有:

Aspect:应用程序的某个模块化的关注点;通常是日志、权限、事务、打点、监控等。

JointPoint:连接点,程序执行过程中明确的点,一般是方法的调用。

Pointcut: 切点。指定施加于满足指定表达式的方法集合。Spring 默认使用 AspectJ pointcut 表达式。

Advance: 通知。指定在方法执行的什么时机,不同的Advance对应不同的切面方法;有before,after,afterReturning,afterThrowing,around。

TargetObject: 目标对象。通过Pointcut表达式指定的将被通知执行切面逻辑的实际对象。

AOP proxy: 代理对象。由AOP框架创建的代理,用于回调实际对象的方法,并执行切面逻辑。Spring实现中,若目标对象实现了至少一个接口,则使用JDK动态代理,否则使用 CGLIB 代理。优先使用JDK动态代理。

Weaving:织入。将切面类与应用对象关联起来。Spring使用运行时织入。通常 Pointcut 和 Advance 联合使用。即在方法上加上 @Advance(@Pointcut)

采用多种策略

@Around(“@annotation(zzz.study.aop.MethodMeasureAnnotation)”) 仅仅指定了在携带指定注解的方法上执行。实际上,可以指定多种策略,比如指定类,指定包下。可以使用逻辑运算符 || , && , ! 来指明这些策略的组合。 例如:

@Around("@annotation(zzz.study.aop.MethodMeasureAnnotation) "

          + "|| execution(* zzz.study.service.inner.BizOrderDataService.*(..))"

          + "|| execution(* zzz.study.core.service.*.*(..)) "

          + "|| execution(* zzz.study.core.strategy..*.*(..)) "

          + "|| execution(* zzz.study.core.report.*.generate*(..)) "

  )

指明了五种策略的组合: 带有 MethodMeasureAnnotation 注解的方法; BizOrderDataService 类的所有方法; zzz.study.core.service 下的所有类的方法; zzz.study.core.strategy 包及其子包下的所有类的方法;zzz.study.core.report 包下所有类的以 generate 开头的方法。

execution表达式

@Pointcut 中, execution 使用最多。 其格式如下:

1

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern)throws-pattern?)

括号中各个pattern分别表示:

修饰符匹配(modifier-pattern?)

返回值匹配(ret-type-pattern)可以为*表示任何返回值,全路径的类名等

类路径匹配(declaring-type-pattern?)

方法名匹配(name-pattern)可以指定方法名 或者 代表所有, set 代表以set开头的所有方法

参数匹配((param-pattern))可以指定具体的参数类型,多个参数间用“,”隔开,各个参数也可以用“”来表示匹配任意类型的参数,如(String)表示匹配一个String参数的方法;(,String) 表示匹配有两个参数的方法,第一个参数可以是任意类型,而第二个参数是String类型;可以用(..)表示零个或多个任意参数

异常类型匹配(throws-pattern?)其中后面跟着“?”的是可选项。

何时不会被通知

并不是满足 pointcut 指定条件的所有方法都会执行切面逻辑。 如果类 C 有三个公共方法,a,b,c ; a 调用 b ,b 调用 c 。会发现 b,c 是不会执行切面逻辑的。这是因为Spring的AOP主要基于动态代理机制。当调用 a 时,会调用代理的 a 方法,也就进入到切面逻辑,但是当 a 调用 b 时, b 是直接在目标对象上执行,而不是在代理对象上执行,因此,b 是不会进入到切面逻辑的。总结下,如下情形是不会执行切面逻辑的:

被切面方法调用的内部方法;

final 方法;

private 方法;

静态方法。

可参阅参考文献的 “8.6.1 Understanding AOP proxies”

However, once the call hasfinallyreached the target object, the SimplePojo reference inthiscase, any method calls that it may make on itself, such asthis.bar() orthis.foo(), are going to be invoked against thethisreference, and not the proxy. This has important implications. It means that self-invocation is not going to result in the advice associated with a method invocation getting a chance to execute.


Okay, so what is to be done aboutthis? The best approach (the term best is used loosely here) is to refactor your code such that the self-invocation does not happen. For sure,thisdoes entail some work on your part, but it is the best, least-invasive approach.

其含义是说,a, b 都是类 C 的方法,a 调用了 b ;如果需要对 b 方法进行切面,那么最好能将 b 抽离出来放在类D的公共方法中,因为 b 是一个需要切面关注点的重要方法。

再比如,排序方法实现为静态方法 DefaultReportItemSorter.sort ,这样是不能被通知到切面的。需要将 DefaultReportItemSorter 改为组件 @Component 注入到依赖的类里, 然后将 sort 改为实例方法。

运行数据分析

运行结果

导出订单数处于[11500,12000]区间的一次运行结果截取如下:

// ...

method=BatchOrderDetailService_getAllOrderDetails, count=23

method=GoodsDimensionExportStrategy_generateReportDatas, count=23

method=BizOrderDataService_generateFinalReportItemList, count=23

method=CsvFileOutputStrategy_output, count=23

method=BaseReportService_appendItemsReportCommon, count=23

method=ExportStrategyFactory_getExportDimensionStrategy, count=24

method=FileService_appendFile, count=24

method=ExportStrategyFactory_getOutputStrategy, count=25

method=BatchGetInfoService_batchGetAllInfo, count=46

method=HAHbaseService_getRowsWithColumnPrefixFilter, count=92

method=HAHbaseService_scanByPrefixFilterList, count=115


MethodCosts:

method=BatchOrderDetailService_getAllOrderDetails, sum=12684, avg=551, max=727, min=504, count=23

method=ReportService_generateReportForExport, sum=46962, avg=46962, max=46962, min=46962, count=1

method=DbOperation_updateExportRecord, sum=63, avg=63, max=63, min=63, count=1

method=HAHbaseService_scanByPrefixFilterList, sum=1660, avg=14, max=115, min=3, count=115

method=GoodsDimensionExportStrategy_generateReportDatas, sum=6764, avg=294, max=668, min=165, count=23

method=BatchGetInfoService_batchGetAllInfo, sum=14885, avg=323, max=716, min=0, count=46

method=CsvFileOutputStrategy_appendHeader, sum=23, avg=23, max=23, min=23, count=1

method=BaseReportService_appendHeader, sum=60, avg=60, max=60, min=60, count=1

method=BizOrderDataService_generateFinalReportItemList, sum=37498, avg=1630, max=4073, min=1326, count=23

method=ExportStrategyFactory_getOutputStrategy, sum=35, avg=1, max=35, min=0, count=25

method=HAHbaseService_getRowsWithColumnPrefixFilter, sum=3709, avg=40, max=112, min=23, count=92

method=BaseReportService_appendItemReport, sum=46871, avg=46871, max=46871, min=46871, count=1

method=FileService_uploadFileWithRetry, sum=138, avg=138, max=138, min=138, count=1

method=GeneralEsSearchService_search, sum=4470, avg=4470, max=4470, min=4470, count=1

method=CsvFileOutputStrategy_generateReportFile, sum=57, avg=57, max=57, min=57, count=1

method=SerialExportStrategy_appendItemReport, sum=46886, avg=46886, max=46886, min=46886, count=1

method=CsvFileOutputStrategy_output, sum=2442, avg=106, max=311, min=39, count=23

method=CommonService_getGeneralEsSearchService, sum=23, avg=23, max=23, min=23, count=1

method=BaseReportService_appendItemsReportCommon, sum=46818, avg=2035, max=5033, min=1655, count=23

method=CommonJobFlow_commonRun, sum=52638, avg=52638, max=52638, min=52638, count=1

method=DefaultReportItemSorter_sort, sum=304, avg=13, max=80, min=2, count=23

method=FileService_getExportFile, sum=29, avg=29, max=29, min=29, count=1

method=FileService_createFile, sum=1, avg=1, max=1, min=1, count=1

method=FileService_appendFile, sum=213, avg=8, max=69, min=2, count=24

method=GoodsDimensionExportStrategy_generateColumnTitles, sum=15, avg=15, max=15, min=15, count=1


DetailOfMethodCosts:

method=BatchOrderDetailService_getAllOrderDetails, cost=[727, 562, 533, 560, 544, 527, 526, 541, 531, 526, 556, 534, 554, 537, 567, 576, 562, 531, 562, 533, 522, 569, 504]

method=HAHbaseService_scanByPrefixFilterList, cost=[115, 54, 34, 12, 13, 36, 31, 19, 7, 6, 21, 18, 10, 6, 4, 24, 16, 13, 7, 8, 39, 17, 10, 9, 11, 21, 18, 9, 6, 8, 23, 17, 9, 10, 8, 24, 15, 11, 5, 6, 19, 15, 11, 5, 8, 21, 18, 9, 10, 10, 19, 16, 10, 5, 6, 24, 16, 6, 7, 5, 22, 17, 8, 12, 9, 19, 19, 8, 11, 8, 19, 36, 6, 6, 4, 20, 19, 6, 4, 4, 20, 17, 10, 7, 3, 20, 17, 4, 5, 7, 20, 16, 7, 4, 4, 37, 32, 4, 5, 3, 17, 14, 6, 9, 6, 18, 48, 6, 4, 3, 20, 16, 8, 7, 9]

method=GoodsDimensionExportStrategy_generateReportDatas, cost=[668, 383, 369, 543, 438, 272, 222, 231, 238, 311, 310, 297, 296, 165, 253, 217, 211, 222, 211, 185, 234, 221, 267]

method=BatchGetInfoService_batchGetAllInfo, cost=[716, 103, 562, 103, 533, 101, 559, 100, 544, 101, 526, 101, 525, 101, 541, 101, 530, 100, 525, 103, 556, 100, 534, 100, 554, 101, 537, 100, 567, 101, 576, 101, 562, 100, 531, 101, 562, 100, 530, 0, 522, 101, 569, 100, 504, 101]

method=BizOrderDataService_generateFinalReportItemList, cost=[4073, 1895, 1668, 1713, 1687, 1498, 1606, 1534, 1476, 1505, 1499, 1578, 1493, 1433, 1515, 1488, 1406, 1438, 1459, 1416, 1326, 1457, 1335]

method=HAHbaseService_getRowsWithColumnPrefixFilter, cost=[86, 49, 40, 112, 35, 33, 33, 72, 32, 30, 30, 78, 31, 30, 29, 83, 70, 28, 29, 81, 30, 28, 28, 91, 26, 28, 24, 109, 30, 29, 26, 56, 27, 29, 28, 54, 26, 27, 23, 61, 27, 28, 24, 57, 25, 27, 26, 107, 28, 28, 26, 59, 41, 36, 25, 54, 43, 23, 23, 59, 34, 31, 30, 63, 29, 32, 28, 54, 31, 27, 27, 61, 28, 33, 26, 64, 36, 47, 26, 62, 27, 26, 24, 50, 26, 23, 24, 47, 28, 29, 25, 54]

// ...

耗时占比

总耗时 52638 ms , 报表生成部分 46962 ms (89.2%),ES 查询订单部分 4470 ms (8.5%) , 其他 1206 ms (2.3%) 。

报表生成部分:每批次的“报表数据生成+格式化报表行数据+写入文件” appendItemsReportCommon 耗时 46818 ms ;批次切分及进度打印耗时 53ms;报表报表头写入文件 60 ms;appendItemsReportCommon ≈ generateFinalReportItemList + generateReportDatas + output ;

报表生成部分(★):批量详情API接口 getAllOrderDetails 耗时 12684 ms (24%), 除详情API之外的其他详情数据拉取耗时37498-12684=24814 ms (47%) ,获取所有订单的详情数据 generateFinalReportItemList 总占比 71%。

报表生成部分(2) : 生成报表行数据 generateReportDatas 耗时 6764 ms (12.9%), 写入文件 output 耗时 2442 ms (4.6%)。

耗时细分

这里部分方法的调用次数取决于获取订单详情时对keyList的切分策略。方法调用耗时是值得关注的点。重点关注耗时区间。

访问ES的耗时平均 4062 ~ 4798ms。随着要查询的订单数线性增长。

批量调用详情API 的平均 532 ~ 570 ms (5个并发)。

Hbase 访问的 getRowsWithColumnPrefixFilter 平均 40 ~ 45 ms , scanByPrefixFilterList 平均 10 ~ 15 ms 。注意,这并不代表 batchGet 的性能比 scanByPrefixFilterList 差。 因为 batchGet 一次取 500 个 rowkey 的数据,而 scanByPrefixFilterList 为了避免超时一次才取 200 个 rowkey 数据。

appendItemsReportCommon: 平均 1996 ~ 2304 ms 。这个方法是生成报表加格式化报表字段数据加写文件。可见,格式化报表字段数据的耗时还是比较多的。

generateReportDatas: 报表字段格式化耗时,平均 294 ms。

output:向报表追加数据耗时,平均 104 ms。

更新数据库操作平均 40 ~ 88ms。

创建和写文件的操作平均 6~15 ms 。 append 内容在 100KB ~ 200KB 之间。

上传文件的操作平均 151~279 ms 。整个文件的上传时间,取决于文件的大小。

注意到,以上是串行策略下运行的结果。也就是所有过程都是顺序执行的。顺序执行策略的优点是排除并发干扰,便于分析基本耗时。

在多线程情形下,单个IO操作会增大,有时会达到 1s ~ 3s 左右。此时,很容易造成线程阻塞,进而影响系统稳定性。

小结

通过方法调用的次数统计及耗时分析,更清晰地理解了一个导出请求的总执行流程及执行耗时占比,为性能和稳定性优化提供了有力的数据依据。

欢迎工作一到五年的Java工程师朋友们加入Java程序员开发: 854393687

群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用自己每一分每一秒的时间来学习提升自己,不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容

  • 5℃的空气 橘子味的香臂 少女光滑的裸背 蓬松柔发的丝丝抚触 零星点点 呼吸也变得纯粹
    杜杜儿阅读 87评论 0 0
  • (西格弗里德·伦茨的另两部作品。这位在德国与君特·格拉斯享有同样文学地位的作家的作品,每一本都值得一读再读) 书的...
    吴玫阅读 334评论 0 1
  • 独立于瓦屋纸窗前 看绵绵雨丝随着风轻轻地洒落 雨中的江南如同江南的女子 素雅而不失芳香 带着雨的丝丝柔情 静静地走...
    雪恋心痕阅读 215评论 1 4
  • 终于可以坐下来休息一下了,连续一个星期的作战,让我深感疲惫,直到这个时间才算是完全静下心总结总结 晚上十二点睡,早...
    一个人一个视界阅读 257评论 0 0
  • 地图高级- 大头针基本使用 1. 大头针 现实生活中的大头针(左图)大头针1.png 地图上的大头针(右图)钉在某...
    翻这个墙阅读 3,502评论 0 0