您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 Code iProcess 课程 角色 咨询 工具 火云堂 讲座吧   成长之路  
会员   
 
   
 
  
每天15篇文章
不仅获得谋生技能
更可以追随信仰
 
 
     
   
 订阅
  捐助
ADO.NET实现应用程序数据访问层
 
作者 CardCaptorSakur的博客,火龙果软件    发布于 2014-06-18
 

[导读]实现数据访问功能是大多数使用.net Framework的开发人员的核心工作,并且由他们生成的数据访问层是其应用程序必不可少的组成部分。本文概述了五个建议,希望您在使用Visual Studio .NET和.NET Framework生成数据访问层时予以考虑。这些技巧包括通过使用基类来利用面向对象的技术和.NET Framework基础结构,通过遵循某些准则使类变得易于继承,以及在就表示方法和外部接口进行决策之前仔细分析自己的需要。

如果您要针对Microsoft .NET Framework来开发以数据为中心的应用程序,那么您终将需要创建数据访问层(DAL)。您可能知道在.NET Framework中生成代码带来的一些好处。因为它同时支持实现和接口继承,所以您的代码可以具有更高的可重用性,尤其是可供您的组织中那些使用与Framework兼容的其他编程语言的开发人员重用。在本文中,我将介绍为了针对基于.NET Framework的应用程序而开发DAL的五个规则。在开始之前,我要说明的是,基于本文中讨论的规则生成的任何DAL都将与Windows平台上的开发人员所喜爱的传统的多层或N层应用程序兼容。在该体系结构中,表示层由对协调数据访问层工作的业务层进行调用的Web窗体、Windows窗体或XML Web Service代码组成。该层包含多个数据访问类。另外,在不需要进行业务处理协调的情况下,表示层可能会直接对DAL进行调用。该体系结构是传统的模型-视图-控制器(MVC)模式的变体,并且在许多方面由Visual Studio.NET及其公开的控件所采用。

规则1:使用面向对象的功能

最基础的面向对象的任务是使用继承的实现来创建抽象基类,该基类可以包含所有数据访问类可以通过继承使用的服务。如果这些服务足够通用,则可以通过在整个组织中分发基类来对它们进行重用。例如,在最简单的情况下,基类可以为派生类完成连接对象的创建,如图1所示。

Imports System.Data.SqlClient

Namespace ACME.Data

Public MustInherit Class DALBase : Implements IDisposable

 Private _connection As SqlConnection

 Protected Sub New(ByVal connect As String)
  _connection = New SqlConnection(connect)
 End Sub

 Protected ReadOnly Property Connection() As SqlConnection
 Get
  Return _connection
 End Get
End Property

Public Sub Dispose() Implements IDisposable.Dispose
 _connection.Dispose()
End Sub

End Class

End Namespace

图1 简单的基类

正如您在该图中看到的那样,DALBase类被标记为MustInherit(在C#中为abstract),以确保它用于继承关系。该类随后会包含一个在公共构造函数(它接受连接字符串作为参数)中实例化的私有SqlConnection对象。然后,受保护的Connection属性允许派生类访问该连接对象,而IDisposable接口中的Dispose方法则确保该连接对象得以被处理。即使是在下面这个简化的示例中,您也可以从中注意到抽象基类的用处:

Public Class WebData : Inherits DALBase
Public Sub New()
 MyBase.New(ConfigurationSettings.AppSettings("ConnectString"))
End Sub

Public Function GetOrders() As DataSet
 Dim da As New SqlDataAdapter("usp_GetOrders", Me.Connection)
 da.SelectCommand.CommandType = CommandType.StoredProcedure
 Dim ds As New DataSet()
 da.Fill(ds)
 Return ds
End Function
End Class

在该示例中,WebData类继承自DALBase,因此它不需要考虑实例化SqlConnection对象的问题,而只需通过MyBase关键字(或C#中的base关键字)将连接字符串传递给基类。WebData类的GetOrders方法可以使用Me.Connection(在C#中为this.Connection)访问受保护的属性。尽管该示例相对简单,但如果您看了规则2和规则3的话,就会发现该基类还可以提供其他服务。

当DAL需要在COM+环境中运行时,抽象基类尤其有用。在这种情况下,因为允许组件使用COM+所需的代码更为复杂,所以创建一个如图2中所示的服务组件基类是有意义的。

<ConstructionEnabled(True), _
Transaction(TransactionOption.Supported), _
EventTrackingEnabled(True)> _
Public MustInherit Class DALServicedBase : Inherits ServicedComponent

Private _connection As SqlConnection

 Protected Overrides Sub Construct(ByVal s As String)
  _connection = New SqlConnection(s)
 End Sub

 Protected ReadOnly Property Connection() As SqlConnection
 Get
  Return _connection
 End Get
End Property

End Class

图2 服务组件基类

在该代码中,DALServicedBase类基本上包含了与图1中相同的功能,但是它另外继承了System.EntERPriseServices命名空间中的ServicedComponent,并且包含了一些属性以指明该组件支持对象结构、事务和统计信息跟踪。然后,该基类负责捕捉在组件服务管理器中配置的结构字符串,并再一次创建和公开SqlConnection对象。需要注意的是,当一个类从DALServicedBase继承时,它还将继承属性的设置。换句话说,派生类也会将它的事务选项设置为Supported。如果该派生类想要重写该行为,则它可以在类级别重新定义该属性。此外,派生类还应该在适当位置对自身利用重载方法和共享方法。主要有两种使用重载方法(具有多个签名的单个方法)的情况。第一,当方法需要接收改变其类型的参数时,可以使用它们。在Framework中,这一类型的典型示例是System.Convert类的方法。例如,ToString方法包括18个接收一个参数的重载方法,每个方法都具有不同的类型。第二,重载方法可以用来公开参数数量不断增加(但不一定是不同类型的参数)的签名。这种类型的重载证明在DAL中非常有效,因为可以使用它来公开用于数据检索和修改的备用签名。例如,可以重载GetOrders方法以便一个签名不接收任何参数并返回所有订单,而另一个签名则接收一个表明调用方只打算检索特定客户订单的参数,如下面的代码所示:

Public Overloads Function GetOrders() As DataSet
Public Overloads Function GetOrders(ByVal customerId As Integer) As DataSet

在这种情况下,良好的实现技巧是将GetOrders方法的功能抽象到一个可以由每个重载签名调用的私有或受保护的方法中。还可以使用共享方法(在C#中为static方法)来展开可供数据访问类的所有实例访问的字段、属性和方法。尽管不能将共享成员与使用组件服务的类结合使用,但是对于可以在数据访问类的共享构造函数中检索然后被所有实例读取的只读数据来说,它们可能十分有用。在对读/写数据使用共享成员时要特别小心,这是因为多个执行线程可能会竞争使用对共享数据的访问权。

规则2:遵守设计准则

在Visual Studio .net随附的联机文档中,有一个标题为“Design Guidelines for Class Library Developers”的主题,它不仅论述您应该遵循的重载成员、构造函数和事件的模式,而且还讨论了类、属性和方法的命名约定。您应该遵守命名约定的主要原因之一是.NET Framework提供的交叉语言继承。如果您要在Visual Basic .NET中生成一个DAL基类,则您需要确保那些使用与.NET Framework兼容的其他语言的开发人员可以从它继承,并能容易地理解它的工作方式。按照我概述过的准则去做,那么您的命名约定和结构将不会是特定于语言的了。举个例子,您会在本文的代码示例中注意到,Camel大小写风格(首个单词小写,并夹杂大写字母)用于方法的参数,Pascal大小写风格(每个单词都大写)用于方法,而基类则具有Base后缀以表示它是一个抽象类。.NET Framework设计准则的必然结果是常规设计模式,就像Gang of Four撰写的Design Patterns(Addison-Wesley, 1995)中所介绍的那些设计模式一样。例如,.NET Framework使用了Observer模式的一个名为Event模式的变体(您在类中公开事件时应当遵循该模式)。

规则3:利用基础结构

.NET Framework包含一些可以帮助处理与基础结构相关的一般性任务(例如,检测和异常处理)的类和结构,通过基类将这些概念与继承相结合可能十分有用。例如,请考虑在System.Diagnostics命名空间中公开的跟踪功能。除了Trace和Debug类,该命名空间还包括从Switch和TraceListener派生的类。Switch类—BooleanSwitch和TraceSwitch,可以通过编程方式以及通过应用程序的配置文件而被配置为打开和关闭。就TraceSwitch而言,可以公开多个级别的跟踪。TraceListener类—TextWriterTraceListener和EventLogTraceListener将Trace和Debug方法的输出分别定向到文本文件和事件日志。因此,您可以将跟踪功能添加到基类中,以便派生类可以轻松地记录消息。继而,应用程序可以使用应用程序配置文件来控制是否启用跟踪。您可以通过包含一个BooleanSwitch类型的私有变量并在构造函数中将其实例化,以将该功能添加到图1所示的DALBase类中:

Public Sub New(ByVal connect As String)
 _connection = New SqlConnection(connect)
 _dalSwitch = New BooleanSwitch("DAL", "Data Access Code")
End Sub

BooleanSwitch的参数包括它的名称和说明。您随后可以添加一个受保护属性,以便将开关打开和关闭,并添加另一个受保护属性,以便使用Trace对象的WriteLineIf方法来格式化和写入跟踪消息:

Protected Property TracingEnabled() As Boolean
 Get
  Return _dalSwitch.Enabled
 End Get
 Set(ByVal Value As Boolean)
  _dalSwitch.Enabled = Value
 End Set
End Property

Protected Sub WriteTrace(ByVal message As String)
 Trace.WriteLineIf(Me.TracingEnabled, Now & ": " & message)
End Sub

这样,派生类无须自己了解开关类和侦听器类,就可以在数据访问类中发生重大事件时轻松地调用WriteTrace方法。

<?XML version="1.0" encoding="utf-8" ?>
<configuration>
<system.diagnostics>
<switches>
<add name="DAL" value="1" />
</switches>
<trace autoflush="true" indentsize="4">
<listeners>
<add name="myListener" type="System.Diagnostics.TextWriterTraceListener" initializeData="DALLog.txt"/>
</listeners>
</trace>
</system.diagnostics>
</configuration>

图3 跟踪配置

要创建侦听器并将其打开,还可以使用应用程序配置文件。图3显示了一个简单的配置文件,它打开刚刚显示的DAL开关,并通过名为myListener的TextWriterTraceListener将它的输出定向到DALLog.txt文件。当然,您还可以通过从TraceListener类派生并将侦听器直接包含在DAL中,来通过编程方式创建自己的侦听器。

Public Class DALException : Inherits ApplicationException

 Public Sub New()
  MyBase.New()
 End Sub

 Public Sub New(ByVal message As String)
  MyBase.New(message)
 End Sub

 Public Sub New(ByVal message As String, ByVal innerException As Exception)
  MyBase.New(message, innerException)
 End Sub

 ' Add custom members here
 Public ConnectString As String
End Class

图4 自定义异常类

毫无疑问,您应该利用的另一个基础结构是结构化异常处理(SEH)。在最基本的级别,DAL可以公开它自己从System.ApplicationException继承的Exception对象,并且还可以公开自定义成员。例如,图4中所示的DALException对象可以用来包装由数据访问类中的代码引发的异常。然后,基类可以公开一个受保护的方法以包装异常、填充自定义成员并将其传回调用方,如下所示:

Protected Sub ThrowDALException(ByVal message As String, _
ByVal innerException As Exception)

Dim newMine As New DALException(message, innerException)

newMine.ConnectString = Me.Connection.ConnectionString
Me.WriteTrace(message & "{" & innerException.Message & "}")
Throw newMine
End Sub

这样,派生类可以轻松地调用受保护的方法,传入所截获的特定于数据的异常(通常为SqlException或OleDbException),并添加一个与特定数据域有关的消息。基类在DALException中包装该异常,并将其传回调用方。这使得调用方可以使用单个Catch语句轻松地捕获来自DAL的所有异常。有关信息,请参阅MSDN上发布的 《Exception Management Application Block Overview》一文(http://msdn.microsoft.com/library/default.ASP?url=/library/en-us/dnbda/html/emab-rm.asp)。该框架通过一组对象将异常的发布与应用程序日志耦合在一起。实际上,您可以通过从.NET Framework提供的BaseApplicationException类继承自己的自定义异常类,来将它们插入到该框架中。

规则4:仔细选择外部接口

当您设计数据访问类的方法时,需要考虑它们将如何接收和返回数据。对于大多数开发人员而言,有三种主要选择:直接使用ADO.NET对象、使用XML和使用自定义类。如果您要直接公开ADO.net对象,则可以利用两个编程模型中的一个。第一个模型包含DataSet和DataTable对象,它们对于断开连接的数据访问很有用。关于DataSet及其关联的DataTable,已在许多文章中进行过介绍,但是在需要处理已经与基础数据存储区断开连接的数据时,它们非常有用。换句话说,DataSet可以在应用层之间传递,即使这些层分布在不同的物理位置也可以进行传递,如在业务层和数据服务层被放在与表示服务不同的服务器群集上的情况下。

另外,对于通过基于XML的Web Service返回数据的情况来说,DataSet对象也是理想的选择,这是因为它们可以序列化,因而可以在SOAP响应消息中返回。这有别于使用实现IDataReader接口的类(例如,SqlDataReader和OleDbDataReader)访问数据。这些数据读取器用于以只进、只读方式访问数据。这两者之间的巨大差异在于:DataSet和DataTable对象可以按值在应用程序域之间(因而也可以在相同或不同计算机上的进程之间)传递,而数据读取器则可以到处传递,并且总是按引用传递。请参见图5,其中Read和GetValues是在服务器进程中执行的,并且它们的返回值被复制到客户端。

图5 远程处理数据读取器

该图着重显示了数据读取器如何存在于创建它的应用程序域中,以及对它的所有访问如何在客户端和服务器应用程序域之间产生往返行程。这意味着,只有当数据读取器在调用方所在的同一应用程序域中执行时,数据访问方法才应当返回这些数据读取器。在使用数据读取器时,还有其他两个需要考虑的问题。

首先,在从数据访问类中的方法返回数据读取器时,需要考虑与该数据读取器相关联的连接对象的生存期。默认情况下,在调用方循环访问数据读取器时,连接会保持繁忙。遗憾的是,当调用方完成工作时,连接将保持开启状态,因此不会返回到连接池(如果启用连接池的话)。但是,您可以通过将CommandBehavior.CloseConnection枚举值传递给命令对象的ExecuteReader方法,来指示数据读取器在它的Close方法被调用时关闭连接。

其次,为了将表示层从特定的Framework data provider(例如SqlClient或OleDb)分离,调用代码应当使用IDataReader接口而不是具体类型(例如SqlDataReader)来引用返回值。这样,如果应用程序从Oracle移植到SQL Server后端,并且数据访问类中的方法的返回类型进行了更改,则表示层无须更改。如果您希望数据访问类返回XML,则可以从System.Xml命名空间中的XmlDocument和XmlReader类(它们类似于DataSet和IDataReader)中进行选择。换句话说,当数据要从它的源断开连接时,您的方法应当返回XmlDocument(或XmlDataDocument),而XmlReader可以用来对XML数据进行流式访问。

最后,您还可以决定用公共属性返回自定义类。这些类可以用Serialization属性标记,以便能够跨应用程序域进行复制。另外,如果您要从方法中返回多个对象,则可能需要强类型集合类。

Imports System.Xml.Serialization

<Serializable()> _
Public Class Book : Implements IComparable

<XmlAttributeAttribute()> Public ProductID As Integer
Public ISBN As String
Public Title As String
Public Author As String
Public UnitCost As Decimal
Public Description As String
Public PubDate As Date

Public Function CompareTo(ByVal o As Object) As Integer _
Implements IComparable.CompareTo

 Dim b As Book = CType(o, Book)
 Return Me.Title.CompareTo(b.Title)
End Function

End Class

Public NotInheritable Class BookCollection : Inherits ArrayList

Default Public Shadows Property Item(ByVal productId As Integer)
As Book

Get
 Return Me(IndexOf(productId))
End Get
Set(ByVal Value As Book)
 Me(IndexOf(productId)) = Value
End Set
End Property

Public Overloads Function Contains(ByVal productId As Integer) As Boolean
 Return (-1 <> IndexOf(productId))
End Function

Public Overloads Function IndexOf(ByVal productId As Integer) As Integer
 Dim index As Integer = 0
 Dim item As Book

 For Each item In Me
  If item.ProductID = productId Then
   Return index
  End If
  index = index + 1
 Next
 Return -1
End Function

Public Overloads Sub RemoveAt(ByVal productId As Integer)
 RemoveAt(IndexOf(productId))
End Sub

Public Shadows Function Add(ByVal value As Book) As Integer
 Return MyBase.Add(value)
End Function

End Class

图6 使用一个自定义类

图6包含了一个简单的Book类及其关联的集合类的示例。您会发现,Book类用Serializable进行了标记,以便跨应用程序域启用“按值(by value)”语义。该类实现了IComparable接口,以便当它包含在集合类中的时候,它将在默认情况下按Title排序。BookCollection类派生自System.Collections命名空间中的ArrayList,并且遮蔽了Item属性和Add方法,以便将集合限制为仅包含Book对象。通过使用自定义类,您可以获得对数据表示方法的完全控制,通过强类型化和IntelliSense提高开发人员的工作效率,并且消除对ADO.NET的调用方依赖性。但是,由于.NET Framework不包含任何与对象相关的映射技术(除了本质上是派生的DataSet类的类型化DataSet对象以外),因此该方法需要更多的代码。在这些情况下,您通常要在数据访问类中创建一个数据读取器,并且使用它来填充自定义类。

规则5:抽象化.net Framework data provider

最后一个规则指定,为什么应该对在DAL内部使用的.NET Framework data provider抽象化,以及应该如何进行抽象。正如我已经提到的那样,ADO.NET编程模型公开了独特的.NET Framework data provider,包括SqlClient、OleDb和其他可从 MSDN 在线Web站点上获得的data provider。尽管该设计能够提高性能,并且使provider能够公开特定于数据源的功能(例如SqlCommand对象的ExecuteXMLReader方法),但它会迫使开发人员决定针对哪个provider进行编码。换句话说,开发人员通常选择使用SqlClient或OleDb,然后直接针对各个命名空间中的类编写代码。

public enum ProviderType : int {SqlClient = 0, OLEDB = 1}

public class ProviderFactory {

 public ProviderFactory(ProviderType provider) {
  _pType = provider;
  _initClass();
 }

 public ProviderFactory() {
  _initClass();
 }

 private ProviderType _pType = ProviderType.SqlClient;
 private bool _pTypeSet = false;
 private Type[] _conType, _comType, _parmType, _daType;

 private void _initClass() {
  _conType = new Type[2];
  _comType = new Type[2];
  _parmType = new Type[2];
  _daType = new Type[2];

  // Initialize the types for the providers
  _conType[(int)ProviderType.SqlClient] = typeof(SqlConnection);
  _conType[(int)ProviderType.OLEDB] = typeof(OleDbConnection);
  _comType[(int)ProviderType.SqlClient] = typeof(SqlCommand);
  _comType[(int)ProviderType.OLEDB] = typeof(OleDbCommand);
  _parmType[(int)ProviderType.SqlClient] = typeof(SqlParameter);
  _parmType[(int)ProviderType.OLEDB] = typeof(OleDbParameter);
  _daType[(int)ProviderType.SqlClient] = typeof(SqlDataAdapter);
  _daType[(int)ProviderType.OLEDB] = typeof(OleDbDataAdapter);
 }

 public ProviderType Provider {
  get {
   return _pType;
  }
  set {
   if (_pTypeSet) {
    throw new ReadOnlyException("Provider already set to " + _pType.ToString()); 
   }
   else {
    _pType = value;
    _pTypeSet = true;
   }
  }
 }

 public IDataAdapter CreateDataAdapter(string commandText,IDbConnection connection) {
  IDataAdapter d;
  IDbDataAdapter da;

  d = (IDataAdapter)Activator.CreateInstance(_daType[(int)_pType], false);
  da = (IDbDataAdapter)d;
  da.SelectCommand = this.CreateCommand(commandText, connection);
  return d;
 }

 public IDataParameter CreateParameter(string paramName, DbType paramType) {
  IDataParameter p;
  p = (IDataParameter)Activator.CreateInstance(_parmType[(int)_pType], false);
  p.ParameterName = paramName;
  p.DbType = paramType;
  return p; 
 }

 public IDataParameter CreateParameter(string paramName, DbType paramType, Object value) {
  IDataParameter p;
  p = (IDataParameter)Activator.CreateInstance(_parmType[(int)_pType], false);
  p.ParameterName = paramName;
  p.DbType = paramType;
  p.Value = value;
  return p;
 }

 public IDbConnection CreateConnection(string connect) {
  IDbConnection c;
  c = (IDbConnection)Activator.CreateInstance(_conType[(int)_pType], false);
  c.ConnectionString = connect;
  return c;
 }

 public IDbCommand CreateCommand(string cmdText, IDbConnection connection) {
  IDbCommand c;
  c = (IDbCommand)Activator.CreateInstance(_comType[(int)_pType], false);
  c.CommandText = cmdText;
  c.Connection = connection;
  return c;
 }
}

图7 Provider Factory

如果您要更改.NET Framework data provider,则需要重新编写数据访问方法的代码。为了避免这种情况,可以使用称为“抽象工厂(Abstrace Factory)”的设计模式。使用该模式,可以生成一个简单的类,该类将公开能够基于标识传入到构造函数的.NET Framework data provider的信息来创建主要的.NET Framework data provider对象(命令、连接、数据适配器和参数)的方法。图7中的代码显示了该类的一个简化的C#版本。为了使用该类,数据访问类中的代码需要针对.NET Framework data provider实现的各种接口(包括IDbCommand、IDbConnection、IDataAdapter和IDataParameter)进行编程。例如,为了用来自参数化存储过程的结果填充DataSet,您可以在数据访问类的方法内部使用以下代码:

Dim _pf As New ProviderFactory(ProviderType.SqlClient)
Dim cn As IDbConnection = _pf.CreateConnection(_connect)
Dim da As IDataAdapter = _pf.CreateDataAdapter("usp_GetBook", cn)

Dim db As IDbDataAdapter = CType(da, IDbDataAdapter)
db.SelectCommand.CommandType = CommandType.StoredProcedure
db.SelectCommand.Parameters.Add(_pf.CreateParameter("@productId", _
DbType.Int32, id))

Dim ds As New DataSet("Books")
da.Fill(ds)

通常,您需要在类级别声明ProviderFactory变量,并且在数据访问类的构造函数中将其实例化。另外,就像这里显示的那样,它的构造函数将用从配置文件中读取的data provider填充,而不是硬编码。如您想像的那样,将ProviderFactory添加到DAL基类中会非常美妙,然后可以将它包含在程序集中并分发给其他开发人员。您还可以更深入一步,以封装开发人员反复编写的常见ADO.NET代码。实际上,Microsoft已经发布了一个能够为SQL Server执行该功能的数据访问应用程序块(参见《Data Access Application Block Overview》, http://msdn.microsoft.com/library/default.ASP?url=/library/en-us/dnbda/html/daab-rm.asp)。

小结

身处于Web Service时代之中,我们会构建越来越多的应用程序—从独立的应用层来操纵数据。如果您遵循某些基本规则并最终将其作为一种习惯,那么编写数据访问代码会变得更快、更容易并且代码也会具有更高的可重用性,从而节省了与服务器之间的交互并使您在代码中保持数据的独立。

   
115 次浏览  评价: 差  订阅 捐助
 
相关文章

深度解析:清理烂代码
如何编写出拥抱变化的代码
重构-使代码更简洁优美
团队项目开发"编码规范"系列文章
 
相关文档

重构-改善既有代码的设计
软件重构v2
代码整洁之道
高质量编程规范
 
相关课程

基于HTML5客户端、Web端的应用开发
HTML 5+CSS 开发
嵌入式C高质量编程
C++高级编程


使用decj简化Web前端开发
Web开发框架形成之旅
更有效率的使用Visual Studio
MVP+WCF+三层结构搭建框架
ASP.NET运行机制浅析【图解】
编写更好的C#代码
10个Visual Studio开发调试技巧
更多...   


.NET框架与分布式应用架构设计
.NET & WPF & WCF应用开发
UML&.Net架构设计
COM组件开发
.Net应用开发
InstallShield


日照港 .NET Framework & WCF应用开发
神华信息 .NET单元测试
北京 .Net应用软件系统架构
台达电子 .NET程序设计与开发
赛门铁克 C#与.NET架构设计
广东核电 .Net应用系统架构
更多...   
 
 
 
 
 
每天2个文档/视频
扫描微信二维码订阅
订阅技术月刊
获得每月300个技术资源
 
 

关于我们 | 联系我们 | 京ICP备10020922号 京公海网安备110108001071号