偷天換日,用JavaAgent欺騙你的JVM
熟悉Spring的小伙伴們應(yīng)該都對aop比較了解,面向切面編程允許我們在目標(biāo)方法的前后織入想要執(zhí)行的邏輯,而今天要給大家介紹的Java Agent技術(shù),在思想上與aop比較類似,翻譯過來可以被稱為Java代理、Java探針技術(shù)。
Java Agent出現(xiàn)在JDK1.5版本以后,它允許程序員利用agent技術(shù)構(gòu)建一個(gè)獨(dú)立于應(yīng)用程序的代理程序,用途也非常廣泛,可以協(xié)助監(jiān)測、運(yùn)行、甚至替換其他JVM上的程序,先從下面這張圖直觀的看一下它都被應(yīng)用在哪些場景:
看到這里你是不是也很好奇,究竟是什么神仙技術(shù),能夠應(yīng)用在這么多場景下,那今天我們就來挖掘一下,看看神奇的Java Agent是如何工作在底層,默默支撐了這么多優(yōu)秀的應(yīng)用。
回到文章開頭的類比,我們還是用和aop比較的方式,來先對Java Agent有一個(gè)大致的了解:
- 作用級別:aop運(yùn)行于應(yīng)用程序內(nèi)的方法級別,而agent能夠作用于虛擬機(jī)級別
- 組成部分:aop的實(shí)現(xiàn)需要目標(biāo)方法和邏輯增強(qiáng)部分的方法,而Java Agent要生效需要兩個(gè)工程,一個(gè)是agent代理,另一個(gè)是需要被代理的主程序
- 執(zhí)行場合:aop可以運(yùn)行在切面的前后或環(huán)繞等場合,而Java Agent的執(zhí)行只有兩種方式,jdk1.5提供的preMain模式在主程序運(yùn)行前執(zhí)行,jdk1.6提供的agentMain在主程序運(yùn)行后執(zhí)行
下面我們就分別看一下在兩種模式下,如何動(dòng)手實(shí)現(xiàn)一個(gè)agent代理程序。
Premain模式
Premain模式允許在主程序執(zhí)行前執(zhí)行一個(gè)agent代理,實(shí)現(xiàn)起來非常簡單,下面我們分別實(shí)現(xiàn)兩個(gè)組成部分。
agent
先寫一個(gè)簡單的功能,在主程序執(zhí)行前打印一句話,并打印傳遞給代理的參數(shù):
- public class MyPreMainAgent {
- public static void premain(String agentArgs, Instrumentation inst) {
- System.out.println("premain start");
- System.out.println("args:"+agentArgs);
- }
- }
在寫完了agent的邏輯后,需要把它打包成jar文件,這里我們直接使用maven插件打包的方式,在打包前進(jìn)行一些配置。
- <build>
- <plugins>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-jar-plugin</artifactId>
- <version>3.1.0</version>
- <configuration>
- <archive>
- <manifest>
- <addClasspath>true</addClasspath>
- </manifest>
- <manifestEntries>
- <Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>
- <Can-Redefine-Classes>true</Can-Redefine-Classes>
- <Can-Retransform-Classes>true</Can-Retransform-Classes>
- <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
- </manifestEntries>
- </archive>
- </configuration>
- </plugin>
- </plugins>
- </build>
配置的打包參數(shù)中,通過manifestEntries的方式添加屬性到MANIFEST.MF文件中,解釋一下里面的幾個(gè)參數(shù):
- Premain-Class:包含premain方法的類,需要配置為類的全路徑
- Can-Redefine-Classes:為true時(shí)表示能夠重新定義class
- Can-Retransform-Classes:為true時(shí)表示能夠重新轉(zhuǎn)換class,實(shí)現(xiàn)字節(jié)碼替換
- Can-Set-Native-Method-Prefix:為true時(shí)表示能夠設(shè)置native方法的前綴
其中Premain-Class為必須配置,其余幾項(xiàng)是非必須選項(xiàng),默認(rèn)情況下都為false,通常也建議加入,這幾個(gè)功能我們會在后面具體介紹。在配置完成后,使用mvn命令打包:
- mvn clean package
打包完成后生成myAgent-1.0.jar文件,我們可以解壓jar文件,看一下生成的MANIFEST.MF文件:
可以看到,添加的屬性已經(jīng)被加入到了文件中。到這里,agent代理部分就完成了,因?yàn)榇聿荒軌蛑苯舆\(yùn)行,需要附著于其他程序,所以下面新建一個(gè)工程來實(shí)現(xiàn)主程序。
主程序
在主程序的工程中,只需要一個(gè)能夠執(zhí)行的main方法的入口就可以了。
- public class AgentTest {
- public static void main(String[] args) {
- System.out.println("main project start");
- }
- }
在主程序完成后,要考慮的就是應(yīng)該如何將主程序與agent工程連接起來。這里可以通過-javaagent參數(shù)來指定運(yùn)行的代理,命令格式如下:
- java -javaagent:myAgent.jar -jar AgentTest.jar
并且,可以指定的代理的數(shù)量是沒有限制的,會根據(jù)指定的順序先后依次執(zhí)行各個(gè)代理,如果要同時(shí)運(yùn)行兩個(gè)代理,就可以按照下面的命令執(zhí)行:
- java -javaagent:myAgent1.jar -javaagent:myAgent2.jar -jar AgentTest.jar
以我們在idea中執(zhí)行程序?yàn)槔赩M options中加入添加啟動(dòng)參數(shù):
- -javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra
- -javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks
執(zhí)行main方法,查看輸出結(jié)果:
根據(jù)執(zhí)行結(jié)果的打印語句可以看出,在執(zhí)行主程序前,依次執(zhí)行了兩次我們的agent代理??梢酝ㄟ^下面的圖來表示執(zhí)行代理與主程序的執(zhí)行順序。
缺陷
在提供便利的同時(shí),premain模式也有一些缺陷,例如如果agent在運(yùn)行過程中出現(xiàn)異常,那么也會導(dǎo)致主程序的啟動(dòng)失敗。我們對上面例子中agent的代碼進(jìn)行一下改造,手動(dòng)拋出一個(gè)異常。
- public static void premain(String agentArgs, Instrumentation inst) {
- System.out.println("premain start");
- System.out.println("args:"+agentArgs);
- throw new RuntimeException("error");
- }
再次運(yùn)行主程序:
可以看到,在agent拋出異常后主程序也沒有啟動(dòng)。針對premain模式的一些缺陷,在jdk1.6之后引入了agentmain模式。
Agentmain模式
agentmain模式可以說是premain的升級版本,它允許代理的目標(biāo)主程序的jvm先行啟動(dòng),再通過attach機(jī)制連接兩個(gè)jvm,下面我們分3個(gè)部分實(shí)現(xiàn)。
agent
agent部分和上面一樣,實(shí)現(xiàn)簡單的打印功能:
- public class MyAgentMain {
- public static void agentmain(String agentArgs, Instrumentation instrumentation) {
- System.out.println("agent main start");
- System.out.println("args:"+agentArgs);
- }
- }
修改maven插件配置,指定Agent-Class:
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-jar-plugin</artifactId>
- <version>3.1.0</version>
- <configuration>
- <archive>
- <manifest>
- <addClasspath>true</addClasspath>
- </manifest>
- <manifestEntries>
- <Agent-Class>com.cn.agent.MyAgentMain</Agent-Class>
- <Can-Redefine-Classes>true</Can-Redefine-Classes>
- <Can-Retransform-Classes>true</Can-Retransform-Classes>
- </manifestEntries>
- </archive>
- </configuration>
- </plugin>
主程序
這里我們直接啟動(dòng)主程序等待代理被載入,在主程序中使用了System.in進(jìn)行阻塞,防止主進(jìn)程提前結(jié)束。
- public class AgentmainTest {
- public static void main(String[] args) throws IOException {
- System.in.read();
- }
- }
attach機(jī)制
和premain模式不同,我們不能再通過添加啟動(dòng)參數(shù)的方式來連接agent和主程序了,這里需要借助com.sun.tools.attach包下的VirtualMachine工具類,需要注意該類不是jvm標(biāo)準(zhǔn)規(guī)范,是由Sun公司自己實(shí)現(xiàn)的,使用前需要引入依賴:
- <dependency>
- <groupId>com.sun</groupId>
- <artifactId>tools</artifactId>
- <version>1.8</version>
- <scope>system</scope>
- <systemPath>${JAVA_HOME}\lib\tools.jar</systemPath>
- </dependency>
VirtualMachine代表了一個(gè)要被附著的java虛擬機(jī),也就是程序中需要監(jiān)控的目標(biāo)虛擬機(jī),外部進(jìn)程可以使用VirtualMachine的實(shí)例將agent加載到目標(biāo)虛擬機(jī)中。先看一下它的靜態(tài)方法attach:
- public static VirtualMachine attach(String var0);
通過attach方法可以獲取一個(gè)jvm的對象實(shí)例,這里傳入的參數(shù)是目標(biāo)虛擬機(jī)運(yùn)行時(shí)的進(jìn)程號pid。也就是說,我們在使用attach前,需要先獲取剛才啟動(dòng)的主程序的pid,使用jps命令查看線程pid:
- 11140
- 16372 RemoteMavenServer36
- 16392 AgentmainTest
- 20204 Jps
- 2460 Launcher
獲取到主程序AgentmainTest運(yùn)行時(shí)pid是16392,將它應(yīng)用于虛擬機(jī)的連接。
- public class AttachTest {
- public static void main(String[] args) {
- try {
- VirtualMachine vm= VirtualMachine.attach("16392");
- vm.loadAgent("F:\\Workspace\\MyAgent\\target\\myAgent-1.0.jar","param");
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
在獲取到VirtualMachine實(shí)例后,就可以通過loadAgent方法可以實(shí)現(xiàn)注入agent代理類的操作,方法的第一個(gè)參數(shù)是代理的本地路徑,第二個(gè)參數(shù)是傳給代理的參數(shù)。執(zhí)行AttachTest,再回到主程序AgentmainTest的控制臺,可以看到執(zhí)行了了agent中的代碼:
這樣,一個(gè)簡單的agentMain模式代理就實(shí)現(xiàn)完成了,可以通過下面這張圖再梳理一下三個(gè)模塊之間的關(guān)系。
應(yīng)用
到這里,我們就已經(jīng)簡單地了解了兩種模式的實(shí)現(xiàn)方法,但是作為高質(zhì)量程序員,我們肯定不能滿足于只用代理單純地打印語句,下面我們再來看看能怎么利用Java Agent搞點(diǎn)實(shí)用的東西。
在上面的兩種模式中,agent部分的邏輯分別是在premain方法和agentmain方法中實(shí)現(xiàn)的,并且,這兩個(gè)方法在簽名上對參數(shù)有嚴(yán)格的要求,premain方法允許以下面兩種方式定義:
- public static void premain(String agentArgs)
- public static void premain(String agentArgs, Instrumentation inst)
agentmain方法允許以下面兩種方式定義:
- public static void agentmain(String agentArgs)
- public static void agentmain(String agentArgs, Instrumentation inst)
如果在agent中同時(shí)存在兩種簽名的方法,帶有Instrumentation參數(shù)的方法優(yōu)先級更高,會被jvm優(yōu)先加載,它的實(shí)例inst會由jvm自動(dòng)注入,下面我們就看看能通過Instrumentation實(shí)現(xiàn)什么功能。
Instrumentation
先大體介紹一下Instrumentation接口,其中的方法允許在運(yùn)行時(shí)操作java程序,提供了諸如改變字節(jié)碼,新增jar包,替換class等功能,而通過這些功能使Java具有了更強(qiáng)的動(dòng)態(tài)控制和解釋能力。在我們編寫agent代理的過程中,Instrumentation中下面3個(gè)方法比較重要和常用,我們來著重看一下。
addTransformer
addTransformer方法允許我們在類加載之前,重新定義Class,先看一下方法的定義:
- void addTransformer(ClassFileTransformer transformer);
ClassFileTransformer是一個(gè)接口,只有一個(gè)transform方法,它在主程序的main方法執(zhí)行前,裝載的每個(gè)類都要經(jīng)過transform執(zhí)行一次,可以將它稱為轉(zhuǎn)換器。我們可以實(shí)現(xiàn)這個(gè)方法來重新定義Class,下面就通過一個(gè)例子看看具體如何使用。
首先,在主程序工程創(chuàng)建一個(gè)Fruit類:
- public class Fruit {
- public void getFruit(){
- System.out.println("banana");
- }
- }
編譯完成后復(fù)制一份class文件,并將其重命名為Fruit2.class,再修改Fruit中的方法為:
- public void getFruit(){
- System.out.println("apple");
- }
創(chuàng)建主程序,在主程序中創(chuàng)建了一個(gè)Fruit對象并調(diào)用了其getFruit方法:
- public class TransformMain {
- public static void main(String[] args) {
- new Fruit().getFruit();
- }
- }
這時(shí)執(zhí)行結(jié)果會打印apple,接下來開始實(shí)現(xiàn)premain代理部分。
在代理的premain方法中,使用Instrumentation的addTransformer方法攔截類的加載:
- public class TransformAgent {
- public static void premain(String agentArgs, Instrumentation inst) {
- inst.addTransformer(new FruitTransformer());
- }
- }
FruitTransformer類實(shí)現(xiàn)了ClassFileTransformer接口,轉(zhuǎn)換class部分的邏輯都在transform方法中:
- public class FruitTransformer implements ClassFileTransformer {
- @Override
- public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
- ProtectionDomain protectionDomain, byte[] classfileBuffer){
- if (!className.equals("com/cn/hydra/test/Fruit"))
- return classfileBuffer;
- String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class";
- return getClassBytes(fileName);
- }
- public static byte[] getClassBytes(String fileName){
- File file = new File(fileName);
- try(InputStream is = new FileInputStream(file);
- ByteArrayOutputStream bs = new ByteArrayOutputStream()){
- long length = file.length();
- byte[] bytes = new byte[(int) length];
- int n;
- while ((n = is.read(bytes)) != -1) {
- bs.write(bytes, 0, n);
- }
- return bytes;
- }catch (Exception e) {
- e.printStackTrace();
- return null;
- }
- }
- }
在transform方法中,主要做了兩件事:
- 因?yàn)閍ddTransformer方法不能指明需要轉(zhuǎn)換的類,所以需要通過className判斷當(dāng)前加載的class是否我們要攔截的目標(biāo)class,對于非目標(biāo)class直接返回原字節(jié)數(shù)組,注意className的格式,需要將類全限定名中的.替換為/
- 讀取我們之前復(fù)制出來的class文件,讀入二進(jìn)制字符流,替換原有classfileBuffer字節(jié)數(shù)組并返回,完成class定義的替換
將agent部分打包完成后,在主程序添加啟動(dòng)參數(shù):
- -javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar
再次執(zhí)行主程序,結(jié)果打印:
- banana
這樣,就實(shí)現(xiàn)了在main方法執(zhí)行前class的替換。
redefineClasses
我們可以直觀地從方法的名字上來理解它的作用,重定義class,通俗點(diǎn)來講的話就是實(shí)現(xiàn)指定類的替換。方法定義如下:
- void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;
它的參數(shù)是可變長的ClassDefinition數(shù)組,再看一下ClassDefinition的構(gòu)造方法:
- public ClassDefinition(Class<?> theClass,byte[] theClassFile) {...}
ClassDefinition中指定了的Class對象和修改后的字節(jié)碼數(shù)組,簡單來說,就是使用提供的類文件字節(jié),替換了原有的類。并且,在redefineClasses方法重定義的過程中,傳入的是ClassDefinition的數(shù)組,它會按照這個(gè)數(shù)組順序進(jìn)行加載,以便滿足在類之間相互依賴的情況下進(jìn)行更改。
下面通過一個(gè)例子來看一下它的生效過程,premain代理部分:
- public class RedefineAgent {
- public static void premain(String agentArgs, Instrumentation inst)
- throws UnmodifiableClassException, ClassNotFoundException {
- String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class";
- ClassDefinition def=new ClassDefinition(Fruit.class,
- FruitTransformer.getClassBytes(fileName));
- inst.redefineClasses(new ClassDefinition[]{def});
- }
- }
主程序可以直接復(fù)用上面的,執(zhí)行后打?。?/p>
- banana
可以看到,用我們指定的class文件的字節(jié)替換了原有類,即實(shí)現(xiàn)了指定類的替換。
retransformClasses
retransformClasses應(yīng)用于agentmain模式,可以在類加載之后重新定義Class,即觸發(fā)類的重新加載。首先看一下該方法的定義:
- void retransformClasses(Class... classes) throws UnmodifiableClassException;
它的參數(shù)classes是需要轉(zhuǎn)換的類數(shù)組,可變長參數(shù)也說明了它和redefineClasses方法一樣,也可以批量轉(zhuǎn)換類的定義。
下面,我們通過例子來看看如何使用retransformClasses方法,agent代理部分代碼如下:
- public class RetransformAgent {
- public static void agentmain(String agentArgs, Instrumentation inst)
- throws UnmodifiableClassException {
- inst.addTransformer(new FruitTransformer(),true);
- inst.retransformClasses(Fruit.class);
- System.out.println("retransform success");
- }
- }
看一下這里調(diào)用的addTransformer方法的定義,與上面略有不同:
- void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
ClassFileTransformer轉(zhuǎn)換器依舊復(fù)用了上面的FruitTransformer,重點(diǎn)看一下新加的第二個(gè)參數(shù),當(dāng)canRetransform為true時(shí),表示允許重新定義class。這時(shí),相當(dāng)于調(diào)用了轉(zhuǎn)換器ClassFileTransformer中的transform方法,會將轉(zhuǎn)換后class的字節(jié)作為新類定義進(jìn)行加載。
主程序部分代碼,我們在死循環(huán)中不斷的執(zhí)行打印語句,來監(jiān)控類是否發(fā)生了改變:
- public class RetransformMain {
- public static void main(String[] args) throws InterruptedException {
- while(true){
- new Fruit().getFruit();
- TimeUnit.SECONDS.sleep(5);
- }
- }
- }
最后,使用attach api注入agent代理到主程序中:
- public class AttachRetransform {
- public static void main(String[] args) throws Exception {
- VirtualMachine vm = VirtualMachine.attach("6380");
- vm.loadAgent("F:\\Workspace\\MyAgent\\target\\retransformAgent-1.0.jar");
- }
- }
回到主程序控制臺,查看運(yùn)行結(jié)果:
可以看到在注入代理后,打印語句發(fā)生變化,說明類的定義已經(jīng)被改變并進(jìn)行了重新加載。
其他
除了這幾個(gè)主要的方法外,Instrumentation中還有一些其他方法,這里僅簡單列舉一下常用方法的功能:
- removeTransformer:刪除一個(gè)ClassFileTransformer類轉(zhuǎn)換器
- getAllLoadedClasses:獲取當(dāng)前已經(jīng)被加載的Class
- getInitiatedClasses:獲取由指定的ClassLoader加載的Class
- getObjectSize:獲取一個(gè)對象占用空間的大小
- appendToBootstrapClassLoaderSearch:添加jar包到啟動(dòng)類加載器
- appendToSystemClassLoaderSearch:添加jar包到系統(tǒng)類加載器
- isNativeMethodPrefixSupported:判斷是否能給native方法添加前綴,即是否能夠攔截native方法
- setNativeMethodPrefix:設(shè)置native方法的前綴
Javassist
在上面的幾個(gè)例子中,我們都是直接讀取的class文件中的字節(jié)來進(jìn)行class的重定義或轉(zhuǎn)換,但是在實(shí)際的工作環(huán)境中,可能更多的是去動(dòng)態(tài)的修改class文件的字節(jié)碼,這時(shí)候就可以借助javassist來更簡單的修改字節(jié)碼文件。
簡單來說,javassist是一個(gè)分析、編輯和創(chuàng)建java字節(jié)碼的類庫,在使用時(shí)我們可以直接調(diào)用它提供的api,以編碼的形式動(dòng)態(tài)改變或生成class的結(jié)構(gòu)。相對于ASM等其他要求了解底層虛擬機(jī)指令的字節(jié)碼框架,javassist真的是非常簡單和快捷。
下面,我們就通過一個(gè)簡單的例子,看看如何將Java agent和Javassist結(jié)合在一起使用。首前先引入javassist的依賴:
- <dependency>
- <groupId>org.javassist</groupId>
- <artifactId>javassist</artifactId>
- <version>3.20.0-GA</version>
- </dependency>
我們要實(shí)現(xiàn)的功能是通過代理,來計(jì)算方法執(zhí)行的時(shí)間。premain代理部分和之前基本一致,先添加一個(gè)轉(zhuǎn)換器:
- public class Agent {
- public static void premain(String agentArgs, Instrumentation inst) {
- inst.addTransformer(new LogTransformer());
- }
- static class LogTransformer implements ClassFileTransformer {
- @Override
- public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
- ProtectionDomain protectionDomain, byte[] classfileBuffer)
- throws IllegalClassFormatException {
- if (!className.equals("com/cn/hydra/test/Fruit"))
- return null;
- try {
- return calculate();
- } catch (Exception e) {
- e.printStackTrace();
- return null;
- }
- }
- }
- }
在calculate方法中,使用javassist動(dòng)態(tài)的改變了方法的定義:
- static byte[] calculate() throws Exception {
- ClassPool pool = ClassPool.getDefault();
- CtClass ctClass = pool.get("com.cn.hydra.test.Fruit");
- CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit");
- CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap());
- ctMethod.setName("getFruit$agent");
- StringBuffer body = new StringBuffer("{\n")
- .append("long begin = System.nanoTime();\n")
- .append("getFruit$agent($$);\n")
- .append("System.out.println(\"use \"+(System.nanoTime() - begin) +\" ns\");\n")
- .append("}");
- copyMethod.setBody(body.toString());
- ctClass.addMethod(copyMethod);
- return ctClass.toBytecode();
- }
在上面的代碼中,主要實(shí)現(xiàn)了這些功能:
- 利用全限定名獲取類CtClass
- 根據(jù)方法名獲取方法CtMethod,并通過CtNewMethod.copy方法復(fù)制一個(gè)新的方法
- 修改舊方法的方法名為getFruit$agent
- 通過setBody方法修改復(fù)制出來方法的內(nèi)容,在新方法中進(jìn)行了邏輯增強(qiáng)并調(diào)用了舊方法,最后將新方法添加到類中
主程序仍然復(fù)用之前的代碼,執(zhí)行查看結(jié)果,完成了代理中的執(zhí)行時(shí)間統(tǒng)計(jì)功能:
這時(shí)候我們可以再通過反射看一下:
- for (Method method : Fruit.class.getDeclaredMethods()) {
- System.out.println(method.getName());
- method.invoke(new Fruit());
- System.out.println("-------");
- }
查看結(jié)果,可以看到類中確實(shí)已經(jīng)新增了一個(gè)方法:
除此之外,javassist還有很多其他的功能,例如新建Class、設(shè)置父類、讀取和寫入字節(jié)碼等等,大家可以在具體的場景中學(xué)習(xí)它的用法。
總結(jié)
雖然我們在平常的工作中,直接用到Java Agent的場景可能并不是很多,但是在熱部署、監(jiān)控、性能分析等工具中,它們可能隱藏在業(yè)務(wù)系統(tǒng)的角落里,一直在默默發(fā)揮著巨大的作用。
本文從Java Agent的兩種模式入手,手動(dòng)實(shí)現(xiàn)并簡要分析了它們的工作流程,雖然在這里只利用它們完成了一些簡單的功能,但是不得不說,正是Java Agent的出現(xiàn),讓程序的運(yùn)行不再循規(guī)蹈矩,也為我們的代碼提供了無限的可能性。