Environment Switcher 原理解析(注解、Apt娶视、反射晒哄、混淆)

Environment Switcher 是一個在 Android 的開發(fā)和測試階段,運用 Java 注解肪获、APT寝凌、反射、混淆等原理來一鍵切換環(huán)境的工具孝赫。

如果你還不了解 Environment Switcher较木,建議先看一下這篇文章《一鍵切換應(yīng)用環(huán)境工具(EnvironmentSwitcher)了解一下?

本文基于 Environment Switcher 1.4 分析青柄。

Environment Switcher 回顧

用過 Environment Switcher 的人都知道伐债,只需按應(yīng)用中的模塊配置環(huán)境,Environment Switcher 就會自動生成一系列方法致开。例如峰锁,下面的代碼就是配置 Music 模塊的環(huán)境:

public class EnvironmentConfig {
    @Module(alias = "音樂")
    private class Music {
        @Environment(url = "https://www.codexiaomai.top/api/", isRelease = true, alias = "正式")
        private String online;

        @Environment(url = "http://test.codexiaomai.top/api/", alias = "測試")
        private String test;
    }
}

只需要寫這 10 行代碼(包括括號和空行)編譯之后,Environment Switcher 就會自動生成下面包含切換/獲取環(huán)境双戳、添加/移除環(huán)境切換監(jiān)聽事件虹蒋、獲取所有模塊/環(huán)境 等功能在內(nèi)的不到 100 行代碼。

public final class EnvironmentSwitcher {
    
    private static final ArrayList ON_ENVIRONMENT_CHANGE_LISTENERS = new ArrayList<OnEnvironmentChangeListener>();

    private static final ArrayList MODULE_LIST = new ArrayList<ModuleBean>();

    public static final ModuleBean MODULE_MUSIC = new ModuleBean("Music", "音樂");

    private static EnvironmentBean sCurrentMusicEnvironment;

    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);

    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "http://test.codexiaomai.top/api/", "測試", MODULE_MUSIC);

    private static final EnvironmentBean DEFAULT_MUSIC_ENVIRONMENT = MUSIC_ONLINE_ENVIRONMENT;

    static {
        ArrayList<EnvironmentBean> environments;

        MODULE_LIST.add(MODULE_MUSIC);
        environments = new ArrayList<>();
        MODULE_MUSIC.setEnvironments(environments);
        environments.add(MUSIC_ONLINE_ENVIRONMENT);
        environments.add(MUSIC_TEST_ENVIRONMENT);
    }

    public static void addOnEnvironmentChangeListener(OnEnvironmentChangeListener onEnvironmentChangeListener) {
        ON_ENVIRONMENT_CHANGE_LISTENERS.add(onEnvironmentChangeListener);
    }

    public static void removeOnEnvironmentChangeListener(OnEnvironmentChangeListener onEnvironmentChangeListener) {
        ON_ENVIRONMENT_CHANGE_LISTENERS.remove(onEnvironmentChangeListener);
    }

    public static void removeAllOnEnvironmentChangeListener() {
        ON_ENVIRONMENT_CHANGE_LISTENERS.clear();
    }

    private static void onEnvironmentChange(ModuleBean module, EnvironmentBean oldEnvironment, EnvironmentBean newEnvironment) {
        for (Object onEnvironmentChangeListener : ON_ENVIRONMENT_CHANGE_LISTENERS) {
            if (onEnvironmentChangeListener instanceof OnEnvironmentChangeListener) {
                ((OnEnvironmentChangeListener) onEnvironmentChangeListener).onEnvironmentChange(module, oldEnvironment, newEnvironment);
            }
        }
    }

    public static final String getMusicEnvironment(Context context, boolean isDebug) {
        return getMusicEnvironmentBean(context, isDebug).getUrl();
    }

    public static final EnvironmentBean getMusicEnvironmentBean(Context context, boolean isDebug) {
        if (!isDebug) {
            return DEFAULT_MUSIC_ENVIRONMENT;
        }
        if (sCurrentMusicEnvironment == null) {
            android.content.SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + ".environmentswitcher", android.content.Context.MODE_PRIVATE);
            String url = sharedPreferences.getString("musicEnvironmentUrl", DEFAULT_MUSIC_ENVIRONMENT.getUrl());
            String environmentName = sharedPreferences.getString("musicEnvironmentName", DEFAULT_MUSIC_ENVIRONMENT.getName());
            String appAlias = sharedPreferences.getString("musicEnvironmentAlias", DEFAULT_MUSIC_ENVIRONMENT.getAlias());
            for (EnvironmentBean environmentBean : MODULE_MUSIC.getEnvironments()) {
                if (android.text.TextUtils.equals(environmentBean.getUrl(), url)) {
                    sCurrentMusicEnvironment = environmentBean;
                    break;
                }
            }
        }
        return sCurrentMusicEnvironment;
    }

    public static final void setMusicEnvironment(Context context, EnvironmentBean environment) {
        context.getSharedPreferences(context.getPackageName() + ".environmentswitcher", android.content.Context.MODE_PRIVATE).edit()
                .putString("musicEnvironmentUrl", environment.getUrl())
                .putString("musicEnvironmentName", environment.getName())
                .putString("musicEnvironmentAlias", environment.getAlias())
                .apply();
        if (!environment.equals(sCurrentMusicEnvironment)) {
            onEnvironmentChange(MODULE_MUSIC, sCurrentMusicEnvironment, environment);
        }
        sCurrentMusicEnvironment = environment;
    }

    public static ArrayList getModuleList() {
        return MODULE_LIST;
    }
}

除了自動生成上面的代碼外拣技,Environment Switcher 還提供了展示和切換環(huán)境列表的 Activity 頁面千诬。Environment Switcher 為何如此強大?

這是因為它站在四大巨人的肩膀上膏斤,這四大巨人分別是 Java 注解 APT 反射混淆徐绑。相信大家對它們都有所耳聞,現(xiàn)在非常流行的 Retrofit莫辨、Butter Knife GreenDao 等開源庫都使用了它們傲茄,這里就不做過多介紹了。

Environment Switcher 的組成與原理

打開 Environment Switcher 的項目目錄沮榜,我們會看到 Environment Switcher 由base compiler compiler-release environmentswitchersample 五個模塊構(gòu)成盘榨。

  • base:包含所有的注解 @Moduel@Environment ,以及 Java Bean 類:ModuleBean蟆融、EnvironmentBean 草巡,監(jiān)聽事件: OnEnvironmentChangeListener 和一個存儲公共靜態(tài)常量的類:Constants。其他幾個模塊都要依賴這個模塊型酥。
  • compiler:只包含一個類 EnvironmentSwitcherCompiler山憨,在編譯 Debug 版本時利用 APT 處理被注解標記的類和屬性生成 EnvironmentSwitcher.java 文件查乒。
  • compiler-release: 和 compiler 模塊一樣只包含一個類 EnvironmentSwitcherCompiler,在編譯 Release 版本時利用 APT 處理被注解標記的類和屬性生成 EnvironmentSwitcher.java 文件郁竟。
  • environmentswitcher:通過反射原理獲取EnvironmentSwitcher.java 中生成的所有模塊的環(huán)境玛迄,并提供列表展示以及切換環(huán)境功能的 Activity 頁面。
  • sample:Environment Switcher 標準使用方法的示例工程棚亩。

為什么 Debug 版和 Release 版要用不同的注解處理工具

因為測試環(huán)境只在 Debug 和測試階段使用蓖议,在 Release 版本中就只使用正式環(huán)境了,而如果 Release 版本中測試環(huán)境不隱藏就會打包到 apk 中讥蟆,一旦被他人獲取可能會帶來不必要的麻煩或損失勒虾。

如何自動隱藏測試環(huán)境

我們先比較一下 compiler 和 compiler-release 生成的 EnvironmentSwitcher.java 文件主要有什么區(qū)別。其實主要區(qū)別就是生成的 EnvironmentBean 靜態(tài)常量瘸彤,具體區(qū)別如下:

  • Debug 版的 EnvironmentSwitcher.java
    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new  EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);
    
    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "http://test.codexiaomai.top/api/", "測試", MODULE_MUSIC);
    
  • Release 版的 EnvironmentSwitcher.java
    public static final EnvironmentBean MUSIC_ONLINE_ENVIRONMENT = new EnvironmentBean("online", "https://www.codexiaomai.top/api/", "正式", MODULE_MUSIC);
    
    public static final EnvironmentBean MUSIC_TEST_ENVIRONMENT = new EnvironmentBean("test", "", "測試", MODULE_MUSIC);
    

通過比較可以發(fā)現(xiàn)只有一個地方不同从撼,那就是 Release 版中的非正式環(huán)境的具體地址為空字符串,這樣就達到了隱藏測試環(huán)境具體地址的效果钧栖,進而解決了測試環(huán)境泄露的問題。

你可能又要說了婆翔,不要騙我啊拯杠,我在環(huán)境配置類 EnvironmentConfig.java 文件中還寫了測試環(huán)境的地址呢,你看:

@Environment(url = "https://www.codexiaomai.top/api/", isRelease = true, alias = "正式")
private String online;

@Environment(url = "http://test.codexiaomai.top/api/", alias = "測試")
private String test;

先不要急啃奴,我慢慢來給大家解釋潭陪。雖然通過 compiler-release 生成的類中把測試環(huán)境地址隱藏了,但在 EnvironmentConfig.java 中的確還活生生的包含測試地址的代碼最蕾。那這個地方的測試環(huán)境怎么隱藏呢依溯?

這就到了一直還沒有出場的混淆工具上場了。

混淆助我一臂之力

先來簡單回顧一下混淆的作用吧:
1瘟则、壓縮(Shrink):檢測并移除無用的類黎炉、字段、方法和屬性醋拧。
2慷嗜、優(yōu)化(Optimize):對字節(jié)碼進行優(yōu)化,移除無用指令丹壕。
3庆械、混淆(obfuscate):對類、方法菌赖、變量缭乘、屬性進行重命名。
4琉用、預(yù)檢(preverify):對Java代碼進行預(yù)檢堕绩,以確保代碼可以執(zhí)行策幼。

看到我用粗體標記的關(guān)鍵字了吧,Environment Switcher 就是利用 compiler-release 配合混淆工具的移除功能來實現(xiàn)隱藏測試環(huán)境的逛尚。

真的有這么神奇嗎垄惧?是不是真的我們用事實說話。(這里以sample工程為例)

首先通過 Gradle 生成 Release 包绰寞,再對生成的 apk 文件進行反編譯到逊。下圖是反編譯后工程的目錄結(jié)構(gòu):

反編譯包結(jié)構(gòu)

上面的圖片中已經(jīng)很清楚的展示了項目被混淆后的結(jié)構(gòu),至于為什么 EnvironmentSwitcher 包中所有子包和類都沒有混淆滤钱,后面會介紹觉壶。

那么 com.xiaomai.demo 包中被混淆的類都分別對應(yīng)于原工程中哪個文件呢?我們通過查看 EnvironmentSwitcher/sample/build/outputs/mapping/release 目錄下找到 mapping.txt 文件件缸,從中提取主要的信息如下:

com.xiaomai.demo.data.Api -> com.xiaomai.demo.a.a:
com.xiaomai.demo.data.GankResponse -> com.xiaomai.demo.a.b:
com.xiaomai.demo.data.MusicResponse -> com.xiaomai.demo.a.c:
com.xiaomai.demo.fragment.HomeFragment -> com.xiaomai.demo.b.a:
com.xiaomai.demo.fragment.MusicFragment -> com.xiaomai.demo.b.b:
com.xiaomai.demo.fragment.SettingsFragment -> com.xiaomai.demo.b.c:
com.xiaomai.demo.net.AppRetrofit -> com.xiaomai.demo.c.a:
com.xiaomai.demo.MainActivity -> com.xiaomai.demo.MainActivity:

com.xiaomai.environmentswitcher.Constants -> com.xiaomai.environmentswitcher.Constants:
com.xiaomai.environmentswitcher.EnvironmentSwitchActivity -> com.xiaomai.environmentswitcher.EnvironmentSwitchActivity:
com.xiaomai.environmentswitcher.EnvironmentSwitcher -> com.xiaomai.environmentswitcher.EnvironmentSwitcher:
com.xiaomai.environmentswitcher.R -> com.xiaomai.environmentswitcher.R:
com.xiaomai.environmentswitcher.annotation.Environment -> com.xiaomai.environmentswitcher.annotation.Environment:
com.xiaomai.environmentswitcher.annotation.Module -> com.xiaomai.environmentswitcher.annotation.Module:
com.xiaomai.environmentswitcher.bean.EnvironmentBean -> com.xiaomai.environmentswitcher.bean.EnvironmentBean:
com.xiaomai.environmentswitcher.bean.ModuleBean -> com.xiaomai.environmentswitcher.bean.ModuleBean:
com.xiaomai.environmentswitcher.listener.OnEnvironmentChangeListener -> com.xiaomai.environmentswitcher.listener.OnEnvironmentChangeListener:

按照上面的映射關(guān)系铜靶,得到下圖結(jié)果:

為了證明我沒有在 mapping.txt 中遺漏 EnvironmentConfig 類的相關(guān)信息,再貼張圖片:

當我借助搜索工具搜索 EnvironmentConfig 關(guān)鍵字時他炊,提示找不到該關(guān)鍵字争剿,這再次證明了 EnvironmentConfig 被混淆工具移除了。

EnvironmentConfig 能被混淆工具移除的前提是不被其他任何類引用痊末,這也是為什么建議將所有被 @Module@Environment 標注的類或?qū)傩杂?private 修飾的原因蚕苇。這樣能在編寫代碼的階段從根本上杜絕因測試環(huán)境被引用導(dǎo)致無法在混淆時被移除進而導(dǎo)致泄露。

為什么 EnvironmentSwitcher 中的類沒被混淆

用過開源庫或其他第三方非開源SDK的大家都知道凿叠,這些庫或SDK有些會要求我配置混淆規(guī)則涩笤,否則會因混淆導(dǎo)致運行時異常。那么 EnvironmentSwitcer 為什么沒有配置混淆規(guī)則盒件,也沒有被混淆呢蹬碧?

這是因為 Environment Switcher 已經(jīng)幫大家做了這一步,是不是很貼心炒刁?恩沽!Environment Switcher 設(shè)計的目標是:“在保證正常功能的前提下,讓使用者少配置哪怕一行代碼”切心。

那么 Environment Switcher 是怎么做到的呢飒筑?主要就是同過 Gradle 配置的。

  • build.gradle
    android {
        defaultConfig {
            ...
            consumerProguardFiles 'consumer-proguard-rules.pro'
        }
    }
    
  • consumer-proguard-rules.pro
    -dontwarn java.nio.**
    -dontwarn javax.annotation.**
    -dontwarn javax.lang.**
    -dontwarn javax.tools.**
    -dontwarn com.squareup.javapoet.**
    -keep class com.xiaomai.environmentswitcher.** { *; }
    

其實 Environment Switcher 除了幫大家做了混淆規(guī)則配置绽昏,還有很多地方协屡。例如添加依賴配置方面:最初版本的 Environment Switcher 中 Activity 是繼承于 AppCompatActivity,展示環(huán)境列表用的是 RecyclerView全谤,這樣就需要添加 support-v7 包和 recyclerview-v7 包肤晓,依賴方式如下:

implementation "com.android.support:appcompat-v7:$version"
implementation "com.android.support:recyclerview-v7:$version"

為什么這里不指定具體版本而要用 version 代替呢?

因為這個 version 是個 "TroubleMaker"。如果項目中依賴的 support-v7 包和 recyclerview-v7 包與Environment Switcher 中的版本不一致补憾,Android Studio 在編譯時會自動選擇高版本的依賴漫萄,這樣就可能產(chǎn)生兼容性錯誤,導(dǎo)致原本正常的項目因提示錯誤而編譯失敗盈匾。舉個最簡單的例子腾务,在Api 26 中 Fragment 的 onCreateView方法的 LayoutInflater 參數(shù)是可空的,如下所示:

override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    return super.onCreateView(inflater, container, savedInstanceState)
}

而在 Api 27 中卻強制不能為空削饵,如下所示:

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    return super.onCreateView(inflater, container, savedInstanceState)
}

這就導(dǎo)致在編譯時出現(xiàn)錯誤提示 'onCreateView' overrides nothing岩瘦。

其實這種錯誤是有方法解決的,具體方法如下:

implementation ("com.xiaomai.environmentswitcher:environmentswitcher:$version"){
    exclude group: 'com.android.support'
}

這樣在引入 Environment Switcher 時就會移除 Environment Switcher 中的 support 包窿撬,但是總覺得這種方式不夠優(yōu)雅启昧,違背了Environment Switcher 的設(shè)計目標。

于是我把 AppCampatActivity 替換為 Activity劈伴,RecyclerView 替換為 ListView密末。這兩個類都是原生 Sdk 提供的,不需要引入任何依賴跛璧,又完美解決了問題严里。

為了方便開發(fā)者,Environment Switcher 還做了很多努力與嘗試追城,在這里就不一一列舉了田炭。

Environment Switcher 除了可以用來做環(huán)境切換工具,還可以做其他的可配置開關(guān)漓柑,例如:打印日志的開關(guān)。(ps:這不是 Environment Switcher 設(shè)計時的目標功能叨吮,算是一個小彩蛋吧A静肌)

@Module(alias = "日志")
private class Log {
    @Environment(url = "false", isRelease = true, alias = "關(guān)閉日志")
    private String closeLog;
    @Environment(url = "true", alias = "開啟日志")
    private String openLog;
}

public void loge(Context context, String tag, String msg) {
    if (EnvironmentSwitcher.getLogEnvironmentBean(context, BuildConfig.DEBUG)
            .equals(EnvironmentSwitcher.LOG_OPENLOG_ENVIRONMENT)) {
        android.util.Log.e(tag, msg);
    }
}

當然這里只是舉一個簡單的例子,Environment Switcher 能做的遠不止這些茶鉴,更多功能歡迎大家動手嘗試锋玲。

好了,關(guān)于Environment Switcher 的原理解析就到此為止吧涵叮,如果后續(xù) Environment Switcher 更新惭蹂,本文會同步更新。

劃重點

嘿嘿割粮,第一次做開源工具盾碗,如果喜歡 Environment Switcher 歡迎 Star

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末舀瓢,一起剝皮案震驚了整個濱河市廷雅,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖航缀,帶你破解...
    沈念sama閱讀 211,042評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件商架,死亡現(xiàn)場離奇詭異,居然都是意外死亡芥玉,警方通過查閱死者的電腦和手機蛇摸,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,996評論 2 384
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來灿巧,“玉大人赶袄,你說我怎么就攤上這事≡曳常” “怎么了弃鸦?”我有些...
    開封第一講書人閱讀 156,674評論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長幢痘。 經(jīng)常有香客問我唬格,道長,這世上最難降的妖魔是什么颜说? 我笑而不...
    開封第一講書人閱讀 56,340評論 1 283
  • 正文 為了忘掉前任购岗,我火速辦了婚禮,結(jié)果婚禮上门粪,老公的妹妹穿的比我還像新娘喊积。我一直安慰自己,他們只是感情好玄妈,可當我...
    茶點故事閱讀 65,404評論 5 384
  • 文/花漫 我一把揭開白布乾吻。 她就那樣靜靜地躺著,像睡著了一般拟蜻。 火紅的嫁衣襯著肌膚如雪绎签。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,749評論 1 289
  • 那天酝锅,我揣著相機與錄音诡必,去河邊找鬼。 笑死搔扁,一個胖子當著我的面吹牛爸舒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播稿蹲,決...
    沈念sama閱讀 38,902評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼扭勉,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了苛聘?” 一聲冷哼從身側(cè)響起剖效,我...
    開封第一講書人閱讀 37,662評論 0 266
  • 序言:老撾萬榮一對情侶失蹤嫉入,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后璧尸,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體咒林,經(jīng)...
    沈念sama閱讀 44,110評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,451評論 2 325
  • 正文 我和宋清朗相戀三年爷光,在試婚紗的時候發(fā)現(xiàn)自己被綠了垫竞。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,577評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡蛀序,死狀恐怖欢瞪,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情徐裸,我是刑警寧澤遣鼓,帶...
    沈念sama閱讀 34,258評論 4 328
  • 正文 年R本政府宣布,位于F島的核電站重贺,受9級特大地震影響骑祟,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜气笙,卻給世界環(huán)境...
    茶點故事閱讀 39,848評論 3 312
  • 文/蒙蒙 一次企、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧潜圃,春花似錦缸棵、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,726評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至隧出,卻和暖如春型诚,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背鸳劳。 一陣腳步聲響...
    開封第一講書人閱讀 31,952評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留也搓,地道東北人赏廓。 一個月前我還...
    沈念sama閱讀 46,271評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像傍妒,于是被迫代替她去往敵國和親幔摸。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,452評論 2 348

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理颤练,服務(wù)發(fā)現(xiàn)既忆,斷路器,智...
    卡卡羅2017閱讀 134,628評論 18 139
  • 院落清風起,幽幽明月來患雇。 高歌思舊事跃脊,如夢影徘徊。 寶劍勞相贈苛吱,花葉隨手摘酪术。 且聽聲聲雨,階前長青苔翠储。 【2017...
    d03e056874dc閱讀 230評論 0 0
  • 在清晨時分绘雁,一縷陽光透射過薄薄的云霧。而此時援所,也有仙鶴飛舞在空中庐舟,小靈鳥們正在肆意的歌唱,大家的忙碌住拭,就好像是...
    絮絮芬芳閱讀 329評論 0 1
  • 在 phabricator 上新建了一個 Diffusion挪略,Diffusion中配置了一個host模式的倉庫,然...
    小發(fā)條閱讀 1,672評論 0 0
  • 謝安筑埭稱邵伯废酷,蔭下民聲頌甘棠瘟檩。 黎庶知恩心自贊,何須圣跡滿街墻澈蟆。 注:揚州市江都區(qū)邵伯鎮(zhèn)墨辛。別稱,“甘棠”或者是“...
    真老實人_425a閱讀 905評論 0 5