從零開始實(shí)現(xiàn)ASP.NET Core MVC的插件式開發(fā)(六) - 如何加載插件引用

標(biāo)題:從零開始實(shí)現(xiàn)ASP.NET Core MVC的插件式開發(fā)(六) - 如何加載插件引用。
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11717254.html
源代碼:https://github.com/lamondlu/DynamicPlugins

image

前景回顧

簡介

在前一篇中谭企,我給大家演示了如何使用.NET Core 3.0中新引入的AssemblyLoadContext來實(shí)現(xiàn)運(yùn)行時(shí)升級和刪除插件。完成此篇之后饱须,我得到了很多園友的反饋鹦赎,很高興有這么多人能夠參與進(jìn)來,我會根據(jù)大家的反饋丸升,來完善這個項(xiàng)目。本篇呢牺氨,我將主要解決加載插件引用的問題狡耻,這個也是反饋中被問的最多的問題。

問題用例

在之前做的插件中猴凹,我們做的都是非常非常簡單的功能夷狰,沒有引入任何的第三方庫。但是正常情況下郊霎,我們所創(chuàng)建的插件或多或少的都會引用一些第三方庫沼头,那么下面我們來嘗試一下,使用我們先前的項(xiàng)目,加載一個使用第三方程序集, 看看會的得到什么結(jié)果进倍。

這里為了模擬土至,我創(chuàng)建了一個新的類庫項(xiàng)目DemoReferenceLibrary, 并在之前的DemoPlugin1項(xiàng)目中引用DemoReferenceLibrary項(xiàng)目猾昆。

DemoReferenceLibrary中陶因,我新建了一個類Demo.cs文件, 其代碼如下:

    public class Demo
    {
        public string SayHello()
        {
            return "Hello World. Version 1";
        }
    }

這里就是簡單的通過SayHello方法,返回了一個字符串垂蜗。

然后在DemoPlugin1項(xiàng)目中楷扬,我們修改之前創(chuàng)建的Plugin1Controller,從Demo類中通過SayHello方法得到需要在頁面中顯示的字符串么抗。

    [Area("DemoPlugin1")]
    public class Plugin1Controller : Controller
    {
        public IActionResult HelloWorld()
        {
            var content = new Demo().SayHello();
            ViewBag.Content = content;
            return View();
        }
    }

最后我們打包一下插件毅否,重新將其安裝到系統(tǒng)中,訪問插件路由之后蝇刀,就會得到以下錯誤螟加。

image

這里就是大部分同學(xué)遇到的問題,無法加載程序集DemoReferenceLibrary吞琐。

如何加載插件引用捆探?

這個問題的原因很簡單,就是當(dāng)通過AssemblyLoadContext加載程序集的時(shí)候站粟,我們只加載了插件程序集黍图,沒有加載它引用的程序集。

例如奴烙,我們以DemoPlugin1的為例助被,在這個插件的目錄如下

image

在這個目錄中,除了我們熟知的DemoPlugin1.dll,DemoPlugin1.Views.dll之外切诀,還有一個DemoReferenceLibrary.dll文件揩环。 這個文件我們并沒有在插件啟用時(shí)加載到當(dāng)前的AssemblyLoadContext中,所以在訪問插件路由時(shí)幅虑,系統(tǒng)找不到這個組件的dll文件丰滑。

為什么Mystique.Core.dllSystem.Data.SqlClient.dll倒庵、Newtonsoft.Json.dll這些DLL不會出現(xiàn)問題呢褒墨?

在.NET Core中有2種LoadContext。 一種是我們之前介紹的AssemblyLoadContext, 它是一種自定義LoadContext擎宝。 另外一種就是系統(tǒng)默認(rèn)的DefaultLoadContext郁妈。當(dāng)一個.NET Core應(yīng)用啟動的時(shí)候,都會創(chuàng)建并引用一個DefaultLoadContext绍申。

如果沒有指定LoadContext圃庭, 系統(tǒng)默認(rèn)會將程序集都加載到DefaultLoadContext中。這里我們可以查看一下我們的主站點(diǎn)項(xiàng)目,這個項(xiàng)目我們也引用了Mystique.Core.dll剧腻、System.Data.SqlClient.dllNewtonsoft.Json.dll涂屁。

image

在.NET Core的設(shè)計(jì)文檔中书在,對于程序集加載有這樣一段描述

If the assembly was already present in A1's context, either because we had successfully loaded it earlier, or because we failed to load it for some reason, we return the corresponding status (and assembly reference for the success case).

However, if C1 was not found in A1's context, the Load method override in A1's context is invoked.

  • For Custom LoadContext, this override is an opportunity to load an assembly before the fallback (see below) to Default LoadContext is attempted to resolve the load.
  • For Default LoadContext, this override always returns null since Default Context cannot override itself.

這里簡單來說,意思就是當(dāng)在一個自定義LoadContext中加載程序集的時(shí)候拆又,如果找不到這個程序集儒旬,程序會自動去默認(rèn)LoadContext中查找,如果默認(rèn)LoadContext中都找不到帖族,就會返回null栈源。

由此,我們之前的疑問就解決了竖般,這里正是因?yàn)橹髡军c(diǎn)已經(jīng)加載了所需的程序集甚垦,雖然在插件的AssemblyLoadContext中找不到這個程序集,程序依然可以通過默認(rèn)LoadContext來加載程序集涣雕。

那么是不是真的就沒有問題了呢艰亮?

其實(shí)我不是很推薦用以上的方式來加載第三方程序集见妒。主要原因有兩點(diǎn)

  • 不同插件可以引用不同版本的第三方程序集颗管,可能不同版本的第三方程序集實(shí)現(xiàn)不同。 而默認(rèn)LoadContext只能加載一個版本寝蹈,導(dǎo)致總有一個插件引用該程序集的功能失效兑障。
  • 默認(rèn)LoadContext中可能加載的第三方程序集與其他插件都不同侄非,導(dǎo)致其他插件功能引用該程序集的功能失效。

所以這里最正確的方式流译,還是放棄使用默認(rèn)LoadContext加載程序集逞怨,保證每個插件的AssemblyLoadContext都完全加載所需的程序集。

那么如何加載這些第三方程序集呢先蒋?我們下面就來介紹兩種方式

  • 原始方式
  • 使用插件緩存

原始方式

原始方式比較暴力骇钦,我們可以選擇加載插件程序集的同時(shí),加載程序集所在目錄中所有的dll文件竞漾。

這里首先我們創(chuàng)建了一個插件引用庫加載器接口IReferenceLoader眯搭。

    public interface IRefenerceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, 
            string folderName, 
            string excludeFile);
    }

然后我們創(chuàng)建一個默認(rèn)的插件引用庫加載器DefaultReferenceLoader,其代碼如下:

    public class DefaultReferenceLoader : IRefenerceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, 
            string folderName, 
            string excludeFile)
        {
            var streams = new List<Stream>();
            var di = new DirectoryInfo(folderName);
            var allReferences = di.GetFiles("*.dll").Where(p => p.Name != excludeFile);

            foreach (var file in allReferences)
            {
                using (var sr = new StreamReader(file.OpenRead()))
                {
                    context.LoadFromStream(sr.BaseStream);
                }
            }
        }
    }

代碼解釋

  • 這里我是為了排除當(dāng)前已經(jīng)加載插件程序集业岁,所以添加了一個excludeFile參數(shù)鳞仙。
  • folderName即當(dāng)前插件的所在目錄,這里我們通過DirectoryInfo類的GetFiles方法笔时,獲取了當(dāng)前指定folderName目錄中的所有dll文件棍好。
  • 這里我依然通過文件流的方式加載了插件所需的第三方程序集。

完成以上代碼之后,我們還需要修改啟用插件的兩部分代碼

  • [MystiqueStartup.cs] - 程序啟動時(shí)借笙,注入IReferenceLoader服務(wù)扒怖,啟用插件
  • [MvcModuleSetup.cs] - 在插件管理頁面,觸發(fā)啟用插件操作

MystiqueStartup.cs

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {

        ...
            
        services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();

        var mvcBuilder = services.AddMvc();

        var provider = services.BuildServiceProvider();
        using (var scope = provider.CreateScope())
        {
            ...

            foreach (var plugin in allEnabledPlugins)
            {
                var context = new CollectibleAssemblyLoadContext();
                var moduleName = plugin.Name;
                var filePath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}\\{moduleName}.dll";
                var referenceFolderPath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}";

                _presets.Add(filePath);
                using (var fs = new FileStream(filePath, FileMode.Open))
                {
                    var assembly = context.LoadFromStream(fs);
                    loader.LoadStreamsIntoContext(context, 
                          referenceFolderPath,
                          $"{moduleName}.dll");

                   ...
                }
            }
        }

        ...
    }

MvcModuleSetup.cs

    public void EnableModule(string moduleName)
    {
        if (!PluginsLoadContexts.Any(moduleName))
        {
            var context = new CollectibleAssemblyLoadContext();

            var filePath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}\\{moduleName}.dll";
            var referenceFolderPath = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\\{moduleName}";
            using (var fs = new FileStream(filePath, FileMode.Open))
            {
                var assembly = context.LoadFromStream(fs);
                _referenceLoader.LoadStreamsIntoContext(context, 
                      referenceFolderPath, 
                      $"{moduleName}.dll");

                ...
            }
        }
        else
        {
            var context = PluginsLoadContexts.GetContext(moduleName);
            var controllerAssemblyPart = new MystiqueAssemblyPart(context.Assemblies.First());
            _partManager.ApplicationParts.Add(controllerAssemblyPart);
        }

        ResetControllActions();
    }

現(xiàn)在我們重新運(yùn)行之前的項(xiàng)目业稼,并訪問插件1的路由盗痒,你會發(fā)現(xiàn)頁面正常顯示了,并且頁面內(nèi)容也是從DemoReferenceLibrary程序集中加載出來了低散。

image

使用插件緩存

原始方式雖然可以幫助我們成功加載插件引用程序集俯邓,但是它并不效率,如果插件1和插件2引用了相同的程序集熔号,當(dāng)插件1的AssemblyLoadContext加載所有的引用程序集之后稽鞭,插件2會將插件1所干的事情重復(fù)一遍。這并不是我們想要的引镊,我們希望如果多個插件同時(shí)使用了相同的程序集朦蕴,就不需要重復(fù)讀取dll文件了。

如何避免重復(fù)讀取dll文件呢祠乃?這里我們可以使用一個靜態(tài)字典來緩存文件流信息梦重,從而避免重復(fù)讀取dll文件。

如果大家覺著在ASP.NET Core MVC中使用靜態(tài)字典來緩存文件流信息不安全亮瓷,可以改用其他緩存方式琴拧,這里只是為了簡單演示。

這里我們首先創(chuàng)建一個引用程序集緩存容器接口IReferenceContainer嘱支, 其代碼如下:

    public interface IReferenceContainer
    {
        List<CachedReferenceItemKey> GetAll();

        bool Exist(string name, string version);

        void SaveStream(string name, string version, Stream stream);

        Stream GetStream(string name, string version);
    }

代碼解釋

  • GetAll方法會在后續(xù)使用蚓胸,用來獲取系統(tǒng)中加載的所有引用程序集
  • Exist方法判斷了指定版本程序集的文件流是否存在
  • SaveStream是將指定版本的程序集文件流保存到靜態(tài)字典中
  • GetStream是從靜態(tài)字典中拉取指定版本程序集的文件流

然后我們可以創(chuàng)建一個引用程序集緩存容器的默認(rèn)實(shí)現(xiàn)DefaultReferenceContainer類,其代碼如下:

    public class DefaultReferenceContainer : IReferenceContainer
    {
        private static Dictionary<CachedReferenceItemKey, Stream> _cachedReferences = new Dictionary<CachedReferenceItemKey, Stream>();

        public List<CachedReferenceItemKey> GetAll()
        {
            return _cachedReferences.Keys.ToList();
        }

        public bool Exist(string name, string version)
        {
            return _cachedReferences.Keys.Any(p => p.ReferenceName == name
                && p.Version == version);
        }

        public void SaveStream(string name, string version, Stream stream)
        {
            if (Exist(name, version))
            {
                return;
            }

            _cachedReferences.Add(new CachedReferenceItemKey { ReferenceName = name, Version = version }, stream);
        }

        public Stream GetStream(string name, string version)
        {
            var key = _cachedReferences.Keys.FirstOrDefault(p => p.ReferenceName == name
                && p.Version == version);

            if (key != null)
            {
                _cachedReferences[key].Position = 0;
                return _cachedReferences[key];
            }

            return null;
        }
    }

這個類比較簡單除师,我就不做太多解釋了沛膳。

完成了引用緩存容器之后,我修改了之前創(chuàng)建的IReferenceLoader接口汛聚,及其默認(rèn)實(shí)現(xiàn)DefaultReferenceLoader锹安。

    public interface IReferenceLoader
    {
        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, string moduleFolder, Assembly assembly);
    }
    public class DefaultReferenceLoader : IReferenceLoader
    {
        private IReferenceContainer _referenceContainer = null;
        private readonly ILogger<DefaultReferenceLoader> _logger = null;

        public DefaultReferenceLoader(IReferenceContainer referenceContainer, ILogger<DefaultReferenceLoader> logger)
        {
            _referenceContainer = referenceContainer;
            _logger = logger;
        }

        public void LoadStreamsIntoContext(CollectibleAssemblyLoadContext context, string moduleFolder, Assembly assembly)
        {
            var references = assembly.GetReferencedAssemblies();

            foreach (var item in references)
            {
                var name = item.Name;

                var version = item.Version.ToString();

                var stream = _referenceContainer.GetStream(name, version);

                if (stream != null)
                {
                    _logger.LogDebug($"Found the cached reference '{name}' v.{version}");
                    context.LoadFromStream(stream);
                }
                else
                {

                    if (IsSharedFreamwork(name))
                    {
                        continue;
                    }

                    var dllName = $"{name}.dll";
                    var filePath = $"{moduleFolder}\\{dllName}";

                    if (!File.Exists(filePath))
                    {
                        _logger.LogWarning($"The package '{dllName}' is missing.");
                        continue;
                    }

                    using (var fs = new FileStream(filePath, FileMode.Open))
                    {
                        var referenceAssembly = context.LoadFromStream(fs);

                        var memoryStream = new MemoryStream();

                        fs.Position = 0;
                        fs.CopyTo(memoryStream);
                        fs.Position = 0;
                        memoryStream.Position = 0;
                        _referenceContainer.SaveStream(name, version, memoryStream);

                        LoadStreamsIntoContext(context, moduleFolder, referenceAssembly);
                    }
                }
            }
        }

        private bool IsSharedFreamwork(string name)
        {
            return SharedFrameworkConst.SharedFrameworkDLLs.Contains($"{name}.dll");
        }
    }

代碼解釋:

  • 這里LoadStreamsIntoContext方法的assembly參數(shù),即當(dāng)前插件程序集倚舀。
  • 這里我通過GetReferencedAssemblies方法叹哭,獲取了插件程序集引用的所有程序集。
  • 如果引用程序集在引用容器中不存在痕貌,我們就是用文件流加載它风罩,并將其保存到引用容器中, 如果引用程序集已存在于引用容器舵稠,就直接加載到當(dāng)前插件的AssemblyLoadContext中超升。這里為了檢驗(yàn)效果入宦,如果程序集來自緩存,我使用日志組件輸出了一條日志室琢。
  • 由于插件引用的程序集乾闰,有可能是來自Shared Framework, 這種程序集是不需要加載的,所以這里我選擇跳過這類程序集的加載盈滴。(這里我還沒有考慮Self-Contained發(fā)布的情況汹忠,后續(xù)這里可能會更改)

最后我們還是需要修改MystiqueStartup.csMvcModuleSetup.cs中啟用插件的代碼。

MystiqueStartup.cs

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {

        ...
        services.AddSingleton<IReferenceContainer, DefaultReferenceContainer>();
        services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();
        ...

        var mvcBuilder = services.AddMvc();

        var provider = services.BuildServiceProvider();
        using (var scope = provider.CreateScope())
        {
            ...

            foreach (var plugin in allEnabledPlugins)
            {
                ...
               
                using (var fs = new FileStream(filePath, FileMode.Open))
                {
                    var assembly = context.LoadFromStream(fs);
                    loader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);

                    ...
                }
            }
        }

        ...
    }

MvcModuleSetup.cs

    public void EnableModule(string moduleName)
    {
        if (!PluginsLoadContexts.Any(moduleName))
        {
            ...
            using (var fs = new FileStream(filePath, FileMode.Open))
            {
                var assembly = context.LoadFromStream(fs);
                _referenceLoader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);
               ...
            }
        }
        else
        {
            ...
        }

        ResetControllActions();
    }

完成代碼之后雹熬,為了檢驗(yàn)效果,我創(chuàng)建了另外一個插件DemoPlugin2, 這個項(xiàng)目的代碼和DemoPlugin1基本一樣谣膳。程序啟動時(shí)竿报,你會發(fā)現(xiàn)DemoPlugin2所使用的引用程序集都是從緩存中加載的,而且DemoPlugin2的路由也能正常訪問继谚。

image

添加頁面來顯示加載的第三方程序集

這里為了顯示一下系統(tǒng)中加載了哪些程序集烈菌,我添加了一個新頁面Assembilies, 這個頁面就是調(diào)用了IReferenceContainer接口中定義的GetAll方法,顯示了靜態(tài)字典中花履,所有加載的程序集芽世。

效果如下:

image

幾個測試場景

最后,在編寫完成以上代碼功能之后诡壁,我們使用以下幾種場景來測試一下济瓢,看一看AssemblyLoadContext為我們提供的強(qiáng)大功能。

場景1

2個插件妹卿,一個引用DemoReferenceLibrary的1.0.0.0版本旺矾,另外一個引用DemoReferenceLibrary的1.0.1.0版本。其中1.0.0.0版本夺克,SayHello方法返回的字符串是"Hello World. Version 1", 1.0.1.0版本箕宙, SayHello方法返回的字符串是“Hello World. Version 2”。

image

啟動項(xiàng)目铺纽,安裝插件1和插件2柬帕,分別運(yùn)行插件1和插件2的路由,你會得到不同的結(jié)果狡门。這說明AssemblyLoadContext為我們做了很好的隔離陷寝,插件1和插件2雖然引用了相同插件的不同版本,但是互相之間完全沒有影響融撞。

場景2

當(dāng)2個插件使用了相同的第三方庫盼铁,并加載完成之后,禁用插件1尝偎。雖然他們引用的程序集相同饶火,但是你會發(fā)現(xiàn)插件2還是能夠正常訪問鹏控,這說明插件1的AssemblyLoadContext的釋放,對插件2的AssemblyLoadContext完全沒有影響肤寝。

image

總結(jié)

本篇我為大家介紹了如何解決插件引用程序集的加載問題当辐,這里我們講解了兩種方式,原始方式和緩存方式鲤看。這兩種方式的最終效果雖然相同缘揪,但是緩存方式的效率明顯更高。后續(xù)我會根據(jù)反饋义桂,繼續(xù)添加新內(nèi)容找筝,大家敬請期待。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末慷吊,一起剝皮案震驚了整個濱河市袖裕,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌溉瓶,老刑警劉巖急鳄,帶你破解...
    沈念sama閱讀 218,451評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異堰酿,居然都是意外死亡疾宏,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,172評論 3 394
  • 文/潘曉璐 我一進(jìn)店門触创,熙熙樓的掌柜王于貴愁眉苦臉地迎上來坎藐,“玉大人,你說我怎么就攤上這事嗅榕∷骋” “怎么了?”我有些...
    開封第一講書人閱讀 164,782評論 0 354
  • 文/不壞的土叔 我叫張陵凌那,是天一觀的道長兼雄。 經(jīng)常有香客問我,道長帽蝶,這世上最難降的妖魔是什么赦肋? 我笑而不...
    開封第一講書人閱讀 58,709評論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮励稳,結(jié)果婚禮上佃乘,老公的妹妹穿的比我還像新娘。我一直安慰自己驹尼,他們只是感情好趣避,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,733評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著新翎,像睡著了一般程帕。 火紅的嫁衣襯著肌膚如雪住练。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,578評論 1 305
  • 那天愁拭,我揣著相機(jī)與錄音讲逛,去河邊找鬼。 笑死岭埠,一個胖子當(dāng)著我的面吹牛盏混,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播惜论,決...
    沈念sama閱讀 40,320評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼许赃,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了馆类?” 一聲冷哼從身側(cè)響起图焰,我...
    開封第一講書人閱讀 39,241評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蹦掐,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體僵闯,經(jīng)...
    沈念sama閱讀 45,686評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡卧抗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,878評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了鳖粟。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片社裆。...
    茶點(diǎn)故事閱讀 39,992評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖向图,靈堂內(nèi)的尸體忽然破棺而出泳秀,到底是詐尸還是另有隱情,我是刑警寧澤榄攀,帶...
    沈念sama閱讀 35,715評論 5 346
  • 正文 年R本政府宣布嗜傅,位于F島的核電站,受9級特大地震影響檩赢,放射性物質(zhì)發(fā)生泄漏吕嘀。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,336評論 3 330
  • 文/蒙蒙 一贞瞒、第九天 我趴在偏房一處隱蔽的房頂上張望偶房。 院中可真熱鬧,春花似錦军浆、人聲如沸棕洋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,912評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽掰盘。三九已至摄悯,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間庆杜,已是汗流浹背射众。 一陣腳步聲響...
    開封第一講書人閱讀 33,040評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留晃财,地道東北人叨橱。 一個月前我還...
    沈念sama閱讀 48,173評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像断盛,于是被迫代替她去往敵國和親罗洗。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,947評論 2 355

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