[原创].NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 原文:[原创].NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇).NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇)   前言:这个系列有段时间没有动了。主要是针对大家的反馈在修改代码。
原文: [原创].NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇)

.NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇)

  前言:这个系列有段时间没有动了。主要是针对大家的反馈在修改代码。在修改的过程中,也有了一些新的体会,这里和大家分享一下,同时也发布一下业务框架的第一个版本。在本篇文章中,学习到的不是仅仅只是代码,而是设计的思想和实现这种思想的方法。在写本篇时有个感触:把一个东西彻底的讲清楚,不容易。希望大家

多提意见。而且在写本篇的时候,我个人也是很兴奋的,至于原因相信大家在看完之后就知道了。J

本篇的议题如下:

1.      打通业务层和数据层

2.      打通方法的选择和实现

3.      再次借鉴.NET Framework设计思想

4.      水到渠成

5.      代码的版本说明

 

  系列文章链接:

 [原创].NET 分布式架构开发实战之一 故事起源

[原创].NET 分布式架构开发实战之二 草稿设计

[原创].NET 分布式架构开发实战之三 数据访问深入一点的思考

[原创].NET 分布式架构开发实战之四 构建从理想和实现之间的桥梁(前篇)

[原创].NET 分布式架构开发实战五 Framework改进篇

[原创].NET 业务框架开发实战之六 DAL的重构

[原创].NET 业务框架开发实战之七 业务层初步构想

[原创].NET 业务框架开发实战之八 业务层Mapping的选择策略

[原创].NET 业务框架开发实战之九 Mapping属性原理和验证规则的实现策略

[原创].NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(前篇)

[原创].NET 业务框架开发实战之十 第一阶段总结,深入浅出,水到渠成(后篇)

 

1.    打通业务层和数据层

首先,回顾之前的文章一直讨论的问题:

1. 如何使得数据层”以不变应万变”。

2. 条件对象如何实现

3. 如何在业务层和数据层之间mapping数据

本篇就告诉大家,如何切切实实的解决上面三个问题。

 

首先,从一个图示开始讲述。

 

 

从上面的图中可以看出,架起在BLL和DAL之前的桥梁的就是中间的那个条件对象。正是因为有了这个,所以上面的提出的问题才得以解决。

下面先从操作上总体来讲述一下这个图的具体流程:

a.       在业务类中创建一个方法。例如在业务类User中,定义如下:

现在只看GetUserByAge这个方法:在方法中构造出一个条件对象,大家第一眼能看出来:是Linq的实现。其实最后的实现只是借用了Linq的思想,仅此而已

b.       解析条件对象。在上面的构造的条件对象中,Age,Name等,都是业务类User的字段,这些字段的值肯定最终是从数据库中的表字段中获取的(或者是通过数据库中的值算出来的),所以在解析条件对象的时候,就要知道这些业务属性对应数据库中哪个表的哪个字段。因此在业务类中声明每个属性的时候就要同时保留它所对应的数据库字段的信息。一旦业务类的属性中保存了这些信息

c.       数据层操作SQL语句。在解析条件对象的时候,就会最终得到相对应的SQL语句,然后在数据层中执行这些SQL语句。

可能上面讲的比较的抽象,因为本篇的要讲述的东西确实比较的多(Word中写了超过了10页),上面讲述的三个步骤也是先按大家有个印象。不是很懂也没有关系。

 

 2. 打通方法的选择和实现

       接下来就是方法的探索和思考,以及实现的过程。我是想带着个大家跟着一起看看,为什么最后会采用这个解决方案的。

首先,就从条件对象开始看起。

在实现条件对象(条件对象和查询对象的区别之前讲过,这里重述一下:查询对象只是条件对象的一个子集,查询对象用来在查询的使用构造查询条件;条件对象不仅仅在查询时构造条件,而且在增加,删除,,修改时候也使用,例如:只修改Name=”admin”的数据,在修改数据库的时候也用了一定的条件。所以条件对象>查询对象)的时候,也是参看了其他开源框架的一些实现(Nhibernate中查询对象的,CSLA)。

同时要明白一点:设计出来的框架是给开发人员使用的,所以要考虑如何使得开发人员最快最好的使用框架,所以要从开发人员的角度看(这一点也是很重要的)。例如在Nhibernate中查询对象,使用的方法如下(仅仅是简单的举例而已):

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
 IListCustomer >  customers  =  _session.CreateCriteria( typeof (Customer))
        .Add(Restrictions.Like(
" Firstname " " Xiaoyang% " ))
        .Add(Restrictions.Between(
" Lastname " " A% " " Y% " ))
        .ListCustomer
> ();
 
其实本系列中的业务框架之前的条件对象的构造也是参看了Nhibernate中查询对象的方法来实现和使用的,如下:

 

ICriteria condition = CriteriaFactory.Create( typeof (ProductBL).Where( " ProductName " , Operation.Equal, " book " );

 因为现在.NET中的开发人员对Linq的一些操作比较的熟悉,而且如果把条件对象的使用方式改为下面的方式:

ICriteria < ProductBL >  condition = CriteriaFactory.Create < ProductBL > (o  =>  o.ProductName  ==   " book " );

   

  那么开发人员的学习成本就几乎为零(因为他们熟悉Linq,如果条件对象也采用这种比较统一的方法实现,他们就可以采用”以此类推”的思想来使用框架),更多的好处我就不说了,大家可以自己体会或者参看本系列之前的文章。

 

接下来就探索实现条件对象的方法(Linq to XXX篇)

熟悉Linq的朋友可以看出:可以使条件对象实现IQueryable接口,然后采用实现linq to XXX的方法,实现自己的Linq Provider。这个方法是否可行,下面,我们就深入linq to XXX的来看一看,看完之后,结果就很清楚了。

首先来看下面两个接口: 

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
   public   interface  IQueryable : IEnumerable {        
        Type ElementType { 
get ; }
        Expression Expression { 
get ; }
        IQueryProvider Provider { 
get ; }
    }

     public   interface  IQueryable < T >  : IEnumerable < T > , IQueryable, IEnumerable {}
 
   public interface IQueryProvider {

        IQueryable CreateQuery(Expression expression);

        IQueryable<TElement> CreateQuery<TElement>(Expression expression);

        object Execute(Expression expression);

        TResult Execute<TResult>(Expression expression);

    }


  

Linq的出现,使得很多东西都和linq扯上了关系:Linq to sql, Linq to Google, Linq to javascript, Linq to Nhibernate...... 所列出来的这些,我们统称为linq to XXX。这些Linq to XXX都是实现了上面两个接口。

下面就通过自己实现linq to sql来举例分析。

从总体来看:linq to sql的本质就是:把操作转换为sql语句,然后用ADO.NET执行,最后把结果转换为实体返回。

其实下面列出了这么多的代码,其中最关键的其实就是QueryProvider中的Execute方法:这个方法负责把你的操作进行解析,其实真正负责解析的QueryTranslator。

 

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
  public   class  Query < T >  : IQueryable < T > , IQueryable, IEnumerable < T > , IEnumerable, IOrderedQueryable < T > , IOrderedQueryable {
        QueryProvider provider;
        Expression expression;
 

        
public  Query(QueryProvider provider) {
            
if  (provider  ==   null ) {
                
throw   new  ArgumentNullException( " provider " );
            }
            
this .provider  =  provider;
            
this .expression  =  Expression.Constant( this );
        }
 

        
public  Query(QueryProvider provider, Expression expression) {
            
if  (provider  ==   null ) {
                
throw   new  ArgumentNullException( " provider " );
            }
            
if  (expression  ==   null ) {
                
throw   new  ArgumentNullException( " expression " );
            }
            
if  ( ! typeof (IQueryable < T > ).IsAssignableFrom(expression.Type)) {
                
throw   new  ArgumentOutOfRangeException( " expression " );
            }
            
this .provider  =  provider; 
            
this .expression  =  expression;
        }
 

        Expression IQueryable.Expression {
            
get  {  return   this .expression; }
        }
 

        Type IQueryable.ElementType {
            
get  {  return   typeof (T); }
        }
 

        IQueryProvider IQueryable.Provider {
            
get  {  return   this .provider; }
        }
 

        
public  IEnumerator < T >  GetEnumerator() {
            
return  ((IEnumerable < T > ) this .provider.Execute( this .expression)).GetEnumerator();
        }
 

        IEnumerator IEnumerable.GetEnumerator() {
            
return  ((IEnumerable) this .provider.Execute( this .expression)).GetEnumerator();
        }
 

        
public   override   string  ToString() {
            
return   this .provider.GetQueryText( this .expression);
        }
    }

 

 

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
public   abstract   class  QueryProvider : IQueryProvider {
        
protected  QueryProvider() {
        }
 

        IQueryable
< S >  IQueryProvider.CreateQuery < S > (Expression expression) {
            
return   new  Query < S > ( this , expression);
        }
 

        IQueryable IQueryProvider.CreateQuery(Expression expression) {
            Type elementType 
=  TypeSystem.GetElementType(expression.Type);
            
try  {
                
return  (IQueryable)Activator.CreateInstance( typeof (Query <> ).MakeGenericType(elementType),  new   object [] {  this , expression });
            }
            
catch  (TargetInvocationException tie) {
                
throw  tie.InnerException;
            }
        }
 

        S IQueryProvider.Execute
< S > (Expression expression) {
            
return  (S) this .Execute(expression);
        }
 

        
object  IQueryProvider.Execute(Expression expression) {
            
return   this .Execute(expression);
        }
 

        
public   abstract   string  GetQueryText(Expression expression);
        
public   abstract   object  Execute(Expression expression);
    }

 

 

 

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
internal   class  QueryTranslator : ExpressionVisitor {
    StringBuilder sb;
 

    
internal  QueryTranslator() {
    }
 

    
internal   string  Translate(Expression expression) {
        
this .sb  =   new  StringBuilder();
        
this .Visit(expression);
        
return   this .sb.ToString();
    }
 

    
private   static  Expression StripQuotes(Expression e) {
        
while  (e.NodeType  ==  ExpressionType.Quote) {
            e 
=  ((UnaryExpression)e).Operand;
        }
        
return  e;
    }
 

    
protected   override  Expression VisitMethodCall(MethodCallExpression m) {
        
if  (m.Method.DeclaringType  ==   typeof (Queryable)  &&  m.Method.Name  ==   " Where " ) {
            sb.Append(
" SELECT * FROM ( " );
            
this .Visit(m.Arguments[ 0 ]);
            sb.Append(
" ) AS T WHERE  " );
            LambdaExpression lambda 
=  (LambdaExpression)StripQuotes(m.Arguments[ 1 ]);
            
this .Visit(lambda.Body);
            
return  m;
        }
        
throw   new  NotSupportedException( string .Format( " The method '{0}' is not supported " , m.Method.Name));
    }
 

    
protected   override  Expression VisitUnary(UnaryExpression u) {
        
switch  (u.NodeType) {
            
case  ExpressionType.Not:
                sb.Append(
"  NOT  " );
                
this .Visit(u.Operand);
                
break ;
            
default :
                
throw   new  NotSupportedException( string .Format( " The unary operator '{0}' is not supported " , u.NodeType));
        }
        
return  u;
    }
 

    
protected   override  Expression VisitBinary(BinaryExpression b) {
        sb.Append(
" ( " );
        
this .Visit(b.Left);
        
switch  (b.NodeType) {
            
case  ExpressionType.And:
                sb.Append(
"  AND  " );
                
break ;
            
case  ExpressionType.Or:
                sb.Append(
"  OR " );
                
break ;
            
case  ExpressionType.Equal:
                sb.Append(
"  =  " );
                
break ;
            
case  ExpressionType.NotEqual:
                sb.Append(
"  <>  " );
                
break ;
            
case  ExpressionType.LessThan:
                sb.Append(
"  <  " );
                
break ;
            
case  ExpressionType.LessThanOrEqual:
                sb.Append(
"  <=  " );
                
break ;
            
case  ExpressionType.GreaterThan:
                sb.Append(
"  >  " );
                
break ;
            
case  ExpressionType.GreaterThanOrEqual:
                sb.Append(
"  >=  " );
                
break ;
            
default :
                
throw   new  NotSupportedException( string .Format( " The binary operator '{0}' is not supported " , b.NodeType));
        }
        
this .Visit(b.Right);
        sb.Append(
" ) " );
        
return  b;
    }
 

    
protected   override  Expression VisitConstant(ConstantExpression c) {
        IQueryable q 
=  c.Value  as  IQueryable;
        
if  (q  !=   null ) {
            
//  assume constant nodes w/ IQueryables are table references
            sb.Append( " SELECT * FROM  " );
            sb.Append(q.ElementType.Name);
        }
        
else   if  (c.Value  ==   null ) {
            sb.Append(
" NULL " );
        }
        
else  {
            
switch  (Type.GetTypeCode(c.Value.GetType())) {
                
case  TypeCode.Boolean:
                    sb.Append(((
bool )c.Value)  ?   1  :  0 );
                    
break ;
                
case  TypeCode.String:
                    sb.Append(
" ' " );
                    sb.Append(c.Value);
                    sb.Append(
" ' " );
                    
break ;
                
case  TypeCode.Object:
                    
throw   new  NotSupportedException( string .Format( " The constant for '{0}' is not supported " , c.Value));
                
default :
                    sb.Append(c.Value);
                    
break ;
            }
        }
        
return  c;
    }
 

    
protected   override  Expression VisitMemberAccess(MemberExpression m) {
        
if  (m.Expression  !=   null   &&  m.Expression.NodeType  ==  ExpressionType.Parameter) {
            sb.Append(m.Member.Name);
            
return  m;
        }
        
throw   new  NotSupportedException( string .Format( " The member '{0}' is not supported " , m.Member.Name));
    }
}

 

 

 

对于实现了IQueryable的对象,在他们进行的每一个操作(也就是调用实现这个接口的类上的方法)其实都没有立刻去执行,而且把进行的操作记录下来了,放在一个称为Expression Tree表达式数的数据结构中,然后再真正执行的时候(就是调用Execute来执行), QueryTranslator对象就遍历表达式树,对操作进行解析,例如linq to sql就是把表达式树中操作解析为对数据库进行的操作,以sql语句的形式体现出来.

 

       当把操作解析为了sql语句之后,就是用ADO.NET的方法来执行SQL操作,然后通过反射,把ADO.NET执行的结果转换为数据实体。如下:       

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
internal   class  ObjectReader < T >  : IEnumerable < T > , IEnumerable  where  T :  class new () {
    Enumerator enumerator;
 

    
internal  ObjectReader(DbDataReader reader) {
        
this .enumerator  =   new  Enumerator(reader);
    }
 

    
public  IEnumerator < T >  GetEnumerator() {
        Enumerator e 
=   this .enumerator;
        
if  (e  ==   null ) {
            
throw   new  InvalidOperationException( " Cannot enumerate more than once " );
        }
        
this .enumerator  =   null ;
        
return  e;
    }
 

    IEnumerator IEnumerable.GetEnumerator() {
        
return   this .GetEnumerator();
    }
 

    
class  Enumerator : IEnumerator < T > , IEnumerator, IDisposable {
        DbDataReader reader;
        FieldInfo[] fields;
        
int [] fieldLookup;
        T current;
 

        
internal  Enumerator(DbDataReader reader) {
            
this .reader  =  reader;
            
this .fields  =   typeof (T).GetFields();
        }
 

        
public  T Current {
            
get  {  return   this .current; }
        }
 

        
object  IEnumerator.Current {
            
get  {  return   this .current; }
        }
 

        
public   bool  MoveNext() {
            
if  ( this .reader.Read()) {
                
if  ( this .fieldLookup  ==   null ) {
                    
this .InitFieldLookup();
                }
                T instance 
=   new  T();
                
for  ( int  i  =   0 , n  =   this .fields.Length; i  <  n; i ++ ) {
                    
int  index  =   this .fieldLookup[i];
                    
if  (index  >=   0 ) {
                        FieldInfo fi 
=   this .fields[i];
                        
if  ( this .reader.IsDBNull(index)) {
                            fi.SetValue(instance, 
null );
                        }
                        
else  {
                            fi.SetValue(instance, 
this .reader.GetValue(index));
                        }
                    }
                }
                
this .current  =  instance;
                
return   true ;
            }
            
return   false ;
        }
 

        
public   void  Reset() {
        }
 

        
public   void  Dispose() {
            
this .reader.Dispose();
        }
 

        
private   void  InitFieldLookup() {
            Dictionary
< string int >  map  =   new  Dictionary < string int > (StringComparer.InvariantCultureIgnoreCase);
            
for  ( int  i  =   0 , n  =   this .reader.FieldCount; i  <  n; i ++ ) {
                map.Add(
this .reader.GetName(i), i);
            }
            
this .fieldLookup  =   new   int [ this .fields.Length];
            
for  ( int  i  =   0 , n  =   this .fields.Length; i  <  n; i ++ ) {
                
int  index;
                
if  (map.TryGetValue( this .fields[i].Name,  out  index)) {
                    
this .fieldLookup[i]  =  index;
                }
                
else  {
                    
this .fieldLookup[i]  =   - 1 ;
                }
            }
        }
    }
}

 

 

       上面简单的介绍了如何实现linq to sql具体的实现代码,大家可以自己过后慢慢的看或者参看我的另外的一个linq系列,现在我们继续后面的话题。 

       现在我们回到之前的话题:条件对象是否可以采用这种方式实现      

       大家看到上面的代码Query<T>其中的T,和Execute方法的返回值。在上面的代码中,如果T是User类,即,Query<User>,那么最后的Execute方法返回的一定会是User的集合。也就是说:Execute方法已经对数据源(这里是数据库)进行了操作,并且把结果以数据实体的形式已经返回了,并且返回的数据实体的类型就是T,例如User。

       但是我们这里的要实现的条件对象只是想把条件构造出来,不是立刻去执行。至于具体的执行数据操作者(DataProvider)可以任意选择的:使用ADO.NET方法,还是EF的方法,还是Nhibernate,都是可以配置的。如下:      

img_405b18b4b6584ae338e0f6ecaf736533.gif 代码
     public  List < User >  GetUserByAge()
        {
            ICriteria
< User >  conditionPerson  =
               CriteriaFactory.Create
< User > ().Where(u  =>  u.Age  <   this .Age).OrderBy < string > (u  =>  u.Name).Skip( 8 ).Take( 8 );
            
return  DataPortal.Query(conditionPerson);
        }

       

  上面的代码中,Create方法就是实例化一个ICriteria<User>,此时我们想做的仅仅只是一件事:把在 ICriteria<User>上的操作记录下来而已。然后把记录下来的结果解析,解析的最终结果就是一条sql命令,然后再给不同的DataProvider去执行。也就是说,在DataPortal内部可以配置用什么方法来执行数据操作:是直接使用ADO.NET执行sql命令,还是把sql命令给Entity Framework...通过配置决定。如果ICriteria<T>是从IQueryable接口进行了继承,那么在ICriteria实现这个结果的过程中就必须要去数据库中进行执行,因为Execute方法返回的是T的集合,而不是sql命令(字符串)。 

       大家可能想到:那就在Execute方法中去实现不同的DataProvider,例如之前的例子在ObjectReader用ADO.NET实现了,那么也可以在ObjectReader中用EF实现数据操作。这个方法确实可以,也很不错。但是这个方法在分布式开发中(特别是在WCF中)有一点的局限性。例如你有一个界面,上面可以有很多的选项,如下:

 

 

  在服务接口那边,你肯定不想定义N多差不多的接口方法:如

GetUserByName( string  username);

GetUserByEmail(
string  email);

 

  或者

GetUserByCondition( string  username, string  password, string  email .....);

 

   这样都是很不灵活的,如果User的属性减少了或者增多了,那么如果要在服务器那边暴露的接口的方法也要修改,这样终究是不好。如下采用下面的方法: 

GetUserByCondition(Critera condition);

  

  其中,Critera是条件对象。那么我们在客户端就可以任意构造条件对象,这个条件对象就把在它上面进行的操作记录下来,然后统一的交给GetUserByCondition方法去服务器解释并执行。此时,这个条件对象就是在客户端生成的,而且这个条件对象此时是不用去数据库中去执行的。如果条件对象是从IQueryable接口继承的,那么在客户端构造完条件对象之后,就要去数据库中执行了,如果再在ObjectReader搞个分布式调用,难度不说,也很别扭,这不是我们所要的。

       所以,综合上面的一些考虑,那么可以确定:条件对象不继承IQueryable接口。但是我们又希望采用类似linq的操作,那么只有自己实现了。 

  本篇就暂时写到这里,因为太长了,所以分为前篇和后篇发布,因为博客园不能在一小时内发两篇,所以后篇将会在9点左右发布。希望大家见谅。

  版权为小洋和博客园所有,,欢迎转载,转载请标明出处给作者。

   http://www.cnblogs.com/yanyangtian

 

目录
相关文章
|
4天前
|
设计模式 开发框架 JavaScript
基于.NET8 + Vue/UniApp前后端分离的快速开发框架,开箱即用!
基于.NET8 + Vue/UniApp前后端分离的快速开发框架,开箱即用!
|
1月前
|
XML JSON API
ServiceStack:不仅仅是一个高性能Web API和微服务框架,更是一站式解决方案——深入解析其多协议支持及简便开发流程,带您体验前所未有的.NET开发效率革命
【10月更文挑战第9天】ServiceStack 是一个高性能的 Web API 和微服务框架,支持 JSON、XML、CSV 等多种数据格式。它简化了 .NET 应用的开发流程,提供了直观的 RESTful 服务构建方式。ServiceStack 支持高并发请求和复杂业务逻辑,安装简单,通过 NuGet 包管理器即可快速集成。示例代码展示了如何创建一个返回当前日期的简单服务,包括定义请求和响应 DTO、实现服务逻辑、配置路由和宿主。ServiceStack 还支持 WebSocket、SignalR 等实时通信协议,具备自动验证、自动过滤器等丰富功能,适合快速搭建高性能、可扩展的服务端应用。
100 3
|
4天前
|
消息中间件 监控 数据可视化
基于.NET开源、功能强大且灵活的工作流引擎框架
基于.NET开源、功能强大且灵活的工作流引擎框架
|
4天前
|
开发框架 网络协议 .NET
C#/.NET/.NET Core优秀项目和框架2024年10月简报
C#/.NET/.NET Core优秀项目和框架2024年10月简报
|
4天前
|
网络协议 Unix Linux
精选2款C#/.NET开源且功能强大的网络通信框架
精选2款C#/.NET开源且功能强大的网络通信框架
|
4天前
|
开发框架 JavaScript 前端开发
2024年全面且功能强大的.NET快速开发框架推荐,效率提升利器!
2024年全面且功能强大的.NET快速开发框架推荐,效率提升利器!
|
4天前
|
网络协议 网络安全 Apache
一个整合性、功能丰富的.NET网络通信框架
一个整合性、功能丰富的.NET网络通信框架
|
7天前
|
消息中间件 开发框架 .NET
.NET 8 强大功能 IHostedService 与 BackgroundService 实战
【11月更文挑战第7天】本文介绍了 ASP.NET Core 中的 `IHostedService` 和 `BackgroundService` 接口及其用途。`IHostedService` 定义了 `StartAsync` 和 `StopAsync` 方法,用于在应用启动和停止时执行异步操作,适用于资源初始化和清理等任务。`BackgroundService` 是 `IHostedService` 的抽象实现,简化了后台任务的编写,通过 `ExecuteAsync` 方法实现长时间运行的任务逻辑。文章还提供了创建和注册这两个服务的实战步骤,帮助开发者在实际项目中应用这些功能。
|
2月前
|
开发框架 前端开发 JavaScript
ASP.NET MVC 教程
ASP.NET 是一个使用 HTML、CSS、JavaScript 和服务器脚本创建网页和网站的开发框架。
41 7
|
2月前
|
存储 开发框架 前端开发
ASP.NET MVC 迅速集成 SignalR
ASP.NET MVC 迅速集成 SignalR
58 0

热门文章

最新文章