美文网首页APM框架分析
Argus-APM 简单分析

Argus-APM 简单分析

作者: David_zhou | 来源:发表于2020-04-15 10:27 被阅读0次

    前面部分使用的是argusApm的官方介绍

    简介

    argus-Apm是首个开源的APM框架,由360团队在18年底开源。众所周知,apm包括采集端和分析端,此次开源只开源了采集端,上传以及分析功能可以使用360的DC平台,不过后续DC平台不对新的应用开放,因此需要自己实现上传和分析功能。不过对学习APM来说,和android开发者关系最密切的采集端开源,因此并不影响我们学习apm框架。

    监控模块

    ArgusAPM目前支持如下性能指标:

    • 交互分析:分析Activity生命周期耗时,帮助提升页面打开速度,优化用户UI体验;
    • 网络请求分析:监控流量使用情况,发现并定位各种网络问题;
    • 内存分析:全面监控内存使用情况,降低内存占用;
    • 进程监控:针对多进程应用,统计进程启动情况,发现启动异常(耗电、存活率等);
    • 文件监控:监控APP私有文件大小/变化,避免私有文件过大导致的卡顿、存储空间占用等问题;
    • 卡顿分析:监控并发现卡顿原因,代码堆栈精准定位问题,解决明显的卡顿体验;
    • ANR分析:捕获ANR异常,解决APP的“未响应”问题。

    ArgusAPM特性

    非侵入式

    无需修改原有工程结构,无侵入接入,接入成本低。

    无性能损耗

    ArgusAPM针对各个性能采集模块,优化了采集时机,在不影响原有性能的基础上进行性能的采集和分析。

    监控全面

    目前支持UI性能、网络性能、内存、进程、文件、卡顿、ANR等各个维度的性能数据分析,后续还会继续增加新的性能维度。

    Debug模式

    独有的Debug模式,支持开发和测试阶段、实时采集性能数据,实时本地分析的能力,帮助开发和测试人员在上线前解决性能问题。

    支持插件化方案

    在初始化阶段进行设置,可支持插件接入,目前360手机卫士采用的就是在RePlugin插件中接入ArgusAPM,并且性能方面无影响。

    支持多进程采集

    针对多进程的情况,我们做了相应的数据采集及优化方案,使ArgusAPM即适合单进程APP也适合多进程APP。

    节省用户流量

    ArgusAPM使用wifi状态下上传性能数据,这样避免了频繁网络请求带来的耗电问题及用户流量的消耗。

    ArgusAPM项目结构图

    5c1678c47a8dd.png

    整体架构分为两部分:一是左边蓝色的部分:性能采集模块,一是右边的绿色部分:Gradle Plugin模块。

    下面分别针对这两部分做简单的介绍:

    1.性能采集模块

    该模块总共分为五个Module,并最终生成三个aar文件,即:

    argus-apm-main.aar:APM项目的核心业务模块

    argus-apm-aop.aar:AOP代码的织入模块

    argus-apm-okhttp.aar:采集OKHTTP网络性能

    其中之所以拆分那么多的模块,是为了能够让我们可插拔式的去使用里面的功能,例如,如果我项目中没有使用OKHTTP相关的功能,那么我们就可以关闭相应的依赖。

    2.Gradle Plugin模块

    该模块主要具备两个作用:

    支持AOP编程,方便ArgusAPM能够在编译期织入一些性能采集的代码;

    通过Gradle插件来管理依赖库,使用户接入ArgusAPM更简单。

    5c1679116ee15.png

    启动及关键类

    下面以argus-apm-sample来分析这个apm框架

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        boolean isUi = TextUtils.equals(getPackageName(), ProcessUtils.getCurrentProcessName());
        Config.ConfigBuilder builder = new Config.ConfigBuilder()
                .setAppContext(this)
                .setRuleRequest(new RuleSyncRequest())
                .setUpload(new CollectDataSyncUpload())
                .setAppName("apm_demo")
                .setAppVersion("1.0.0")
                .setApmid("xxxxxxxx");//该ID是在APM的后台进行申请的
        //单进程应用可忽略builder.setDisabled相关配置。
        if (!isUi) { //除了“主进程”,其他进程不需要进行数据上报、清理等逻辑。“主进程”通常为常驻进行,如果无常驻进程,即为UI进程。
            builder.setDisabled(ApmTask.FLAG_DATA_CLEAN)
                    .setDisabled(ApmTask.FLAG_CLOUD_UPDATE)
                    .setDisabled(ApmTask.FLAG_DATA_UPLOAD)
                    .setDisabled(ApmTask.FLAG_COLLECT_ANR)
                    .setDisabled(ApmTask.FLAG_COLLECT_FILE_INFO);
        }
        //builder.setEnabled(ApmTask.FLAG_COLLECT_ACTIVITY_AOP); //activity采用aop方案时打开,默认关闭即可。
        builder.setEnabled(ApmTask.FLAG_LOCAL_DEBUG); //是否读取本地配置,默认关闭即可。
        Client.attach(builder.build());
        Client.isDebugOpen(true);//设置成true的时候将会打开悬浮窗
        Client.startWork();
    }
    

    很明显ConfigBuilder是采用构造器模式,进行各种设置之后最后调用builder返回一个Config对象。然后将这个对象传递给Client的attach方法,Client这个是ArgusAPM外部调用接口(包含配置、初始化等调用)。在attach方法中会调用Manager的init. Manager是ArgusAPM的管理类,集成管理各种task。在init()会调用registerTask对添加的task进行注册。最后在attachBaseContext中调用Client的startWork开始监控。

     // 注册 task:每添加一个task都要进行注册
        public void registerTask() {
            if (Build.VERSION.SDK_INT >= 16) {
                taskMap.put(ApmTask.TASK_FPS, new FpsTask());
            }
            taskMap.put(ApmTask.TASK_MEM, new MemoryTask());
            taskMap.put(ApmTask.TASK_ACTIVITY, new ActivityTask());
            taskMap.put(ApmTask.TASK_NET, new NetTask());
            taskMap.put(ApmTask.TASK_APP_START, new AppStartTask());
            taskMap.put(ApmTask.TASK_ANR, new AnrLoopTask(Manager.getContext()));
            taskMap.put(ApmTask.TASK_FILE_INFO, new FileInfoTask());
            taskMap.put(ApmTask.TASK_PROCESS_INFO, new ProcessInfoTask());
            taskMap.put(ApmTask.TASK_BLOCK, new BlockTask());
            taskMap.put(ApmTask.TASK_WATCHDOG, new WatchDogTask());
        }
    

    task

    task是apm中各个采集任务的细化,分为FpsTask,MemoryTask,ActivityTask等。类图如下


    1570700662279.png

    ITask接口定义各种能力。代码如下

    public interface ITask {
        String getTaskName();
        void start();  // 开始执行task
        boolean isCanWork();  
        void setCanWork(boolean value); 
        boolean save(IInfo info);// 保存task相关的信息
        void stop(); // 停止执行task
    }
    

    BaseTask是task任务基类,新增了抽象方法getStorage。返回实现IStorage接口的对象,因为每个task产生的信息不一样,所以具体的IStorage对象由具体的task自己实现。比如MemoryTask的getStorage方法就返回一个MemStorage对象。

    IStorage

    IStorage接口定义了数据增删改查中间操作。

    public interface IStorage {
        String getName();
        IInfo get(Integer id);
        boolean save(IInfo data);  // 保存数据
        int deleteByTime(long time);// 根据时间删除数据
        boolean delete(Integer id); // 按照一条id删除数据
        boolean update(Integer id, ContentValues cv); // 按照一条id更新数据
        List<IInfo> getAll();
        List<IInfo> getData(int index, int count);
        boolean clean();  // 清除数据
        boolean cleanByCount(int count);// 清除count条数数据
        Object[] invoke(Object... args);
    }
    

    TableStorage这个抽象类实现了IStorage,并且实现了主要的函数。主要是增删改除,但因为各个task产生的数据类型不一致,所以数据库保存的数据也会不一样。要想实现统一的数据库读取操作,就需要子类提供具体的实现,因此TableStorage新增了readDb这个抽象方法,实际的操作由TableStorage的子类提供。storage相关的类图如下:


    4.png

    Table

    因为apm数据是实时采集,但并不是实时上传,所以需要数据库。但每个采集的task都不一样,产生的数据格式也不一样。为了存储的效率,比较合适的是针对各个数据都单独创建表。创建表的类都实现了ITable。ITable的代码如下:

    public interface ITable {
        String createSql();// 创建表
        String getTableName(); // 返回表的名字
    }
    

    table相关的类图如下:


    5.png

    BaseInfo

    上面讲到了apm采集的数据存在本地,因为采集的数据不一样,所以用不同的表来保存。下面来看下具体的数据,首先从IInfo这个接口开始看,代码如下:

    public interface IInfo extends Serializable {
        // 目前是数据库自增字段
        int getId();
        JSONObject toJson() throws JSONException;
        void parserJsonStr(String json) throws JSONException;
        void parserJson(JSONObject json) throws JSONException;
        ContentValues toContentValues();
    }
    

    接口定义了各种能力,IInfo主要包含了与json相关的转化能力。具备这些能力的一个可能原因是满足数据存储在本地以及数据传输的要求。BaseInfo实现了IInfo接口,是采集到各种数据的基类,BaseInfo的类图如下:


    6.png

    BaseInfo的子类根据采集到的数据格式,各自实现了json和string的转换。

    至此,我们从大体上分析了几个主要的类及其子类,包括采集数据的Task,保存数据的BaseInfo,创建表的Table.以及读取数据的TableStorage类。


    7.png

    数据是APM框架的核心,而数据采集又是其中的重点。因为下面分析各个采集数据的task。

    数据采集

    argus-apm采集到的数据有activity,anr,appstart,block,fileinfo,fps,memory,net,processinfo,watchdog。apm采集到的数据越多,越能准确刻画APP的性能,下面简单介绍下数据采集的原理。

    Block

    block能采集到程序执行慢的地方,从TaskManager中启动,最主要的方法是start(),代码如下:

    @Override
    public void start() {
        super.start();
        if (!mBlockThread.isAlive()) { //防止多次调用
            mBlockThread.start();
            mHandler = new Handler(mBlockThread.getLooper());
            Looper.getMainLooper().setMessageLogging(new Printer() {
                private static final String START = ">>>>> Dispatching";
                private static final String END = "<<<<< Finished";
                @Override
                public void println(String x) {
                    if (x.startsWith(START)) {
                        startMonitor();
                    }
                    if (x.startsWith(END)) {
                        removeMonitor();
                    }
                }
            });
        }
    }
    

    最重要的是 Looper.getMainLooper().setMessageLogging(),通过setMessageLogging给主线程设置一个自定义的Printer。这个Printer的println什么时候调用呢?答案是在Looper的loop()方法中。looper()的关键代码如下:

    public static void loop() {
        for (;;) {
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }
            try {
                msg.target.dispatchMessage(msg);
                end = (slowDispatchThresholdMs == 0) ? 0 : SystemClock.uptimeMillis();
            } finally {
            }
            if (logging != null) {
                logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
            }
            }
        }
    }
    

    在消息执行之前,通过logging.println()打印“>>>>> Dispatching”开头的日志,消息执行结束打印“<<<<< Finished”。然后我们自定义的Printer,在消息开始执行的是执行startMonitor,消息结束时调用removeMonitor。这里和BlockCanary类似的原理,在startMonitor中延迟一个特定值,发送一个采集执行堆栈的任务,然后在removeMonitor中取消执行。如果消息执行的事件超过了延迟的时间,则可以认为发生了block, 这个时候就需要采集函数执行堆栈了。采集之后就将卡顿的堆栈保存下来。

    FileInfo

    FileInfoTask会间隔一个时间去遍历sd卡特定目录下的文件大小,目录由apm的config文件确定。之后在config有文件目录的参数情况下遍历data特定的目录,如果没有参数,则默认取数据库信息。 读取文件的信息,比如大小,名字,文件类型等信息,然后根据config中的参数过滤特定大小的文件,然后将文件信息保存在数据库。

    Fps

    需要注意的是FpsTask继承了BaseTask的同时,还实现了Choreographer.FrameCallback接口中的doFrame()方法。在start()中往线程池中发送一个runable,然后向Choreographer注册一个callback.在下一帧的时候回调doFrame,不过这个callback是一次性的,回调之后就被自动移除。因此在接收到callback的doframe回调时需要不断将自身注册到choreographer.采集fps的框架有TinyDancer.

    @Override
        public void doFrame(long frameTimeNanos) {
            mFpsCount++;
            mFrameTimeNanos = frameTimeNanos;
            if (isCanWork()) {
                //注册下一帧回调
                Choreographer.getInstance().postFrameCallback(this);
            } else {
                mCurrentCount = 0;
            }
        }
    

    因为计算帧率需要计算一段时间内的帧数,因此在接收到doframe回调时需要累积帧数。

    计算帧率的关键代码在上面提到的runnable中,代码如下:

    private void calculateFPS() {
            if (mLastFrameTimeNanos == 0) {
                mLastFrameTimeNanos = mFrameTimeNanos;
                return;
            }
            float costTime = (float) (mFrameTimeNanos - mLastFrameTimeNanos) / 1000000.0F;
            if (mFpsCount <= 0 && costTime <= 0.0F) {
                return;
            }
            int fpsResult = (int) (mFpsCount * 1000 / costTime);
            if (fpsResult < 0) {
                return;
            }
         if (fpsResult <= TaskConfig.DEFAULT_FPS_MIN_COUNT) { //如果发生掉帧,则保存数据
                fpsInfo.setFps(fpsResult);
                try {
                    paramsJson.put(FpsInfo.KEY_STACK, CommonUtils.getStack());//获取卡顿时的堆栈
                } catch (JSONException e) {
                    e.printStackTrace();
                }
                fpsInfo.setParams(paramsJson.toString());
                fpsInfo.setProcessName(ProcessUtils.getCurrentProcessName());
                save(fpsInfo); //保存数据
            }
        …….       
            mLastFrameTimeNanos = mFrameTimeNanos;
            mFpsCount = 0;
        }
    

    fps的含义是每秒的帧数,因此计算公式是:fps=总帧数/时间,需要注意时间的换算。

    Memory

    MemoryTask是内存收集处理类,我们简单看下主要的函数getMemoryInfo(),代码如下:

    /**
         * 获取当前内存信息
         */
        private MemoryInfo getMemoryInfo() {
            // 注意:这里是耗时和耗CPU的操作,一定要谨慎调用
            Debug.MemoryInfo info = new Debug.MemoryInfo();
            Debug.getMemoryInfo(info);
            return new MemoryInfo(ProcessUtils.getCurrentProcessName(), info.getTotalPss(), info.dalvikPss, info.nativePss, info.otherPss);
        }
    

    通过Debug类的getMemoryInfo来获取当前的内存信息,最终是native实现。Debug这个类很有用,我们可以看下主要的方法,会有意想不到的收获。比如启动时等待调试就是调用的waitForDebugger方法。获取当前的虚拟机信息可以通过getVmFeatureList方法。另外因为这个比较耗时和耗cpu,所以谨慎调用,这个task的其他方法也是在执行这个逻辑来降低采集的次数和消耗。

    Processinfo

    采集的是进程的启动次数,不是很懂这个数据的意义。

    Watchdog

    WatchDogTask做的事和blockTask类似,都是卡顿检测,不过采用的另外的思路。主要逻辑在其中的runnable中,代码如下:

    private Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (null == mHandler) {
                    Log.e(TAG, "handler is null");
                    return;
                }
    
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        mTick++;
                    }
                });
    
                try {
                    Thread.sleep(DELAY_TIME);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
    
                if (TICK_INIT_VALUE == mTick) {
                    String stack = captureStacktrace();
                    saveWatchdogInfo(stack);
                } else {
                    mTick = TICK_INIT_VALUE;
                }
    
                AsyncThreadTask.getInstance().executeDelayed(runnable, ArgusApmConfigManager.getInstance().getArgusApmConfigData().funcControl.getWatchDogIntervalTime());
            }
        };
    

    主要思路是往主线程中post一个任务,对一个变量mTick执行自加操作。然后在当前线程中sleep一段时间,然后再去检测mTick.如果主线程没有卡顿,那么自加操作肯定会得到执行,这时候mTick就不会与初始值相等。如果相等就可以认为这个等待时间里面,主线程发生了卡顿,这个时候就采集数据。采集的数据主要是堆栈,其实包括cpu数据会更加全面。

    Anr

    发生ANR时都会在data/anr下产生trace文件,因此anr的检测就以trace文件为核心。argusApm提供了两种思路,一种是通过Fileobserver的方式监听这个目录下。另一种是定时采样的方式。默认采用的是定时采样的方式,两种方式可以自己切换。定时采样的逻辑是不断去轮询,如果当前连接上wifi,就读取anr目录。使用wifi的判断逻辑初步猜测是因为发生anr后需要上传trace,为了省流量的原因。在发现有trace文件初步判断之后还会做相关的解析,包括是否上传过,是否是当前进行,是否是无效的文件。通过trace文件来判断anr,其实还有个问题,如果没有权限就拿不到trace文件。这里并没有看到有解决方案。

    Net

    NetTask并没有什么代码,很是诡异。但网络数据的采集常规方法很难做到,需要采集到每次请求,但是不能在请求的前后进行操作。仔细看net下面的子文件夹,发现有文件的名字是以aop开头的,原来网络数据是通过aop的形式采集的。下面的代码是plugin中的代码:

    if (owner == NetConstans.HTTPCLIENT && name == NetConstans.EXECUTE) {
                when (desc) {
                    NetConstans.REQUEST -> {
                        mv.visitMethodInsn(INVOKESTATIC,
                                "com/argusapm/android/core/job/net/i/QHC",
                                "execute",
                                "(Lorg/apache/http/client/HttpClient;Lorg/apache/http/client/methods/HttpUriRequest;)Lorg/apache/http/HttpResponse;",
                                false);
                    }
                    NetConstans.REQUEST_CONTEXT -> {
                        mv.visitMethodInsn(INVOKESTATIC,
                                "com/argusapm/android/core/job/net/i/QHC",
                                "execute",
                                "(Lorg/apache/http/client/HttpClient;Lorg/apache/http/client/methods/HttpUriRequest;Lorg/apache/http/protocol/HttpContext;)Lorg/apache/http/HttpResponse;",
                                false);
                    }
                    else -> super.visitMethodInsn(opcode, owner, name, desc, itf)
                }
            }
    

    通过asm的方式实现了对HttpClient的execute方法的代理。

    public class QHC {
        public static HttpResponse execute(HttpClient client, HttpUriRequest request) throws IOException {
            return isTaskRunning()
                    ? AopHttpClient.execute(client, request)
                    : client.execute(request);
        }
    
        public static HttpResponse execute(HttpClient client, HttpHost host, HttpRequest request, HttpContext context) throws IOException {
            return isTaskRunning()
                    ? AopHttpClient.execute(client, host, request, context)
                    : client.execute(host, request, context);
        }
    }
    

    如果网络数据需要监控,则调用AopHttpClient来代替原来的HttpClient执行方法。代码如下:

     public static HttpResponse execute(HttpClient httpClient, HttpUriRequest request) throws IOException {
            NetInfo data = new NetInfo();
            HttpResponse response = httpClient.execute(handleRequest(request, data));
            handleResponse(response, data);
            return response;
        }
    

    在发起网络请求前调用handleRequest处理request,然后调用handleResponse处理response.这样一来,每个HttpClient发起的请求的数据都能被记录下来。

    Activity

    activity的启动时间常规方式不好采集,argusapm采用的方式hook的方式,这里采用的是hook Instrumentation这个类。Hook的代码如下:

    private static void hookInstrumentation() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, NoSuchFieldException {
            Log.e(TAG, "  hookInstrumentation: ");
            Class<?> c = Class.forName("android.app.ActivityThread");
            Method currentActivityThread = c.getDeclaredMethod("currentActivityThread");
            boolean acc = currentActivityThread.isAccessible();
            if (!acc) {
                currentActivityThread.setAccessible(true);
            }
            Object o = currentActivityThread.invoke(null);
            if (!acc) {
                currentActivityThread.setAccessible(acc);
            }
            Field f = c.getDeclaredField("mInstrumentation");
            acc = f.isAccessible();
            if (!acc) {
                f.setAccessible(true);
            }
            Instrumentation currentInstrumentation = (Instrumentation) f.get(o);
            Instrumentation ins = new ApmInstrumentation(currentInstrumentation);
            f.set(o, ins);
            if (!acc) {
                f.setAccessible(acc);
            }
        }
    

    获取activity的启动时间代码如下:

     @Override
        public void callActivityOnCreate(Activity activity, Bundle icicle) {
        …..
            long startTime = System.currentTimeMillis();
            if (mOldInstrumentation != null) {
                mOldInstrumentation.callActivityOnCreate(activity, icicle);
            } else {
                super.callActivityOnCreate(activity, icicle);
            }
            ActivityCore.startType = ActivityCore.isFirst ? ActivityInfo.COLD_START : ActivityInfo.HOT_START;
            ActivityCore.onCreateInfo(activity, startTime);
        }
    

    onCreateInfo的代码如下:

    public static void onCreateInfo(Activity activity, long startTime) {
            startType = isFirst ? ActivityInfo.COLD_START : ActivityInfo.HOT_START;
            activity.getWindow().getDecorView().post(new FirstFrameRunnable(activity, startType, startTime));
            //onCreate 时间
            long curTime = System.currentTimeMillis();
            saveActivityInfo(activity, startType, curTime - startTime, ActivityInfo.TYPE_CREATE);
        }
    

    FirstFrameRunnable的run方法代码如下:

    @Override
            public void run() {
                if ((System.currentTimeMillis() - startTime) >= ArgusApmConfigManager.getInstance().getArgusApmConfigData().funcControl.activityFirstMinTime) {
                    saveActivityInfo(activity, startType, System.currentTimeMillis() - startTime, ActivityInfo.TYPE_FIRST_FRAME);
                }
                //保存应用冷启动时间
                if (ActivityCore.isFirst) {
                    ActivityCore.isFirst = false;
                    if (ActivityCore.appAttachTime <= 0) {
                        return;
                    }
                    int t = (int) (System.currentTimeMillis() - ActivityCore.appAttachTime);
                    AppStartInfo info = new AppStartInfo(t);
                    ITask task = Manager.getInstance().getTaskManager().getTask(ApmTask.TASK_APP_START);
                    if (task != null) {
                        task.save(info);
                        if (AnalyzeManager.getInstance().isDebugMode()) {
                            AnalyzeManager.getInstance().getParseTask(ApmTask.TASK_APP_START).parse(info);
                        }
                    } 
                    }
                }
            }
    

    首先通过activity.getWindow().getDecorView().post的方式计算首帧时间,原来首帧时间可以这样计算。然后记录oncreate的事件。其他生命周期的耗时比较类似,下面就看一个start的事件。

    @Override
        public void callActivityOnStart(Activity activity) {
        ……
            long startTime = System.currentTimeMillis();
            if (mOldInstrumentation != null) {
                mOldInstrumentation.callActivityOnStart(activity);
            } else {
                super.callActivityOnStart(activity);
            }
            ActivityCore.saveActivityInfo(activity, ActivityInfo.HOT_START, System.currentTimeMillis() - startTime, ActivityInfo.TYPE_START);
        }
    

    原理比较类似,都是在执行生命周期之前记录事件,然后记录执行之后的事件,两者相减就得到了该生命周期对应的时间。其实这种方法会对启动速度有点影响,因为记录的数据会保存到数据库。

    Appstart

    App的启动时间有多种衡量标准,在argusapm中采用的是application的启动到第一个activity的创建结束。因为已经hook了Instrumentation,因此在Instrumentation的callApplicationOnCreate方法执行时记录下application的启动时间,然后callActivityOnCreate记录下第一个activity的启动即可获得冷启动的启动时间。

    总结

    至此采集的task大体上都分析了,除了net和activity的采集,原理并不难。只是细节很多,毕竟apm是在线上运行的,所以需要控制的很精细化才能对用户影响最小。采集的task都是由Manager来统一管理,包括启动,停止,设置参数等。而相关的配置项在Config类中保存。配置可以有本地默认配置,另外也可以从云端动态下发配置。另外apm的数据上传需要自己实现,展示分析的逻辑和配置的下发也是需要自己实现。

    技术有限,闻过则喜,请指正。
    感谢360团队的开源和技术文章,https://github.com/Qihoo360/ArgusAPM

    相关文章

      网友评论

        本文标题:Argus-APM 简单分析

        本文链接:https://www.haomeiwen.com/subject/itmqvhtx.html