热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

通过源代码研究ASP.NETMVC中的Controller和View(六)

通过源代码研究ASP.NETMVC中的Controller和View(一)通过源代码研究ASP.NETMVC中的Controller和Viewÿ

通过源代码研究ASP.NET MVC中的Controller和View(一)

通过源代码研究ASP.NET MVC中的Controller和View(二)

通过源代码研究ASP.NET MVC中的Controller和View(三)

通过源代码研究ASP.NET MVC中的Controller和View(四)

通过源代码研究ASP.NET MVC中的Controller和View(五)

 

上篇谈到Controller最终把执行的操作外包给了ActionInvoker,其默认实现大体上是这么一个过程:

  • 查找Action(FindAction)
  • 获取参数
  • InvokeActionMethod
  • InvokeActionResult

 

那我先从查找Action入手研究其逻辑,其相关代码如下:

      ControllerDescriptor controllerDescriptor = GetControllerDescriptor( controllerContext );
      ActionDescriptor actionDescriptor = FindAction( controllerContext, controllerDescriptor, actionName );

首先获取了一个Controllerdescriptor,然后借助ControllerDescriptor查找ActionDescriptor。

先来看看这两个类型分别代表什么,从名称来看,应该是控制器和行为的描述符,那么具体描述了一些什么东西呢?

image

GetCustomAttributes和IsDefined三个方法是用于实现ICustomAttributeProvider(实现这个接口用于获取附着在描述对象的特性,System.Reflection下大部分描述元数据的类型都实现了这个接口,如Assembly、MethodInfo等)的,除此之外,主要就是FindAction和GetCanonicalActions(获取经典的行为?)。

继续来看看ActionDescriptor:

image

除去实现ICustomAttributeProvider之外的三个方法,我看到还有这样几个方法:

  • Execute( ControllerContext, IDictionary )
  • GetFilters() : FilterInfo
  • GetParameters() : ParameterDescriptor[]
  • GetSelectors() : ICollection

我发现了一个有趣的事实,ControllerDescriptor通过FindAction方法可以获得一个ActionDescriptor,而ActionDescriptor又可以GetParameters来获取一个ParameterDescriptor的数组。换言之,ControllerDescriptor是一个ActionDescriptor的抽象容器,而ActionDescriptor是一个ParameterDescriptor的抽象容器。从这些名称你能看出啥?

考虑到ControllerDescriptor.ControllerType的存在,我有理由相信,ControllerDescriptor是一个依赖具体类型的描述符,换言之这是一个TypeDescriptor,而从名称来看,ParameterDescriptor应该是参数的描述符,类型描述符包含操作描述符集合,操作描述符包含参数描述符集合。直接推论:ActionDescriptor应该是一个方法描述符(MethodDescriptor),至少是一个被抽象的方法的描述符。它可以传递一些parameter来被Execute,得到一个object。即使ActionDescriptor没有对应某个具体的方法,从GetParamters和Execute来看,它至少可以被当作一个方法来发现、绑定(Bind,利用ParameterDescriptor[])以及调用执行(Execute)。不妨顺便来看看ParameterDescriptor:

image

这个东西看起来的确就是一个参数描述符。思路大体上能够理顺了,那么接下来,是研究实现的时间。

看看GetControllerDescription的实现:

    protected virtual ControllerDescriptor GetControllerDescriptor( ControllerContext controllerContext )
    {
      Type controllerType = controllerContext.Controller.GetType();
      ControllerDescriptor controllerDescriptor = DescriptorCache.GetDescriptor( controllerType, () => new ReflectedControllerDescriptor( controllerType ) );
      return controllerDescriptor;
    }

首先是获取Controller的运行时类型,然后这个DescriptorCache.GetDescriptor从名字和调用上可以大体上猜测,这个方法会首先到缓存中根据controllerType查找有没有缓存的东东,如果没有,就调用后面的匿名方法创建实例返回并缓存,来证实一下:

  internal sealed class ControllerDescriptorCache : ReaderWriterCache<Type, ControllerDescriptor>
  {

    public ControllerDescriptorCache()
    {
    }

    public ControllerDescriptor GetDescriptor( Type controllerType, Func<ControllerDescriptor> creator )
    {
      return FetchOrCreateItem( controllerType, creator );
    }

  }

FetchOrCreateItem这个方法名进一步的证实了猜测&#xff0c;我们继续看这个方法的实现&#xff1a;

    protected TValue FetchOrCreateItem( TKey key, Func creator )
    {
      // first, see if the item already exists in the cache
      _rwLock.EnterReadLock();
      try
      {
        TValue existingEntry;
        if ( _cache.TryGetValue( key, out existingEntry ) )
        {
          return existingEntry;
        }
      }
      finally
      {
        _rwLock.ExitReadLock();
      }

      // insert the new item into the cache
      TValue newEntry &#61; creator();
      _rwLock.EnterWriteLock();
      try
      {
        TValue existingEntry;
        if ( _cache.TryGetValue( key, out existingEntry ) )
        {
          // another thread already inserted an item, so use that one
          return existingEntry;
        }

        _cache[key] &#61; newEntry;
        return newEntry;
      }
      finally
      {
        _rwLock.ExitWriteLock();
      }
    }

结果已经非常明朗&#xff0c;_rwLock的EnterXXX和ExitXXX方法显然是进入和退出读锁以及写锁。去掉这些同步代码看起来就会是这样&#xff1a;

    protected TValue FetchOrCreateItem( TKey key, Func creator )
    {

      TValue existingEntry;
      if ( _cache.TryGetValue( key, out existingEntry ) )
        return existingEntry;

      TValue newEntry &#61; creator();
      _cache[key] &#61; newEntry;
      return newEntry;
    }

现在答案就是一目了然的了。

 

缓存的逻辑并非主线&#xff0c;还是回到GetControllerDescriptor继续分析。根据之前被证实的猜测&#xff0c;最终创建ControllerDescriptor的&#xff0c;就是这个匿名方法&#xff1a;

() &#61;> new ReflectedControllerDescriptor( controllerType )

换言之&#xff0c;其实的GetControllerDescriptor的实现大体上就是这样&#xff1a;

    protected virtual ControllerDescriptor GetControllerDescriptor( ControllerContext controllerContext )
    {
      return new ReflectedControllerDescriptor( controllerContext.Controller.GetType() ) );
    }

创建ControllerDescriptor也就是利用Controller的运行时类型创建一个ReflectedControllerDescriptor的实例而已。这进一步证实了ControllerDescriptor其实是一个TypeDescriptor的猜测。

 

接下来看FindAction的实现&#xff1a;

    protected virtual ActionDescriptor FindAction( ControllerContext controllerContext, ControllerDescriptor controllerDescriptor, string actionName )
    {
      ActionDescriptor actionDescriptor &#61; controllerDescriptor.FindAction( controllerContext, actionName );
      return actionDescriptor;
    }

FindAction啥活儿也没干&#xff0c;直接把工作又外包给了刚创建的ControllerDescriptor对象&#xff0c;我们知道ControllerDescriptor其实是一个ReflectedControllerDescriptor的实例&#xff0c;所以来看看这个实例的实现&#xff1a;

    public override ActionDescriptor FindAction( ControllerContext controllerContext, string actionName )
    {
      if ( controllerContext &#61;&#61; null )
      {
        throw new ArgumentNullException( "controllerContext" );
      }
      if ( String.IsNullOrEmpty( actionName ) )
      {
        throw new ArgumentException( MvcResources.Common_NullOrEmpty, "actionName" );
      }

      MethodInfo matched &#61; _selector.FindActionMethod( controllerContext, actionName );
      if ( matched &#61;&#61; null )
      {
        return null;
      }

      return new ReflectedActionDescriptor( matched, actionName, this );
    }

调用了_selector的FindActionMethod方法来得到一个方法信息&#xff08;MethodInfo&#xff09;然后用这个方法来创建一个ReflectedActionDescriptor的实例。看来刚才的猜测一点没错&#xff0c;ActionDescriptor的确是一个方法的描述符。那么&#xff0c;这个_selector又是什么&#xff1f;

    private readonly ActionMethodSelector _selector;

哈&#xff0c;又引入了一个新的类型ActionMethodSelector&#xff0c;从名字来看&#xff0c;这个类完全是为了Select一个Method而存在的。这个类型没有任何派生类&#xff0c;也不派生自任何类&#xff0c;并且还是一个密封类&#xff08;sealed&#xff09;&#xff0c;职责也非常明确&#xff0c;就是选择ActionMethod&#xff0c;而这个 ActionMethod应该就是我们在控制器中写的什么Index或是 About方法。

还是来看看FindActionMethod的实现&#xff1a;

    public MethodInfo FindActionMethod( ControllerContext controllerContext, string actionName )
    {
      List<MethodInfo> methodsMatchingName &#61; GetMatchingAliasedMethods( controllerContext, actionName );
      methodsMatchingName.AddRange( NonAliasedMethods[actionName] );
      List<MethodInfo> finalMethods &#61; RunSelectionFilters( controllerContext, methodsMatchingName );

      switch ( finalMethods.Count )
      {
        case 0:
          return null;

        case 1:
          return finalMethods[0];

        default:
          throw CreateAmbiguousMatchException( finalMethods, actionName );
      }
    }

先调用了GetMatchingAliasedMethods方法&#xff0c;然后再将这个方法的结果与NonAliasedMethods[actionName]合并&#xff0c;最后RunSelectionFilters&#xff08;运行选择筛选器&#xff09;。最后看获取的方法恰好一个的话就返回。

这里的Matching和Aliased容易把人搞晕&#xff0c;求助谷歌大神&#xff0c;matching是一个形容词&#xff0c;相匹配的意思。aliased谷歌大神也没办法帮我&#xff0c;但我知道alias是别名的意思&#xff0c;推测aliased是alias的过去式&#xff0c;那就是已经alias的意思&#xff0c;或者被alias的意思。也许&#xff0c;就是被别名的意思吧。

所以GetMatchingAliasedMethod的解释就是&#xff1a;获取 相匹配的 被别名的 方法。

呃&#xff0c;&#xff0c;&#xff0c;先不看方法&#xff0c;因为我看到有一个很奇怪的对象叫做NonAliasedMethods&#xff0c;这个东西是哪来的&#xff1f;值是什么&#xff1f;

    public ILookup<string, MethodInfo> NonAliasedMethods
    {
      get;
      private set;
    }

哈&#xff0c;这玩意儿竟然是个ILookup&#xff0c;不常见啊&#xff0c;那么他的值是哪里来的&#xff0c;看看构造函数&#xff1a;

    public ActionMethodSelector( Type controllerType )
    {
      ControllerType &#61; controllerType;
      PopulateLookupTables();
    }

然后&#xff1a;

    private void PopulateLookupTables()
    {
      MethodInfo[] allMethods &#61; ControllerType.GetMethods( BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.Public );
      MethodInfo[] actionMethods &#61; Array.FindAll( allMethods, IsValidActionMethod );

      AliasedMethods &#61; Array.FindAll( actionMethods, IsMethodDecoratedWithAliasingAttribute );
      NonAliasedMethods &#61; actionMethods.Except( AliasedMethods ).ToLookup( method &#61;> method.Name, StringComparer.OrdinalIgnoreCase );
    }

哈&#xff0c;在这里看到了两个熟悉的东西&#xff0c;AliasedMethods和NonAliasedMethods。他们分别是这么来的&#xff1a;

首先allMethods是ControllerType&#xff08;就是传给ControllerDescriptor的Controller.GetType()&#xff0c;具体实现可以自己看源代码&#xff09;的所有公开的实例方法集合。然后对这个集合进行了一次筛选&#xff0c;Array.FindAll其实就类似于Where方法&#xff0c;后面的那个IsValidActionMethod是筛选条件&#xff0c;这个方法的实现是这样的&#xff1a;

    private static bool IsValidActionMethod( MethodInfo methodInfo )
    {
      return !(methodInfo.IsSpecialName ||
               methodInfo.GetBaseDefinition().DeclaringType.IsAssignableFrom( typeof( Controller ) ));
    }

那么这里定义了几种情况不是合法的ActionMethod&#xff08;会被筛掉&#xff09;&#xff1a;

  • 是特殊的名称&#xff08;编译器生成的方法、构造函数等&#xff09;
  • 方法的原始声明类型&#xff08;假设一个类型A有一个虚方法virtual Test&#xff0c;被派生类B重写为override Test&#xff0c;则GetBaseDefinition获取到A中定义的虚方法Test&#xff0c;即为原始声明类型&#xff09;是Controller或是Controller的基类&#xff08;IsAssignableFrom&#xff09;。

简单的说&#xff0c;编译器生成的方法和定义在Controller里面的方法&#xff0c;就不是合法的ActionMethod&#xff0c;除此之外&#xff0c;都是。

结合起来&#xff1a;ControllerType里面所有公开的实例方法&#xff0c;除去编译器生成的、构造函数、Controller及其基类定义的方法及他们的重写之外&#xff0c;剩下的都是ActionMethod&#xff08;看来返回值没什么限制哦&#xff0c;但也许限制不在这里&#xff09;。

 

然后&#xff0c;合法的ActionMethod&#xff08;actionMethods&#xff09;被分成两拨&#xff0c;一拨是满足IsMethodDecoratedWithAliasingAttribute的&#xff08;AliasedMethods&#xff09;&#xff0c;另一拨是剩下的&#xff08;Except&#xff09;。来看看这个名字很长的方法的实现&#xff1a;

    private static bool IsMethodDecoratedWithAliasingAttribute( MethodInfo methodInfo )
    {
      return methodInfo.IsDefined( typeof( ActionNameSelectorAttribute ), true /* inherit */);
    }

如果你记心好的话&#xff0c;应该会记得这个IsDefine刚才出现过&#xff0c;没错&#xff0c;这是ICustomAttributeProvider接口的一个成员。他用于检查方法是否定义了&#xff08;附着了&#xff09;某个类型的特性&#xff0c;这里这个类型是ActionNameSelectorAttribute&#xff0c;后面的true表示如果定义了这个特性类的派生类&#xff08;派生特性&#xff09;也算在内。

 

那么这里的逻辑可以理清了&#xff0c;所有定义了ActionNameSelectorAttribute特性的方法&#xff0c;都是AliasedMethod&#xff08;被别名的方法&#xff09;&#xff0c;除此之外&#xff0c;都是NonAliasedMethod&#xff08;没被别名的方法&#xff09;。

没有被别名的方法会被转换为一个ILookup对象&#xff0c;ILookup说白了&#xff0c;就是GroupBy的结果的可检索Key版本。ILookup首先是一个IEnumerable>&#xff08;继承于它&#xff09;&#xff0c;其次&#xff0c;ILookup提供了一个索引器&#xff0c;用于获取Key等于特定值的IGrouping。下图说明了ILookup&#xff1a;

image

好了&#xff0c;ILookup并不是重点&#xff0c;我看到这里作为Key的是method.Name&#xff08;方法名&#xff09;&#xff0c;并且传入了一个StringComparer.OrdinalIgnoreCase&#xff0c;不区分大小写的字符串比较器。也就是说这里的Key将不区分大小写。

回到FindActionMethod方法&#xff0c;那么NonAliasedMethods[actionName]就可以理解了&#xff0c;由于ILookup的Key是method.Name&#xff0c;所以NonAliasedMethods[actionName]就是获取所有名字和actionName一样的方法&#xff08;不区分大小写&#xff09;。

那么继续来看看GetMatchingAliasMethods的实现&#xff1a;

    internal List<MethodInfo> GetMatchingAliasedMethods( ControllerContext controllerContext, string actionName )
    {
      // find all aliased methods which are opting in to this request
      // to opt in, all attributes defined on the method must return true

      var methods &#61; from methodInfo in AliasedMethods
                    let attrs &#61; (ActionNameSelectorAttribute[]) methodInfo.GetCustomAttributes( typeof( ActionNameSelectorAttribute ), true /* inherit */)
                    where attrs.All( attr &#61;> attr.IsValidName( controllerContext, actionName, methodInfo ) )
                    select methodInfo;
      return methods.ToList();
    }

LINQ表达式的描述性很强&#xff0c;我很喜欢&#xff0c;这段LINQ表达式直接描述是这样的&#xff1a;

从AliasedMethod集合中获取一个个methodInfo&#xff0c;获取methodInfo的ActionNameSelectorAttribute特性集合并命名为attrs&#xff0c;从中所有这些methodInfo中筛选出attrs集合中每一项都满足IsValidName的项。

简单的说&#xff0c;选择AliasedMethod中&#xff0c;所有ActionNameSelectorAttribute特性都满足IsValidName的methodInfo&#xff0c;那么&#xff0c;IsValidName又是什么逻辑&#xff1f;

这个方法在ActionNameSelectorAttribute中是一个抽象方法&#xff0c;这个类只有一个实现类ActionNameAttribute&#xff0c;所以这个方法也就只有一份实现&#xff08;至少在 MVC框架里&#xff09;&#xff1a;

  

ActionNameAttribute:

    public override bool IsValidName( ControllerContext controllerContext, string actionName, MethodInfo methodInfo )
    {
      return String.Equals( actionName, Name, StringComparison.OrdinalIgnoreCase );
    }

那么这里就是简单的比较了一下actionName和自己的Name属性。这个特性干什么用的基本上也就能推导出来了&#xff0c;如果你想给方法取一个别名&#xff08;不用方法名作为actionName&#xff09;&#xff0c;就可以应用这个特性&#xff0c;然后取一个你喜欢的名字。

 

这里的实现似乎存在一个非常明显的Bug&#xff0c;如果我为一个方法取了两个别名&#xff0c;那么这个方法应该就不可能被映射到了。因为这里的判断逻辑是所有&#xff08;All&#xff09;的Attribute都要IsValidName&#xff0c;换言之这个actionName要同时等于两个别名&#xff0c;才会被选择&#xff0c;这显然不可能。所以这里的All应该改为Any才对。

不过事实上&#xff0c;一个方法不能被附着两个ActionNameAttribute&#xff0c;因为这个特性是不能多次应用的&#xff08;在这个类型和基类的AttributeUsage定义了AllowMultiple &#61; false&#xff09;&#xff0c;所以不可能出现两个这样的特性。

 

OK&#xff0c;至此&#xff0c;已经可以完全了解FindActionMethod前段的逻辑了&#xff1a;

  1. 从被取了别名的&#xff08;Aliased&#xff09;方法中找别名&#xff08;ActionNameAttribute.Name&#xff09;与actionName相匹配的方法
  2. 再从没有取别名的方法中找方法名与actionName相匹配的方法
  3. 把这两个结果整合&#xff08;AddRange&#xff09;。
  4. 再运行SelectionFilter&#xff08;选择筛选器&#xff1f;&#xff09;
  5. 最后如果结果集里只有一个方法&#xff0c;那么返回&#xff0c;有多个则异常&#xff0c;没有则返回空。

 

最后来看看选择筛选器干了些什么&#xff1a;

    private static List<MethodInfo> RunSelectionFilters( ControllerContext controllerContext, List<MethodInfo> methodInfos )
    {
      // remove all methods which are opting out of this request
      // to opt out, at least one attribute defined on the method must return false

      List<MethodInfo> matchesWithSelectionAttributes &#61; new List<MethodInfo>();
      List<MethodInfo> matchesWithoutSelectionAttributes &#61; new List<MethodInfo>();

      foreach ( MethodInfo methodInfo in methodInfos )
      {
        ActionMethodSelectorAttribute[] attrs &#61; (ActionMethodSelectorAttribute[]) methodInfo.GetCustomAttributes( typeof( ActionMethodSelectorAttribute ), true /* inherit */);
        if ( attrs.Length &#61;&#61; 0 )
        {
          matchesWithoutSelectionAttributes.Add( methodInfo );
        }
        else if ( attrs.All( attr &#61;> attr.IsValidForRequest( controllerContext, methodInfo ) ) )
        {
          matchesWithSelectionAttributes.Add( methodInfo );
        }
      }

      // if a matching action method had a selection attribute, consider it more specific than a matching action method
      // without a selection attribute
      return ( matchesWithSelectionAttributes.Count > 0 ) ? matchesWithSelectionAttributes : matchesWithoutSelectionAttributes;
    }

首先定义了两个列表&#xff0c;With和Without Selection Attributes&#xff0c;然后遍历所有的方法&#xff0c;获取方法上附着的ActionMethodSelectorAttribute&#xff0c;如果方法上没有这个特性&#xff08;attrs.Length &#61;&#61; 0&#xff09;&#xff0c;那么归入matchesWithoutSelectionAttributes这一拨&#xff0c;如果方法上有这个特性&#xff0c;那么调用特性的IsValidForRequest&#xff0c;为true的归入matchesWithSelectionAttributes这一拨&#xff0c;其他的方法抛弃。

最后&#xff0c;如果With这一拨有任何方法&#xff0c;返回With这一拨&#xff0c;否则返回Without这一拨。

简单的说&#xff1a;

如果有方法附着了ActionMethodSelectorAttribute&#xff0c;而又IsValidForRequest的话&#xff0c;那么就返回这些方法。否则&#xff0c;返回没有附着ActionMethodSelectorAttribute的方法。

当然&#xff0c;ActionMethodSelectorAttribute也是一个抽象类&#xff0c;但他的派生类很多&#xff1a;

image

不过这不要紧&#xff0c;因为我看到了HttpPostAttribute&#xff0c;其实那就是[HttpPost]么&#xff0c;在MVC范例网站的AccountController里面就能看到&#xff1a;

    [HttpPost]
    public ActionResult LogOn( LogOnModel model, string returnUrl )
    {
      if ( ModelState.IsValid )
      {
        if ( MembershipService.ValidateUser( model.UserName, model.Password ) )
        {
          FormsService.SignIn( model.UserName, model.RememberMe );
          if ( !String.IsNullOrEmpty( returnUrl ) )
          {
            return Redirect( returnUrl );
          }
          else

......

我知道HttpPost是用来标识仅当请求是以Post方式提交的时候才调用这个方法&#xff0c;那么这个Attribute的IsValidForRequest的实现则可以简单的检查请求是不是POST提交过来的达到所需要的效果。其实现我瞄了一眼&#xff0c;比较麻烦&#xff0c;就不在这里展开了&#xff0c;还是尽快走主线逻辑吧&#xff0c;这些内容大家如果有兴趣完全可以自行研究。

写在最后&#xff0c;这里的逻辑非常值得注意&#xff0c;由于在SelectionFilters之后&#xff0c;如果方法组中还存在有多个方法&#xff0c;则会直接抛出异常。可以知道&#xff08;最重要结论&#xff09;&#xff1a;

  1. 同名&#xff08;方法名或别名&#xff09;的方法一定要有不同性质的ActionMethodSelectorAttribute&#xff08;没有也算一种性质&#xff09;。
  2. 如果同一个性质的ActionMethodSelectorAttribute被应用到两个同名的方法&#xff0c;当这个Attribute验证通过时&#xff0c;将出错&#xff0c;这很危险&#xff0c;也是容易造成隐患的地方。
  3. MVC框架内所有的这些ActionMethosSelectorAttribute除了AcceptVerbsAttribute之外都是互斥的&#xff08;不可能同时满足&#xff09;&#xff0c;这样的好处是只要两个同名方法没有附着一样类型的特性&#xff0c;就一定不会同时列入候选列表而抛出异常。但如果你自己写了一些ActionMethodSelector与现有的不互斥&#xff0c;你要特别注意会不会有一种特定的情况导致两个同名方法同时满足&#xff0c;这将是很难检出的隐患。
  4. 方法的签名在FindAction的过程中是被无视的&#xff0c;除非你自己写一个ActionMethodSelectorAttribute来判断方法签名与当前请求是否匹配。
  5. 综上所述&#xff0c;没事别整同名的方法。

 

这一篇就到这里了。

 

在结束之前&#xff0c;我分享一个非常搞笑的ActionMethodSelectorAttribute实现&#xff1a;

namespace System.Web.Mvc
{
  using System.Reflection;

  [AttributeUsage( AttributeTargets.Method, AllowMultiple &#61; false, Inherited &#61; true )]
  public sealed class *******Attribute : ActionMethodSelectorAttribute
  {
    public override bool IsValidForRequest( ControllerContext controllerContext, MethodInfo methodInfo )
    {
      return false;
    }
  }
}

这是MVC框架里面的一个类型的源代码&#xff0c;类型的的名字被打上了马赛克&#xff0c;不妨猜猜这个Attribute到底是干啥用的&#xff0c;以及&#xff0c;它的名字是什么。。。。



推荐阅读
author-avatar
赵翠123_797
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有