动态追踪技术底层分析(字节码插桩)

1. 动态追踪技术

动态追踪技术是可以不用重启线上java项目来进行问题排查的技术。比如Arthas就属于一种动态追踪工具,它提供的monitor, trace, watch命令就是用动态追踪技术实现的。
Arthas工具的基础,就是Java Agent技术,可以利用他来构建一个附加的代理程序,用来协助检测性能,还可以替换一些现有的功能,甚至jdk的类也能修改,就像JVM级别的AOP功能。
我们使用Arthas工具,觉得它很强大,对于性能调优它能提供不少帮助,那么阿里是怎么实现的呢?当我们研究技术到一定深度的时候我们就不能满足于使用了,我会思考它是怎么实现的,我们在工作中能不能开发出类似的辅助工具来帮助自己和同事呢?我们来试试吧。

2.Java Agent技术

Java agent是java1.5后引入的新特性,其主要作用是在class被加载前对其拦截,插入我们监听的字节码,也叫字节码插桩。作为JVM的AOP,就需要有AOP的功能,Java Agent提供了两个类似AOP的功能。

  • premain: 可以在main运行之前进行一些操作(Java的入口是main方法)
  • agentmain: 控制类运行时的行为(Arthas使用的就是这种)

在JVM中,只会调用其中一个。
要构建一个agent程序,大体可以分为以下步骤:

  • 1.使用字节码增加工具,编写增强代码
  • 2.在manifest中指定Premain-Class/Agent-Class属性
  • 3.使用参数加载或者使用attach方式改变app项目中的内容。

我们这里用的字节码增强工具是javassist,它的工作流程如下

2.1 Premain

Premain是在JVM加载类之前拦截并修改字节码,不能热修改。
我们先来玩一个Premain的实例

2.1.1 编写Agent

Java Agent体现方式是一个jar包,我们创建一个maven工程/Maven module,打包成一个jar包即可。我这里把premain代码写成一个Maven module, 它的测试代码放在另一个module(app module)下


加入javassist依赖, 我们需要用javassist完成字节码增强

<dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
        </dependency>
    </dependencies>

创建一个简单的打印信息的测试类

package com.sandwich.premain.test;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 * vm options:
 * -javaagent:D:\MavenRepository\org\sandwich\premain\1.0-SNAPSHOT\premain-1.0-SNAPSHOT.jar
 * -Xbootclasspath/a:D:\MavenRepository\org\javassist\javassist\3.28.0-GA\javassist-3.28.0-GA.jar
 */
public class App {
    public static void main(String[] args) {
        printMsg("Hello Sandwich");
    }

    private static void printMsg(String message) {
        System.out.println(message);
    }
}

创建一个普通的java类, 添加premain方法, 参数要包含Instrumentation。

package com.sandwich;


import java.lang.instrument.Instrumentation;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class AgentByPremain {
    public static void premain(String agentOps, Instrumentation instrumentation) {
        System.out.println("======> premain started");
        //Transformer: Agent就靠这个来变异,它是核心方法
        instrumentation.addTransformer(new PremainTransformer());
    }

}

2.1.2 编写Transformer

我们在这里统计某个方法的执行时间,使用JavaAssist工具来增强字节码。
比如app项目中App类的printMsg方法。
那么需要如下步骤:
编写一个Agent类,实现ClassFileTransformer接口, 然后在transform方法中实现以下逻辑:

  • 1.获取App类的字节码
  • 2.获取printMsg方法的字节码
  • 3.在方法前后,加入时间统计
  • 4.把修改后的字节码返回。
package com.sandwich;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class PremainTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) {
        String loadName = className.replace("/", ".");
        if (className.endsWith("App")) {
            try {
                //javassist 完成字节码增强(打印方法的执行时间<纳秒>)
                CtClass ctClass = ClassPool.getDefault().get(loadName);
                CtMethod ctMethod = ctClass.getDeclaredMethod("printMsg");
                ctMethod.addLocalVariable("_startTime", CtClass.longType);
                ctMethod.insertBefore("_startTime = System.nanoTime();");
                ctMethod.insertAfter("System.out.println(\"cost: \" + (System.nanoTime() - _startTime) + \"ns\");");
                return ctClass.toBytecode();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return classfileBuffer;
    }
}

2.1.3 打包Agent

创建MANIFEST.MF文件(让外界知晓)
路径:src/main/resources/META-INF/MANIFEST.MF

maven打包会覆盖这个文件,所以我们在pom.xml中用manifestFile为它指定为保留文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>javaagent</artifactId>
        <groupId>org.sandwich</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>premain</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.sandwich</groupId>
            <artifactId>app</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
            <type>bundle</type>
        </dependency>
    </dependencies>
    

    <build>
        <plugins>
            <!--防止type = bundle的依赖报错-->
            <plugin>
                <groupId>org.apache.felix</groupId>
                <artifactId>maven-bundle-plugin</artifactId>
                <extensions>true</extensions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <!--防止我指定的MANIFEST被覆盖-->
                        <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

执行mvn install打包安装到本地代码库

得到本地代码库jar包的地址

2.1.4 Premain代理方式启动应用

在jvm启动时启用代理。思路如下

java -javaagent:agent.jar App

在idea中,我们把参数放到jvm options里面执行

-javaagent:D:\MavenRepository\org\sandwich\premain\1.0-SNAPSHOT\premain-1.0-SNAPSHOT.jar

执行结果出了点意外

premain入口进去了,但是并没有计算方法的执行时间,也没有抛出异常
接下来我们查查原因
把Agent实现的Exception改成Throwable

重新install preagent的jar再执行App的main

这次异常信息被catch到了,原来是找不到这个类javassist/ClassPool。
这个明明是已经通过maven引入的依赖,并且已经下载到本地的

我通过其他途径修复了这个问题
先copy这个依赖包的本地路径

然后在vm options那里再添加以下参数

-Xbootclasspath/a:D:\MavenRepository\org\javassist\javassist\3.28.0-GA\javassist-3.28.0-GA.jar

重新执行可以得到以下结果

由此可见,通过增强后方法执行前后的信息都被改变了,得到了这个方法的执行时间。
把测试代码改循环,再执行

public class App {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            printMsg("Hello Sandwich");
            Thread.sleep(2000);
        }
    }

    private static void printMsg(String message) {
        System.out.println(message);
    }
}

就可以查看这个字节码被修改后并非一次性生效的,在这个代码的执行周期里,它都被修改了

2.2 Agentmain

Agentmain可以在运行时将agent附加到到任意的虚拟机中来修改字节码,并且修改后可以立马更新,不需要重新加载类,因此可以实现热修改,并且比自定义类加载器更方便。
由于agent main方式无法像premain方式那样在命令行指定代理jar,因此需要借助Attach Tools API
接下来我们先要熟悉一下attach tool
熟悉Attach tool
先把attach tool使用的jar包放到一个指定路径下(这个地址并没有一定要局限在哪里,只要你能找得到它,我把它放到jdk的lib目录下)
然后添加这个jdk的依赖

    <dependencies>
        <dependency>
            <groupId>com.sun</groupId>
            <artifactId>tools</artifactId>
            <version>1.8.0</version>
            <scope>system</scope>
            <systemPath>C:\Program Files\Java\jdk1.8.0_191\lib\tools.jar</systemPath>
        </dependency>
    </dependencies>

执行以下死循环程序不关闭

public class App {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            printMsg("Hello Sandwich");
            Thread.sleep(2000);
        }
    }

    private static void printMsg(String message) {
        System.out.println(message);
    }
}

然后用以下代码去attach jvm

package com.sandwich.jvmattach.test;

import com.sun.tools.attach.*;

import java.io.IOException;
import java.util.List;
import java.util.Properties;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class JvmAttach {

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (VirtualMachineDescriptor vmd :
                list) {
            if (vmd.displayName().endsWith("com.sandwich.premain.test.App")) {
                System.out.println("Process pid: " + vmd.id());
                VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
                Properties properties = virtualMachine.getSystemProperties();
                String javaVersion = properties.getProperty("java.version");
                System.out.println("Java version: " + javaVersion);
                System.out.println("Java properties: " + properties);
                virtualMachine.detach();
            }
        }
    }
}

输出结果如下:


这个pid跟我们用jps查看到的是一样的

attach tool可以让我们附着到vm进程上,然后对vm进行操作。
到这里是不是跟Arthas attach到一个进程,然后读取它的各种数据十分相似?其实原理是一样的。
接下来我们正式来完成一个agentmain的测试实例

2.2.1 编写一个测试程序

写一个Runnable task,简单地用它来模拟一个不断被调用的api

package com.sandwich.agentmain.test;

import java.util.concurrent.TimeUnit;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class RunnableTask implements Runnable{


    @Override
    public void run() {
        System.out.println("Running...");
    }

    public void shout() {
        System.out.println("Shouting...");
    }

    public static void main(String[] args) throws InterruptedException {
        RunnableTask task = new RunnableTask();
        while (true) {
            task.run();
            TimeUnit.SECONDS.sleep(2);
            task.shout();
            TimeUnit.SECONDS.sleep(2);

        }
    }
}

这个测试类放在一个叫app的module下,这个module主要放测试代码

2.2.2 增加一个agentmain的module

这个module最终我要把它编译成agentmain的jar。
agentmain的目录结构如下


接下来我介绍一下它的创建顺序。
添加javassist依赖

        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
            <type>bundle</type>
        </dependency>

添加测试类的依赖
因为我把测试类放在另一个module里面,模拟在其他jar里面,这个类需要被retransform

        <dependency>
            <groupId>org.sandwich</groupId>
            <artifactId>app</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

添加maven-bundle-plugin
因为javassist的type是bundle,添加这个plugin是为了防止javassist下载报错

            <plugin>
                <groupId>org.apache.felix</groupId>
                <artifactId>maven-bundle-plugin</artifactId>
                <extensions>true</extensions>
            </plugin>

添加agentmain核心类

package com.sandwich;

import com.sandwich.agentmain.test.RunnableTask;

import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class AgentByAgentMain {

    public static void agentmain(String agentOps, Instrumentation instrumentation) throws UnmodifiableClassException {
        System.out.println("======> agentmain started: " + agentOps);
        instrumentation.addTransformer(new AgentmainTransformer(agentOps), true);
        instrumentation.retransformClasses(RunnableTask.class);
    }
}

添加Transformer

package com.sandwich;


import javassist.*;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class AgentmainTransformer implements ClassFileTransformer {

    private final String targetClassName;

    public AgentmainTransformer(String targetClassName) {
        this.targetClassName = targetClassName;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        className = className.replaceAll("/", ".");
        if (!className.equals(targetClassName)) {
            return null;
        }

        try {
            ClassPool classPool = ClassPool.getDefault();
            classPool.insertClassPath(className);
            CtClass ctClass = classPool.get(className);
            for (CtMethod ctMethod : ctClass.getDeclaredMethods()) {
                if (Modifier.isPublic(ctMethod.getModifiers()) && !ctMethod.getName().equals("main")) {
                    // 修改字节码
                    ctMethod.addLocalVariable("begin", CtClass.longType);
                    ctMethod.addLocalVariable("end", CtClass.longType);
                    ctMethod.insertBefore("begin = System.nanoTime();");
                    ctMethod.insertAfter("end = System.nanoTime();");
                    ctMethod.insertAfter("System.out.println(\"方法" + ctMethod.getName() + "耗时\"+ (end - begin) +\"ns\");");
                }
            }
            ctClass.detach();
            return ctClass.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

添加maven-jar-plugin
用这个plugin来个性化定制jar包
ps: 这里实现Agent-Class跟前面Premain-Class写在MANIFEST.MF不太一样,其实他们是可以写成一样的实现方式的,我这样写只是为了让大家了解更多的实现方法。

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.6</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Agent-Class>com.sandwich.AgentByAgentMain</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>

mvn install得到jar包



这个配置其实最终也会生成一个MANIFEST.MF文件

以下是文件里面的内容

Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Built-By: Sandwich
Agent-Class: com.sandwich.AgentByAgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Class-Path: app-1.0-SNAPSHOT.jar javassist-3.28.0-GA.jar
Created-By: Apache Maven 3.6.0
Build-Jdk: 1.8.0_191
Boot-Class-Path: D:/MavenRepository/org/javassist/javassist/3.28.0-GA/
 javassist-3.28.0-GA.jar

到这里agentmain代理的jar已经完成了。

2.2.3 编写attach测试代码

package com.sandwich.agentmain.test;

import com.sun.tools.attach.*;

import java.io.IOException;

/**
 * @author 公众号:IT三明治
 * @date 2022/3/23
 */
public class AttachTest {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        for (VirtualMachineDescriptor descriptor: VirtualMachine.list()) {
            if (descriptor.displayName().equals("com.sandwich.agentmain.test.RunnableTask")) {
                VirtualMachine virtualMachine = VirtualMachine.attach(descriptor.id());
                virtualMachine.loadAgent("D:\\MavenRepository\\org\\sandwich\\agentmain\\1.0-SNAPSHOT\\agentmain-1.0-SNAPSHOT.jar", "com.sandwich.agentmain.test.RunnableTask");
                virtualMachine.detach();
            }
        }
    }
}

2.3.4 调试测试类以及错误排查

先启动RunnableTask


再运动attach程序

跑完后发现已经进入了agentmain的入口了,但是并不是我期待的结果,因为没有开始计算方法执行时间。
把Exception改成Throwable

重新执行mvn install得到新package,再分别执行RunnableTask和AttachTest
可以看到这里也出错了

还是找不到javassist,如果我们只是从maven依赖的角度分析的话,我们明明是在依赖添加了javassist的,为什么会找不到它的类呢?
做这个java agent测试,坑还真多,需要很有耐心去解决才行。
我怀疑是类加载的原因,让我们来追踪一下类加载情况
在RunnableTask和AttachTest分别添加追踪类加载的vm option.

-XX:+TraceClassLoading



再分别先后执行RunnableTask和AttachTest,观察打印结果
执行RunnableTask并搜索类加载日志

并无javassist相关的类被加载
再执行AttachTest并搜索类加载日志


只能在RunnableTask下搜到javassist not found的异常,并没有javassist 相关类被加载。
AgentmainTransformer也是进入agentmain核心代码才会被加载。

如果需要调用javassist相关的类,那么它必须在AgentmainTransformer加载前就已经完成加载。
我们要怎么才能让javassist先被加载呢?
按照我之前写的双亲委派模型,类的加载顺序如下

以下是类加载器原码

它首先使用parent尝试进行类加载,parent失败后才轮到自己。
这里的类加载器优先顺序是Bootstrap class loader>Extension class loader>Application class loader>Custom class loader
所以我们要先确定AgentmainTransformer的当前类加载器
改造一下代码先

重新编译先后执行RunnableTask和AttachTest

可见AgentmainTransformer当前的类加载器和transform进来的类加载器是同一个,都是Application class loader。
这样就好办了,我们想办法把javassist的包丢到它前面的class loader加载就好了。
我直接把javassist的包丢到Extension class loader加载的路径试试

重新先后执行RunnableTask和AttachTest得到如下结果

javassist果然被加载了,然后RunnableTask被从VM_RedefineClasses重新加载

字节码也被修改成功了,除了main方法外的方法都被统计执行时间了。

2.3.5 javassist not found的其他解决方案

把Extension class loader加载路径下的javassist先删除,我们再试试其他解决方案

C:\Program Files\Java\jdk1.8.0_191\jre\lib\ext\javassist-3.28.0-GA.jar

在agentmain下的pom加上下面一行


Boot-Class-Path
A list of paths to be searched by the bootstrap class loader. Paths represent directories or libraries (commonly referred to as JAR or zip libraries on many platforms). These paths are searched by the bootstrap class loader after the platform specific mechanisms of locating a class have failed. Paths are searched in the order listed. Paths in the list are separated by one or more spaces. A path takes the syntax of the path component of a hierarchical URI. The path is absolute if it begins with a slash character ('/'), otherwise it is relative. 官方文档 ...
由此可见这个配置可以指定bootstrap class loader去到指定的路径下加载jar包。它的优先级更高。
重新编译,再先后执行RunnableTask和AttachTest
可见javassist不但被加载了,而且拥有最高优先级,第一个被加载的就是它


字节码同样被修改成功并且成功统计除main外所有方法的执行时间。

3 总结

我们利用java agent技术成功修改了目标字节码,完成对目标代码的动态修改,追踪。其实作为 Java的动态追踪技术,站在比较底层的角度上来说,底层无非就是基于ASM、Java Attach API、Instrument开发的创建。 Arthas都是针对这些技术做封装而已。
我已经上传完整测试代码到公众号: IT三明治,如果需要请关注并回复:1002,下载测试,希望能避免你重走我踩过的坑。

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

推荐阅读更多精彩内容