• 让UpdatePanel支持上传文件


    客户端通信替换机制

      UpdatePanel从一开始就无法支持AJAX的文件上传方式。Eilon Lipton写了一篇文章解释了这个问题的原因。文章中提供了两个绕开此问题的方法:

    1. 将“上传”按钮设为一个传统的PostBack控件而不是异步PostBack。您可以使用多种方法来这么做:例如将一个按钮放置在UpdatePanel外,将按钮设为某个UpdatePanel的PostBackTrigger,或者调用ScriptManager.RegisterPostBackControl来注册它。
    2. 建立一个不使用ASP.NET AJAX的上传页面,很多站点已经这么做了。

      不过,我们为什么不使UpdatePanel兼容FileUpload控件(<input type="file" />)呢?如果可以这样,一定能够受需要使用UpdatePanel上传文件的用户欢迎。

      我们首先要解决的问题是,找到一种能够将信息发送到服务器端的方法。我们都知道XMLHttpRequest只能发送字符串。在这里,我们使用和其他的异步上传文件的解决方案一样,使用iframe来上传文件。iframe元素是一个非常有用的东西,即使在AJAX这个概念出现之前,它已经被用于制作一些异步更新的效果了。

      其次,我们该如何改变UpdatePanel传输数据的行为?幸亏Microsoft AJAX Library有个灵活的异步通讯层,我们可以方便创建一个UpdatePanelIFrameExecutor来继承Sys.Net.WebRequestExecutor,并且将它交给一个上传文件的WebRequest对象。因此,下面的代码可以作为我们开发组件的第一步:

    Type.registerNamespace("AspNetAjaxExtensions");
     
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor = function(sourceElement)
    {
        AspNetAjaxExtensions.UpdatePanelIFrameExecutor.initializeBase(this);
     
        // ...
    }
     
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor.prototype =
    {
        // ...
    }
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor.registerClass(
        "AspNetAjaxExtensions.UpdatePanelIFrameExecutor",
         Sys.Net.WebRequestExecutor);
     
    AspNetAjaxExtensions.UpdatePanelIFrameExecutor._beginRequestHandler = function(sender, e)
    {
        var inputList = document.getElementsByTagName("input");
        for (var i = 0; i < inputList.length; i++)
        {
            var type = inputList[i].type;
            if (type && type.toUpperCase() == "FILE")
            {
                e.get_request().set_executor(
                    new AspNetAjaxExtensions.UpdatePanelIFrameExecutor(e.get_postBackElement()));

                return;
            }
        }
    }
     
    Sys.Application.add_init(function()
    {
        Sys.WebForms.PageRequestManager.getInstance().add_beginRequest(
            AspNetAjaxExtensions.UpdatePanelIFrameExecutor._beginRequestHandler);
    });

      在上面的代码中,我们在页面初始化时监听了PageRequestManager对象的beginRequest事件。当PageRequestManager触发了一个异步请求时,我们会检查页面上是否有<input type="file" />控件。如果存在的话,则创建一个UpdatePanelIFrameExecutor实例,并分配给即将执行的WebRequest对象。

      根据异步通讯层的实现,WebRequest的作用只是一个保存请求信息的容器,至于如何向服务器端发送信息则完全是Executor的事情了。事实上Executor完全可以不理会WebRequest携带的信息自行处理,而我们的UpdatePanelIFrameExecutor就是这样的玩意儿。它会改变页面上的内容,将信息Post到额外的IFrame中,并且处理从服务器端获得的数据。

     

    服务器端组件

      再来关注服务器端的组件。目前的主要问题是,我们如何让页面(事实上是ScriptManager控件)认为它接收到的是一个异步的回送?ScriptManager控件会在HTTP请求的Header中查找特定的项,但是我们在向IFrame中POST数据时无法修改Header。所以我们必须使用一个方法来“欺骗”ScriptManager。

      目前使用的解决方案是,我们在POST数据之前在页面中隐藏的输入元素(<input type="hidden" />)中放入一个特定的标记,然后我们开发的服务器端组件(我把它叫做UpdatePanelFileUplaod)会在它的Init阶段(OnInit方法)中在Request Body中检查这个标记,然后使用反射来告诉ScriptManager目前的请求为一个异步请求。

      但是事情并不像我们想象的那么简单,让我们在写代码之前来看一个方法:

    internal sealed class PageRequestManager
    {
        // ...
     
        internal void OnInit()
        {
            if (_owner.EnablePartialRendering && !_owner._supportsPartialRenderingSetByUser)
            {
                IHttpBrowserCapabilities browser = _owner.IPage.Request.Browser;
                bool supportsPartialRendering =
                    (browser.W3CDomVersion >= MinimumW3CDomVersion) &&
                    (browser.EcmaScriptVersion >= MinimumEcmaScriptVersion) &&
                    browser.SupportsCallback;
     
                if (supportsPartialRendering)
                {
                    supportsPartialRendering = !EnableLegacyRendering;
                }
                _owner.SupportsPartialRendering = supportsPartialRendering;
            }
     
            if (_owner.IsInAsyncPostBack)
            {
                _owner.IPage.Error += OnPageError;
            }

        }

        ...
    }

      上面这段代码会在ScriptManager的OnInit方法中被调用。请注意最后加粗部分的代码,“_owner”变量是当前页面上的ScriptManager。在页面收到一个真正的异步回送之后,PageRequestManager会响应页面的Error事件,并且将错误信息用它定义的格式输出。如果我们只是修改了ScriptManager的私有field,那么如果在异步回送时出现了一个未捕获的异常,那么页面就会输出客户端未知的内容,导致在客户端解析失败。所以我们必须保证这种情况下的输出和真正的异步回送是相同的,于是我们就可以使用以下的做法来解决错误处理的问题。

    internal static class FileUploadUtility
    {
        public static bool IsInUploadAsyncPostBack(HttpContext context)
        {
            string[] values = context.Request.Params.GetValues("__UpdatePanelUploading__");
     
            if (values == null) return false;
     
            foreach (string value in values)
            {
                if (value == "true")
                {
                    return true;
                }
            }
     
            return false;
        }
    }
     
     
    [PersistChildren(false)]
    [ParseChildren(true)]
    [NonVisualControl]
    public class UpdatePanelFileUpload : Control
    {
        // ScriptManager members;
        private readonly static FieldInfo s_isInAsyncPostBackFieldInfo;
        private readonly static PropertyInfo s_pageRequestManagerPropertyInfo;
     
        // PageRequestManager members;
        private readonly static MethodInfo s_onPageErrorMethodInfo;
     
        static UpdatePanelFileUpload()
        {
            // Omitted: Initializing of the static members for reflection;
            ...
        }
     
        private bool m_pageInitialized = false;
     
        protected override void OnInit(EventArgs e)
        {
            base.OnInit(e);
     
            // Omitted: Initializing UpdatePanelFileUpload control on the page;
            ...

            this.IsInUploadAsyncPostBack = FileUploadUtility.IsInUploadAsyncPostBack(this.Context);
            if (this.IsInUploadAsyncPostBack)
            {
                s_isInAsyncPostBackFieldInfo.SetValue(ScriptManager.GetCurrent(this.Page), true);
                this.Page.Error += (sender, ea) =>
                {
                    s_onPageErrorMethodInfo.Invoke(
                        this.PageRequestManager, new object[] { sender, ea });

                };
            }

        }
     
        public bool IsInUploadAsyncPostBack { get; private set; }
     
        private object m_pageRequestManager;
        private object PageRequestManager
        {
            get
            {
                if (this.m_pageRequestManager == null)
                {
                    this.m_pageRequestManager = s_pageRequestManagerPropertyInfo.GetValue(
                        ScriptManager.GetCurrent(this.Page), null);
                }
     
                return this.m_pageRequestManager;
            }
        }
     
        ...
    }

      这段实现并不复杂。如果Request Body中的“__UpdatePanelUploading__”的值为“true”,我们就会使用反射修改ScirptManager控件中的私有变量“_isInAsyncPostBack”。此后,我们使用了自己定义的匿名方法来监听页面的Error事件,当页面的Error事件被触发时,我们定义的新方法就会将能够正确解析的内容发送给客户端。

      自然,UpdatePanelFileUpload也需要将程序集中内嵌的脚本文件注册到页面中。我为组件添加了一个开关,可以让用户开发人员使用编程的方式来打开/关闭对于AJAX文件上传的支持。这部分实现更为简单:

    public bool Enabled
    {
        get { ... }
        set { ... }
    }
     
    public string ExecuteMethod
    {
        get { ... }
        set { ... }
    }
     
    protected override void OnPreRender(EventArgs e)
    {
        base.OnPreRender(e);
     
        ScriptManager sm = ScriptManager.GetCurrent(this.Page);
        if (sm.IsInAsyncPostBack || !sm.EnablePartialRendering ||
            this.IsInUploadAsyncPostBack || !this.Enabled)
        {
            return;
        }
     
        if (String.IsNullOrEmpty(this.ExecuteMethod))
        {
            throw new ArgumentException("Please provide the ExecuteMethod.");
        }
     
        ScriptReference script = new ScriptReference(
            "AspNetAjaxExtensions.UpdatePanelFileUpload.js",
            this.GetType().Assembly.FullName);
        ScriptManager.GetCurrent(this.Page).Scripts.Add(script);
     
        if (!String.IsNullOrEmpty(this.ExecuteMethod))
        {
            this.Page.ClientScript.RegisterStartupScript(
                this.GetType(),
                "ExecuteMethod",
                "AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm = " + this.ExecuteMethod + ";",
                true);
        }
    }

      从上面的代码中还可以看到一个ExecuteMethod属性,而这个属性最终会被拼接为一段JavaScript并注册到页面中去。这是新版UpdatePanelFileUpload控件的新特点。这个控件最关键的特性是使用iframe来传递和接受数据,而我将实现这个功能完全交由用户来实现。原因如下:

    • 使用iframe进行通信非常复杂也很难写出真正完美的代码,因此将这部分功能转移到控件外部,这样用户就可以自行修改了。
    • 一些AJAX组件提供了使用iframe进行通信的功能(例如jQuery的Form插件),但是控件无法知道用户的应用中是否已经用了其他客户端框架,因此UpdatePanelFileUpload不会与任何特定的客户端框架进行绑定。

      当然,为了方便大家使用,也为了提供一个完整的解决方案,我会提供一个基于jQuery的Form插件的ExecuteMethod。在使用中也可以将其替换为适合您项目的做法,例如swfupload

    客户端组件

      UpdatePanelIFrameExecutor继承了WebRequestExecutor,因此需要实现许多方法和属性。但是我们事实上不用完整地实现所有的成员,因为客户端的异步刷信机制只会访问其中的一部分。以下是异步刷信过程中会使用的成员列表,我们必须正确地实现它们:

    • get_started: 表示一个Executor是否已经开始 了。
    • get_responseAvailable: 表示一个请求是否成功。
    • get_timedOut: 表示一个请求是否超时。
    • get_aborted: 表示一个请求是否被取消了。
    • get_responseData: 获得文本形式的Response Body。 
    • get_statusCode: 获得Response的状态代码
    • executeRequest: 执行一个请求。
    • abort: 停止正在运行的请求。

      UploadPanelIFrameExecutor非常简单,只是定义了一些私有变量:

    AspNetAjaxExtensions.UpdatePanelIFrameExecutor = function(sourceElement)
    {
        AspNetAjaxExtensions.UpdatePanelIFrameExecutor.initializeBase(this);
     
        // for properties
        this._started = false;
        this._responseAvailable = false;
        this._timedOut = false;
        this._aborted = false;
        this._responseData = null;
        this._statusCode = null;
       
        // the element initiated the async postback
        this._sourceElement = sourceElement;
        // the form in the page.
        this._form = Sys.WebForms.PageRequestManager.getInstance()._form;
    }

      对于大部分属性来说,它们的实现只不过是对上面这些私有变量进行读取或写入而已,在此不提。而一个Executor最重要的莫过于它的executeRequest方法,一些回调函数,还有过期定时器之类的逻辑:

    executeRequest : function()
    {
        this._addAdditionalHiddenElements();
     
        var onSuccess = Function.createDelegate(this, this._onSuccess);
        var onFailure = Function.createDelegate(this, this._onFailure);
     
        this._started = true;
     
        var timeout = this._webRequest.get_timeout();
        if (timeout > 0)
        {
            this._timer = window.setTimeout(
                Function.createDelegate(this, this._onTimeout), timeout);

        }
       
        AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm(
            this._form, onSuccess, onFailure);

    },
     
    _addAdditionalHiddenElements : function() { ... },
     
    _removeAdditionalHiddenElements : function() { ... },
     
    _onSuccess : function(responseData)
    {
        this._clearTimer();
        if (this._aborted || this._timedOut) return;
       
        this._statusCode = 200;
        this._responseAvailable = true;
        this._responseData = responseData;
       
        this._removeAdditionalHiddenElements();
        this.get_webRequest().completed(Sys.EventArgs.Empty);
    },
     
    _onFailure : function()
    {
        this._clearTimer();
        if (this._aborted || this._timedOut) return;
       
        this._statusCode = 500;
        this._responseAvailable = false;
       
        this._removeAdditionalHiddenElements();
        this.get_webRequest().completed(Sys.EventArgs.Empty);
    },
     
    abort : function()
    {
        this._aborted = true;
        this._clearTimer();
       
        this._removeAdditionalHiddenElements();
    },
     
    _onTimeout : function()
    {
        this._timedOut = true;

        this._statusCode = 500;
        this._responseAvailable = false;

        this._removeAdditionalHiddenElements();
        this.get_webRequest().completed(Sys.EventArgs.Empty);

    },
     
    _clearTimer : function()
    {
        if (this._timer != null)
        {
            window.clearTimeout(this._timer);
            delete this._timer;
        }
    }

      如果您了解Executor的功能,那么应该很容易看懂上面的代码:executeRequest方法用于发出请求,在executeRequest方法中还会打开一个监听是否超时的定时器,当得到回复或超时后就会调用WebRequest的completed方法(在_onSuccess和_onFailure方法内)进行通知。不过上面这段代码中还有两个特别的方法“_addAddtionalHiddenElements”和“removeAdditionalHiddenElements,从名称上就能看出,这是为这次“异步提交”而准备的额外元素。

      那么我们该创建哪些附加的隐藏输入元素呢?自然我们表示“异步回送”的自定义标记是其中之一,那么剩下的还需要哪些呢?似乎我们只能通过阅读PageRequestManager的代码来找到问题的答案。还好,似乎阅读下面的代码并不困难:

    function Sys$WebForms$PageRequestManager$_onFormSubmit(evt)
    {
        // ...
       
        // Construct the form body
        var formBody = new Sys.StringBuilder();
        formBody.append(this._scriptManagerID + '=' + this._postBackSettings.panelID + '&');
     
        var count = form.elements.length;
        for (var i = 0; i < count; i++)
        {
            // ...
            // Traverse the input elements to construct the form body
            // ...
        }
     
        if (this._additionalInput)
        {
            formBody.append(this._additionalInput);
            this._additionalInput = null;
        }

     
        var request = new Sys.Net.WebRequest();
        // ...
        // prepare the web request object
        // ...
     
        var handler = this._get_eventHandlerList().getHandler("initializeRequest");
        if (handler) {
            var eventArgs = new Sys.WebForms.InitializeRequestEventArgs(
                request, this._postBackSettings.sourceElement);
            handler(this, eventArgs);
            continueSubmit = !eventArgs.get_cancel();
        }
     
        // ...
     
        this._request = request;
        request.invoke();
     
        if (evt) {
            evt.preventDefault();
        }
    }

      请注意加粗部分的代码。可以发现有两种数据需要被添加为隐藏的输入元素。其一是ScriptManager相关的信息(第一部分的红色代码),其二则是变量“_additionalInput”的内容。我们很容易得到前者的值,但是后者的内容究竟是什么呢?我们继续阅读代码:

    function Sys$WebForms$PageRequestManager$_onFormElementClick(evt)
    {
        var element = evt.target;
        if (element.disabled) {
            return;
        }
     
        // Check if the element that was clicked on should cause an async postback
        this._postBackSettings = this._getPostBackSettings(element, element.name);
     
        if (element.name)
        {
            if (element.tagName === 'INPUT')
            {
                var type = element.type;
                if (type === 'submit')
                {
                    this._additionalInput =
                        element.name + '=' + encodeURIComponent(element.value);
                }
                else if (type === 'image')
                {
                    var x = evt.offsetX;
                    var y = evt.offsetY;
                    this._additionalInput =
                        element.name + '.x=' + x + '&' + element.name + '.y=' + y;
                }
            }
            else if ((element.tagName === 'BUTTON') &&
                (element.name.length !== 0) && (element.type === 'submit'))
            {
                this._additionalInput = element.name + '=' + encodeURIComponent(element.value);
            }
        }
    }

      _onFormElmentClick方法会在用户点击form中特定元素时执行。方法会提供变量“_additionalInput”的内容,然后紧接着,我们之前分析过的_onFormSubmit方法会被调用。于是只要我们对WebRequest的body属性进行分析,就能够轻松地得知需要像form中额外添加哪些隐藏输入元素:

    _addHiddenElement : function(name, value)
    {
        var hidden = document.createElement("input");
        hidden.name = name;
        hidden.value = value;
        hidden.type = "hidden";
        this._form.appendChild(hidden);
        Array.add(this._hiddens, hidden);
    },
     
    _addAdditionalHiddenElements : function()
    {
        var prm = Sys.WebForms.PageRequestManager.getInstance();
       
        this._hiddens = [];
       
        this._addHiddenElement(prm._scriptManagerID, prm._postBackSettings.panelID);
        this._addHiddenElement("__UpdatePanelUploading__", "true");
       
        var additionalInput = null;
        var element = this._sourceElement;
       
        if (element.name)
        {
            var requestBody = this.get_webRequest().get_body();
            var index = -1;
           
            if (element.tagName === 'INPUT')
            {
                var type = element.type;
                if (type === 'submit')
                {
                    index = requestBody.lastIndexOf("&" + element.name + "=");
                }
                else if (type === 'image')
                {
                    index = requestBody.lastIndexOf("&" + element.name + ".x=");
                }
            }
            else if ((element.tagName === 'BUTTON') && (element.name.length !== 0) &&
                (element.type === 'submit'))

            {
                index = requestBody.lastIndexOf("&" + element.name + "=");
            }
           
            if (index > 0)
            {
                additionalInput = requestBody.substring(index + 1);
            }
        }
       
        if (additionalInput)
        {
            var inputArray = additionalInput.split("&");
            for (var i = 0; i < inputArray.length; i++)
            {
                var nameValue = inputArray[i].split("=");
                this._addHiddenElement(nameValue[0], decodeURIComponent(nameValue[1]));
            }
        }
    }, 

      至于请求结束(超时或得到结果)后用于清除那些额外元素的方法也就顺理成章了:

    _removeAdditionalHiddenElements : function()
    {
        var hiddens = this._hiddens;
        delete this._hiddens;
       
        for (var i = 0; i < hiddens.length; i++)
        {
            hiddens[i].parentNode.removeChild(hiddens[i]);
        }
       
        hiddens.length = 0;
    },

      至此,我们的客户端组件已经编写完毕了。不过您应该产生疑问:通过IFrame传递数据的代码在哪里啊?我们接下来就来解释这个问题。

    自定义Execute方法

      之前我已经描述过这个组件的一个特点:由于使用IFrame传递数据的逻辑非常复杂,因此我将其与控件的逻辑进行分离,这样用户就可以在需要时对这部分逻辑进行修改。此外这种做法还可以避免UpdatePanelFileUpload与某个特定的JavaScript框架绑定,用户可以选择一个符合自己应用程序的做法来实现这部分逻辑。因此UpdatePanelFileUpload释放出一个属性ExecuteMethod,它会在页面上写上“AspNetAjaxExtensions.UpdatePanelIFrameExecutor._executeForm = ...”这样的代码。而ExecuteMethod会在UpdatePanelIFrameExecutor的executeRequest方法内被调用。ExecuteMethod方法接受三个参数:“form”,“onSuccess”和“onFailure”。第一个参数为需要Post的Form,而后两个参数都为回调函数,供ExecuteMethod在合适的时候调用。

      jQuery的Form插件提供了一个将内容Post到一个IFrame的功能,因此我在这里提供一个基于jQuery的方法作为示例:

    function htmlDecode(s)
    {
        ...
    }
     
    function executeForm(form, onSuccess, onFailure)
    {
        $("#"+ form.id).ajaxSubmit({
            url : form.action,
            type : "POST",
            error : onFailure,
            success: getOnSuccessHandler(onSuccess)});
    }
     
    function getOnSuccessHandler(onSuccess)
    {
        return function(content)
        {
            if (content.startsWith("<PRE>") || content.startsWith("<pre>"))
            {
                content = content.substring(5);
            }
           
            if (content.endsWith("</PRE>") || content.endsWith("</pre>"))
            {
                content = content.substring(0, content.length - 6);
            }
           
            content = htmlDecode(content);
           
            if (content.indexOf(""n") >= 0 && content.indexOf(""r"n") < 0)
            {
                content = content.replace(/"n/g, ""r"n");
            }
           
            onSuccess(content);
        }
    }

      原本以为jQuery的Form插件提供了一个成熟的解决方案,可惜最后发现依旧不够完美。例如会在传输的结果两边加上“<PRE>”和“</PRE>”标签,还会将其中的字符进行编码,这迫使我们在得到结果后还需要进行Html Decod——这在JavaScript中可不是一件容易实现的工作。最后我从网上找了一个JavaScript版本的HTML Decode函数才算解决这个问题。此外还有一个问题就和浏览器密切相关了:IE中的换行字符为“"r"n”,而FireFox中的换行字符为“"n”,因此同样的字符串经过IFrame的传递之后实际就改变了。在普通情况下这不会造成太大问题,不过UpdatePanel客户端的解析逻辑与字符串长度密切相关,因此我们需要将结果中的"n替换成"r"n才能让功能正常运行。同样地,我们在服务器端如果手动输出HTML时,就必须输出"r"n而不是"n。

      经过我的简单测试,这个方法能够支持IE6+以及FireFox 1.5+的浏览器,不过没有测试过Safari或Opera浏览器。理论上,您可以使用更好的办法来替换这个基于jQuery的实现,甚至您可以避免使用IFrame传递的方式,而改用其他的解决方案,例如swfupload。如果您发现示例中的方法有什么问题,或者有更好的做法请联系我。

    控件的使用

      由于UpdatePanelFileUpload控件的工作原理是欺骗ScriptManager,将其修改为普通异步调用的状态,因此我们要尽可能早地做到这一点。所以在使用这个控件时必须将其紧跟着ScirptManager摆放,页面中的ScriptManager和UpdatePanelFileUpload控件之间存在任何其他ASP.NET AJAX控件,就可能会产生一些不可预知的问题。以下是附件中的使用示例:

    <script type="text/C#" runat="server">
        protected void btnUpload_Click(object sender, EventArgs e)
        {
            this.lblFileSize.Text = this.fileUpload.PostedFile.ContentLength.ToString();
        }
    </script>
     
    <form id="form1" runat="server">
        <asp:ScriptManager runat="server" ID="sm">
            <Scripts>
                <asp:ScriptReference Path="Scripts/jquery-1.2.3.js" />
                <asp:ScriptReference Path="Scripts/jquery.form.js" />
            </Scripts>
        </asp:ScriptManager>
        <ajaxExt:UpdatePanelFileUpload ID="UpdatePanelFileUpload1" runat="server"
            ExecuteMethod="executeForm" />
     
        <asp:UpdatePanel runat="server" ID="up1">
            <ContentTemplate>
                <%= DateTime.Now %><br />
                <asp:Label runat="server" ID="lblFileSize" />
            </ContentTemplate>
            <Triggers>
                <asp:AsyncPostBackTrigger ControlID="btnUpload" />
            </Triggers>
        </asp:UpdatePanel>
     
        <asp:FileUpload runat="server" ID="fileUpload" />
        <asp:Button runat="server" ID="btnUpload" Text="Upload"
            onclick="btnUpload_Click" />
    </form>

  • 相关阅读:
    [Clr via C#读书笔记]Cp4类型基础
    [Clr via C#读书笔记]Cp3共享程序集和强命名程
    [Clr via C#读书笔记]Cp2生成打包部署和管理应用程序和类型
    [Clr via C#读书笔记]Cp1CLR执行模型
    试用Markdown来写东西
    字符编码的总结
    常去的网站
    Click Once使用总结
    【LevelDB源码阅读】Slice
    【程序员面试金典】面试题 01.05. 一次编辑
  • 原文地址:https://www.cnblogs.com/EricChan/p/1439338.html
Copyright © 2020-2023  润新知