素小暖講JVM:第九章 類加載與執行子系統的案例與實戰

本系列是用來記錄《深刻理解Java虛擬機》這本書的讀書筆記。方便本身查看,也方便你們查閱。java

欲速則不達,欲達則欲速!程序員

第九章 類加載與執行子系統的案例與實戰web

代碼編譯的結果從本地機器碼轉變爲字節碼是存儲格式發展的一小步,倒是編程語言發展的一大步。編程

1、案例分析swift

一、tomcat:正統的類加載器架構數組

主流的java web服務器,如tomcat、jetty、weblogic、websphere等服務器,都實現了自定義的類加載。由於一個功能健全的web服務器,要解決如下問題:瀏覽器

(1)部署在同一個服務器上的兩個web應用程序所使用的java類庫能夠實現相互隔離。服務器應當保證兩個應用程序的類庫能夠互相獨立使用。緩存

(2)部署在同一個服務器上的兩個web應用程序所使用的java類庫能夠互相分享。若是部分類庫不能分享,虛擬機的方法區就會很容易出現過分膨脹的風險。tomcat

(3)服務器須要儘量的保證自身的安全不受部署的web應用程序影響。基於安全考慮,服務器所使用的類庫應該與應用程序的類庫互相獨立。安全

(4)支持JSP應用的web服務器,大多數須要支持hotswap功能。咱們知道,JSP文件最終要編譯成java class才能由虛擬機執行,但JSP文件因爲其純文本存儲的特性,運行時修改的機率遠遠大於第三方類庫或程序自身class文件。

因爲存在上述問題,在部署web應用時,單獨的一個classpath就沒法知足需求了,因此各類web服務器都不約而同的提供了好幾個classpath路徑供影虎存放第三方類庫,這些路徑通常以lib或classes命名。不一樣路徑的類庫,具有不一樣的訪問範圍和服務對象。

在tomcat目錄結構中,有3種目錄( 「/common/*」、「/server/*」和「/shared/*」 )能夠存放java類庫,另外加上web應用程序自身目錄 「WEB-INF/*」,一共4組,把java類庫放置在這些目錄中的含義分別是:

  • 放置在/common目錄中:類庫可被tomcat和全部的web應用程序共同使用。
  • 放置在/server目錄中:類庫可被tomcat使用,對全部web應用程序不可見。
  • 放置在/shared目錄中:類庫可被全部的web應用程序共同使用,但對tomcat不可見。
  • 放置在 /WebApp/WEB-INF目錄中: 類庫僅僅能夠被此web應用程序使用,對tomcat和其它web應用程序都不可見

爲了支持這套目錄結構,並對目錄裏面的類庫進行加載和隔離,tomcat自定義多個類加載器,這些類加載器按照經典的雙親委派模型來實現。

從圖中的委派關係能夠看出, CommonClassLoader 能加載的類均可以被 CatelinaClassLoader和SharedClassLoader使用 , 而CatelinaClassLoader和SharedClassLoader本身能加載的類則與對方相互隔離。

WebAppClassLoader可使用SharedClassLoader加載到的類,但各個WebAppClassLoader實例之間相互隔離。

而JasperLoader的加載範圍則僅僅是這個JSP文件所編譯出來的哪個Class,它出現的目的就是爲了被拋棄:當服務器檢測到JSP文件被修改時,會替換掉目前的JasperLoader的實例,並經過再創建一個新的Jsp類加載器來實現JSP文件的HotSwap功能。

注意:對於Tomcat6.x的版本,只有指定了 tomcat/conf/catalina.properties 配置文件的 server.loader 和 share.loader 項纔會真正創建對應的 *ClassLoader實例,不然會用到這兩個類加載器的地方使用CommonClassLoader 的實例來代替,默認配置中沒有設置這兩個loader 項。

二、OSGi:靈活的類加載器架構

(1)OSGi概述

OSGi裏,Bundle之間的依賴關係從傳統的上層模塊依賴底層模塊轉變爲平級模塊之間的依賴。

OSGi特色,要歸功於它靈活的類加載架構。OSGi的Bundle類加載器之間只有規則,沒有固定的委派關係。

java程序社區流傳着這麼一個觀點:「學習J2EE規範,去看JBoss源碼;學習類加載器,就去看OSGi源碼」。儘管J2EE規範和類加載器的知識並非一個對等的概念。不過既然這個觀點能在程序員中流傳開來,也從側面說明了OSGi對類加載器的運用確實有其獨到之處。

OSGi(open service gateway initiative)是OSGI聯盟制定的一個基於java語言的動態模塊化規範,最著名的應用案例就是eclipse IDE。

(2)引入OSGi的一個重要理由:

  • 更精確的模塊劃分和可見性控制
  • 基於OSGi的程序極可能能夠實現模塊級的熱插拔功能,當程序升級更新或調試出錯時,能夠只停用,從新安裝後啓用程序的其中一部分,這對企業級程序來講是一個很是有誘惑力的特性。

OSGi之因此能有上述誘人的特色,要歸功於它靈活的類加載器結構。OSGi的Bundle類加載器之間只有規則,沒有固定的委派關係。例如,某個Bundel聲明瞭一個它依賴的Package,若是有其餘Bundle聲明發布了這個Package後,那個對這個Package的全部類加載動做都會給發佈它的Bundle類加載器去完成。不涉及某個具體的Package時,各個Bundle加載器都是平級的關係,只有具體使用到某個Package和Class的時候,纔會根據Package導入導出定義來構造Bundle間的委派。

另外,一個Bundle類加載器爲其它Bundle提供服務時,會根據 Export-Package 列表嚴格控制訪問範圍。若是一個類存在於Bundle的類加載器能找到這個類,但不會提供給其它bundle使用,並且OSGi平臺也不會把其它Bundle的類加載請求分配給這個Bundle來處理。

(3)代碼實例

咱們能夠舉一個更具體一點的簡單例子,加入存在BundleA,BundleB和BundleC三個模塊,而且這三個Bundle定義的依賴關係爲:

  • Bundle A:聲明發布了packageA,依賴了java.*包;
  • Bundel B:聲明依賴了PackageA和PackageC,同時也依賴了java.*包;
  • Bundle C:聲明發布了packageC,依賴了PackageA.
那麼,這三個Bundle之間的類加載器及父類 加載器之間的關係如圖9-2所示。
 
因爲沒有牽扯到哦具體的OSGi實現,圖中的類加載器都沒有指明具體的加載器實現,只是一個體現了加載器間關係的概念模型,而且只是體現了OSGi中最簡單的加載器委派關係。通常來講,在OSGi里加載一個雷可能發生的查找行爲和委派關係會比圖9-2中顯示的複雜的多,類加載時可能進行的查找規則以下:
  • 以java.*開頭的類,委派給父類加載器加載。
  • 不然,委派列表名單內的類,委派給父類加載器加載。
  • 不然,Import列表中的類,委派給Export這個類的Bundle的類加載器加載
  • 不然,查找當前Bundle的Classpath,使用本身的類加載器加載。
  • 不然,查找是否在本身的Fragment Bundle中,若是是則委派給Fragment Bundle的類加載器加載。
  • 不然,查找Dynamic Import列表的Bundle,委派給對應的Bundle類加載器加載。
  • 不然,查找失敗。
從圖9-2中還能夠看出,在OSGi裏面,加載器之間的關係再也不是雙親委派模型的樹形結構,而是已經進一步發展成了一種運行時才能肯定的網狀結構。這個網狀結構的類加載器架構在帶來更優秀的靈活性的同時,也可能會產生許多新的隱患。筆者曾經參與過講一個非OSGi的大型系統向Equinox OSG平臺遷移的項目,因爲歷史緣由,代碼模塊之間的依賴關係錯綜複雜,勉強分離出各個模塊的Bundle後,發如今 高併發環境下常常出現死鎖。咱們很容易就找到了死鎖的緣由:若是出現了Bundle A依賴Bundle B的Package B,而Bundle B又依賴了Bundel A的Package A,這兩個Bundle進行類加載時就很容易發生死鎖。具體狀況是當Bundel A 加載Package B時,首先須要鎖定但錢磊加載器的實例對象(java.lang.ClassLoader.loadClass()是一個synchronized方法),而後把請求委派給Bundle B的加載器處理,但若是這時候Bundle B的也正好想加載Package A的類,它也先鎖定本身的加載器再去請求Bundle A的加載器處理,這樣兩個加載器都在等待對方處理本身的請求,而對方處理完以前本身又一直處於同步鎖定的狀態,所以他們就互相死鎖,永遠沒法完成加載請求了。Equinox的Bug List中也有關於這類問題的Bug,並提供了一個以犧牲戲能爲代價的解決方法-用戶能夠啓用osgi.classloader.singleThreadLoads參數來按單線程串行化的方式強制進行類加載動做。在JDK1.7中,將會爲非樹狀繼承關係下的類加載器架構進行一次專門的升級,但願從底層避免這類死鎖的情況,這個動做也是爲將實現「官方」模塊化規範-JSR-296,JSR-277座準備。
整體來講,OSGi描繪了一個很美好的模塊化開發的目標,並且定義了實現這個目標所須要的各類服務,同時也有成熟框架對其提供實現支持。對於單個虛擬機下的應用,從開發初期就創建在OSGi上是一個很不錯的選擇,這樣便於約束依賴。但並不是全部的應用都適合採用OSGi做爲架構基礎,OSGi在提供強大功能的同時也引入的額外的複雜度,帶來了線程死鎖和內存泄漏的風險。
 
(4)字節碼生成技術與動態代理的實現
 
「字節碼生成」並非什麼高深的技術讀者在看到「字節碼生成」這個標題時也沒必要先去想諸如Javassist,CGLib和ASM之類的字節碼類庫,由於JDK裏面的javac命令就是字節碼生成技術的「老祖宗」,而且javac也是一個由java語言寫成的程序,它的代碼存放在OpenJDK的jdk7/langtools/src/share/classes/com/sun/tools/javac目錄中。要深刻了解字節碼生成,閱讀源碼是一個很好的途徑,不過javac對於咱們這個例子來講太龐大了。在java裏面除了javac和字節碼類庫外,使用到字節碼生成的例子還有不少,如Web服務器中的JSP編譯器,編譯時植入的AOP框架,還有很經常使用的動態代理技術,甚至在使用反射的時候虛擬機都有可能會在運行時生成字節碼來提升執行速度。咱們選擇其中相對簡單的動態代理來看看字節碼生成技術是如何影響程序運做的。
相信許多Java開發人員都使用過動態代理,即便沒有直接使用過java.lang.reflect.Proxy或實現過java.lang.reflect.invocationHandler接口,應該也用過Spring來作過Bean的組織管理。若是使用過Spring,那大多數狀況下就都用過動態代理,由於若是Bean是面向接口編程,那麼在Spring內部則都是銅鼓動態代理的方式來對Bean進行加強的。動態代理中所謂的「動態」,是針對使用Java代碼實際編寫了代理類的「靜態」代言而言的,它的優點不在於省去了編寫代理類那一點工做量,而是實現了能夠在原始類和接口還未知的時候,就肯定代理類的代理行爲,當代理類與原始類脫離直接關係後,接能夠很靈活地重用於不一樣的應用場景之中。
代碼清單9-1中演示了一個最簡單的動態代理的用法,原始的邏輯是打印一句「hello world」,代理類的邏輯實在原始類的方法執行前打印一句「welcome」。咱們先看一下代碼,而後再分析JDK是如何作到的。
public class DynamicProxyTest {
	interface IHello{
		void sayHello();
	}
	static class Hello implements IHello{
		@Override
		public void sayHello(){
			System.out.println("hello world");
		}
	}
	
	static class DynamicProxy implements InvocationHandler{
		Object originalObj;
		Object bind(Object originalObj){
			this.originalObj = originalObj;
			return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass(),
					getInterfaces(),this);
		}
		@Override
		public Object invoke(Object proxy, Method method, Object[] args)
				throws Throwable {
			System.out.println("welcome");
			return method.invoke(originalObj, args);
		}
		
	}
	
	public static void main(String[] args) {
		IHello hello = (IHello)new DynamicProxy().bind(new Hello());
		hello.sayHello();
	}
}

上述代碼中,惟一的「黑匣子」就是Proxy.newProxyInstance()方法,除此以外再沒有任何特殊之處。這個方法返回一個實現了IHello的接口。而且代理了new Hello()實例行爲的對象。跟蹤這個方法的源碼,能夠看到程序進行了驗證,優化,緩存,同步,生成字節碼和顯式類加載等操做,前面的步驟並非咱們關注的重點,而最後它調用了sun.misc.ProxyGenerator.generateProxyClass()方法來完成生成字節碼的動做.。

 三、字節碼生成技術與動態代理的實現

相信許多Java開發人員都是用過動態代理,例如 java.lang.reflect.Proxy 或實現過 java.lang.reflect.InvocationHandler 接口。

下面一個例子,在方法前面打印一句「welcome」。

package jvm;

public interface IHello {
    void sayHello();
    void sayHi();
}
package jvm;

public class Hello implements IHello{
    @Override
    public void sayHello() {
        System.out.println("hello world");
    }

    @Override
    public void sayHi() {
        System.out.println("hi world");
    }
}
package jvm;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class DynamicProxy implements InvocationHandler {
    Object originalObj;
    Object bind(Object originalObj) {
        this.originalObj = originalObj;
        return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("welcome");
        return method.invoke(originalObj,args);
    }
}
package jvm;

public class test {
    public static void main(String[] args) {
        IHello hel = (IHello) new DynamicProxy().bind(new Hello());
        hel.sayHello();
        hel.sayHi();
    }
}

運行結果

四、Retrotranslator:跨越JDK版本

把JDK1.5 中編寫的代碼放到 JDK1.4 或 1.3 的環境去部署使用。爲了解決這個問題,一種名爲「Java逆轉移植」的工具(Java Backporting Tools)應運而生,Retrotranslator 是這類工具中較爲出色的一個。

2、實戰:本身動手實現遠程執行功能

咱們將使用前面學到的類加載及虛擬機執行子系統的知識去實如今服務端執行臨時代碼的能力。

一、目標

首先,在實現「在服務端執行臨時代碼」這個需求以前,先明確一下本次實戰的具體目標,咱們但願最終的產品是這樣的:

(1)不依賴JDK版本,能在目前普通使用的JDK中部署。

(2)不改變原有服務端程序的部署,不依賴任何第三方類庫。

(3)不侵入原有程序,即無需改變原程序的任何代碼,也不會對原程序運行帶來任何影響。

(4)「臨時代碼」應當具有足夠的自由度,不須要依賴特定的類或特定的接口。

(5)「臨時代碼」的執行結果能返回客戶端,執行結果能夠包括程序中輸出的信息及拋出的異常等。

二、思路

在程序實現的過程當中,咱們須要解決如下3個問題:

(1) 如何編譯提交到服務器的Java代碼?

(2) 如何執行編譯後的Java代碼?

(3) 如何收集Java代碼的執行結果?

三、實現

第一個類用於實現「同一個類的代碼能夠被屢次加載」這個需求,具體代碼以下:

package org.swift.framework.RemotePlugin;

/**
 * 爲了屢次載入執行類而加入的加載器
 * 把defineClass方法開放出來,只有外部顯式調用的時候纔會使用到loadByte方法
 * 由虛擬機調用時,仍然按照原有的雙親委派規則使用loadClass方法進行加載
 * zww
 */
public class HotSwapClassLoader extends ClassLoader {

    public HotSwapClassLoader() {
        super(HotSwapClassLoader.class.getClassLoader()); //使用父類的加載器
    }

    public Class loadByte(byte[] classByte) {
        return defineClass(null, classByte, 0, classByte.length);
    }

}

HotSwapClassLoader 所作的事情僅僅是公開父類中的defineClass() ,這個類加載器的類查找範圍與它的父類加載器是徹底一致的。

第二個類實現將 java.lang.System 替換爲咱們本身定義的HackSystem 類的過程,它直接修改符合Class 文件格式的 byte[] 數組中的常量池部分,將常量池中指定內容的 CONSTANT_Utf8_info 常量替換爲新的字符串。

package org.swift.framework.RemotePlugin;

/**
 * 修改Class文件,暫時只提供修改常量池常量的功能
 */
public class ClassModifier {

    /**
     * Class文件中常量池的起始偏移
     */
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;

    /**
     * CONSTANT_Utf8_info 常量的tag標誌
     */
    private static final int CONSTANT_Utf8_info = 1;

    /**
     * 常量池中11種常量所佔的長度,CONSTANT_Utf8_info型常量除外,由於不是定長的
     */
    private static final int[] CONSTANT_ITEM_LENGTH = {-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5};

    private static final int u1 = 1;
    private static final int u2 = 2;

    private byte[] classByte;

    public ClassModifier(byte[] classByte) {
        this.classByte = classByte;
    }

    public byte[] modifyUTF8Constant(String oldStr, String newStr) {
        int cpc = getConstantPoolCount();   //常量的數量
        int offset = CONSTANT_POOL_COUNT_INDEX + u2;    //CONSTANT_POOL 起始位置
        for (int i = 0; i < cpc; i++) {
            int tag = ByteUtils.bytes2Int(classByte, offset, u1);   //獲取常量型
            if (tag == CONSTANT_Utf8_info) {    //判斷常量型類型是不是CONSTANT_Utf8_info
                int len = ByteUtils.bytes2Int(classByte, offset + u1, u2);
                offset += (u1 + u2);
                String str = ByteUtils.bytes2String(classByte, offset, len);
                if (str.equalsIgnoreCase(oldStr)) {
                    byte[] strBytes = ByteUtils.string2Bytes(newStr);
                    byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2);
                    classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen);
                    classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes);
                    return classByte;
                } else {
                    offset += len;
                }
            } else {
                offset += CONSTANT_ITEM_LENGTH[tag];
            }
        }
        return classByte;
    }

    /**
     * 獲取常量池中常量的數量
     * @return
     */
    private int getConstantPoolCount() {
        return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
    }

}

ByteUtils 工具的實現:

package org.swift.framework.RemotePlugin;

public class ByteUtils {

    public static int bytes2Int(byte[] b, int start, int len) {
        int sum = 0;
        int end = start + len;
        for (int i = start; i < end; i++) {
            // 由於當系統檢測到byte可能會轉化成int或者說byte與int類型進行運算的時候,
            // 就會將byte的內存空間高位補1(也就是按符號位補位)擴充到32位
            // 若是b[i]爲負數時:例如:10000001 & 11111111  ==》 1111111111111111111111111 10000001 & 11111111 = 000000000000000000000000 10000001
            int n = ((int)b[i]) & 0xff;
            n <<= (--len) * 8;
            sum = n + sum;
        }
        return sum;
    }

    public static byte[] int2Bytes(int value, int len) {
        byte[] b = new byte[len];
        for (int i = 0; i < len; i++) {
            b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff);
        }
        return b;
    }

    public static String bytes2String(byte[] b, int start, int len) {
        return new String(b, start, len);
    }

    public static byte[] string2Bytes(String str) {
        return str.getBytes();
    }

    public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) {
        // ||        |~offset|    ~len   ||      ||
        byte[] newBytes = new byte[originalBytes.length - len + replaceBytes.length];
        System.arraycopy(originalBytes, 0, newBytes, 0, offset); //替換位置以前
        System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length); //替換的位置
        System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.length, originalBytes.length - offset -len); //替換的位置以後
        return newBytes;
    }

}

通過ClassModifier 處理後的 byte[] 數據纔會傳給 HotSwapClassLoader.loadByte() 方法進行類加載

最後一個類就是前面提到的代替 java.lang.System 的 HackSystem ,這個類除了把 out 和 err 兩個靜態變量修改了,其餘都來自於 System類的 public方法。

package org.swift.framework.RemotePlugin;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PrintStream;

/**
 * 爲JavaClass 劫持 java.lang.System 提供支持
 * 除了 out 和 err 外,其他的都直接轉發給 System 處理
 */
public class HackSystem {

    public final static InputStream in = System.in;

    private static ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    public final  static PrintStream out = new PrintStream(buffer);

    public final static  PrintStream err = out;

    public static String getBufferString() {
        return buffer.toString();
    }

    public static void clearBuffer() {
        buffer.reset();
    }

    public static void setSecurityManager(final SecurityManager s) {
        System.setSecurityManager(s);
    }

    public static SecurityManager getSecurityManager() {
        return System.getSecurityManager();
    }

    public static long currentTimeMillis() {
        return System.currentTimeMillis();
    }

    //下面全部的方法都與 java.lang.System 的名稱同樣
    //實現都是字節調System的對應方法
    //因版面緣由,省略其餘方法

}

至此,4個支持類已經講解完畢,咱們來看看最後一個類 JavaClassExecuter ,它是提供外部調用的入口

package org.swift.framework.RemotePlugin;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * JavaClass 執行工具
 */
public class JavaClassExecuter {

    /**
     * 執行外部傳過來的表明一個Java類的byte數組
     * 將輸入類byte數組中表明 java.lang.System的CONTANT_Utf8_info常量修改成劫持後的HackSystem類
     * 執行方法爲該類的 main 方法,輸出結構爲該類向System.out/err輸出的信息
     * @param classByte
     * @return
     */
    public static String execute(byte[] classByte) {
        HackSystem.clearBuffer();
        ClassModifier classModifier = new ClassModifier(classByte);
        //修改Class字節碼,把HackSystem 替代 System
        byte[] modiBytes = classModifier.modifyUTF8Constant("java.lang.System", "org.swift.framework.RemotePlugin.HackSystem");
        HotSwapClassLoader loader = new HotSwapClassLoader();
        Class clazz = loader.loadByte(modiBytes);
        try {
            //調用其main方法
            Method method = clazz.getMethod("main", new Class[] { String[].class});
            method.invoke(null, new String[] {null});
        } catch (Exception e) {
            e.printStackTrace(HackSystem.out);
        }
        return HackSystem.getBufferString();
    }

}

四、驗證

任意寫一個Jaca類,只須要向外System.out 信息便可,同事放到指定路徑 C://TestClass.class ,而後創建一個Jsp 文件,在瀏覽器能夠看到這個類的運行結果。

package org.swift.framework.RemotePlugin;

public class TestClass {
    public static void main(String[] args) {
        System.out.println("this is a test class");
    }
}
<%@ page import="java.lang.*" %>
<%@ page import="java.io.*" %>
<%@ page import="org.swift.framework.RemotePlugin.*" %>
<%
    InputStream is = new FileInputStream("C:/TestClass.class");
    byte[] b = new byte[is.available()];
    is.read(b);
    is.close();

    out.println("<textarea style='width:1000;height:800'>");
    out.println(JavaClassExecuter.execute(b));
    out.println("</textarea>");
%>

其中主要須要學習的就是對 class文件的內容進行修改替換,並能夠正常提供使用。

3、總結

本書中第6~9章介紹了class文件格式,類加載及虛擬機執行引擎及部份內容,這些內容時虛擬機必不可少的組成部分,瞭解了虛擬機如何執行程序,才能更好的理解怎樣才能寫出優秀的代碼。

關於虛擬機執行子系統的介紹到此爲止就結束了,經過這4章的講解,咱們描繪了一個虛擬機應該如何運行class文件的概念模型。對於具體到某個虛擬機的實現,爲了使實現簡單清晰,或者爲了更快的運行速度,在虛擬機內部的運做跟概念模型可能會有很是大的差別,但從最終的執行結果來看應該是一致的。

 

推薦博文

素小暖講JVM

 

鳴謝:特別感謝做者周志明提供的技術支持!

相關文章
相關標籤/搜索