【IT168 技术文章】
随着 Microsoft 的 .NET Framework 的逐渐流行,许多开发人员迫切想了解关于将 .NET 应用程序与 Oracle 集成的非常好的方式的信息 — 不仅在基本的连通性方面,还包括与使用 Visual Studio.NET (VS.NET) 进行有效的应用程序开发的关系。
在本文中,我将说明构建使用 Oracle 数据库的 .NET 应用程序所涉及到的基本但不可或缺的过程,包括:
如何添加工程引用,以在您的 .NET 工程中支持 Oracle 类库
如何创建 Oracle 数据库连接字符串
如何使用 Connection、Command 和 DataReader 对象
您将有机会应用您在三个上机操作实践中学到的内容,难度从相对简单逐渐变复杂。
关于如何保护应用程序的信息和上机操作,请参见我的文章“在 Oracle 数据库上保护 .NET 应用程序”。(另外,有关涉及一系列 Oracle .NET 应用程序生命周期问题的技术文章,请参见 OTN .NET 开发人员中心。)
请注意,免费的 Oracle Developer Tools for Visual Studio(可从 OTN 下载)提供了一个 Visual Studio 插件,该插件可以简化 Oracle 上的 .NET 应用程序开发,并使之更加直观。但在这里我们不讨论这个问题;要想了解这方面的更多信息,您可以前往 Oracle Developer Tools for Visual Studio 产品中心。
.NET 数据提供程序
除了基本的 Oracle 客户端连通性软件,.NET 应用程序还需要使用称为受管理的数据提供程序(其中“受管理的”指的是代码由 .NET 框架管理)的工具。数据提供程序是指 .NET 应用程序代码和 Oracle 客户端连通性软件之间的层。几乎在所有情况下,最优的性能都是通过使用为特定数据库平台优化了的提供程序而不是通用的 .NET OLE DB 数据提供程序实现的。
Oracle、Microsoft 和第三方供应商都提供针对 Oracle 产品进行了优化的 .NET 数据提供程序。Oracle 和 Microsoft 免费提供其 Oracle 数据提供程序。(Microsoft 针对 .NET Framework 1.1 的提供程序包括在该框架中,但仍然需要 Oracle 客户端软件安装。)在本文中,我们将使用 Oracle Data Provider for .NET (ODP.NET),它包含在 Oracle 数据库中或者单独提供下载。
ODP.NET 提供标准的 ADO.NET 数据库访问,同时还提供专用于 Oracle 数据库的特性,如 XML DB、数据访问性能优化和真正应用集群连接池。
安装了 ODP.NET 和 Oracle 客户端软件后,就可以开始使用 Visual Studio 进行应用程序开发了。在开始开发前,请先确认客户端连通性。如果您可以使用 Visual Studio 所在计算机上的 Oracle 客户端软件(如 SQL*Plus)连接到 Oracle,您就知道 Oracle 客户端软件已经正确安装并配置。
如果您是初次接触 Oracle,请参阅 Oracle 数据库两日开发人员指南 中的“安装 .NET 产品”一节,以便具体了解有关如何安装和配置 ODP.NET 的信息,也可以参阅 Oracle 数据库文档库了解有关 Oracle 数据库的常规信息。
在 Visual Studio.NET 中创建工程
在启动 VS.NET 之后,第一个任务是创建一个工程。您可以单击 New Project 按钮或选择 File | New | Project...(如下所示)。
图 1:在 Visual Studio.NET 中创建一个新工程
出现一个 New Project 对话框。在对话框左侧的 Project Types 下,选择您的编程语言。在这个例子中,我们选择 VB.NET。在右侧的 Templates 下,选择一个工程模板。为简单起见,这里选择 Windows Application。
图 2:使用 New Project 对话框
您将需要为工程名称(我们使用 OraWinApp)和解决方案名称(我们使用 OraSamples)指定有意义的名称。一个解决方案包含一个或多个工程。当一个解决方案仅包含一个工程时,许多人对二者使用相同的名称。
添加引用
因为我们的工程必须与 Oracle 数据库连接,因此必须添加一个到包含我们选择的数据提供程序的 dll 的引用。在 Solution Explorer 内,选择 References 节点,右键单击并选择 Add Reference。或者,您可以转至菜单栏并选择 Project,然后选择 Add Reference。
图 3:添加引用
出现 Add Reference 对话框。
图 4:选择 ODP.NET 管理的数据提供程序
从列表中选择 Oracle.DataAccess.dll,然后单击 Select 按钮,最后单击 OK 按钮,使您的工程识别 ODP.NET 数据提供程序。
图 5:选择 Oracle 管理的提供程序之后的 Solution Explorer
VB.NET/C# 语句
在添加引用之后,标准的做法是要添加 VB.NET Imports 语句、C# using 语句或 J# import 语句。从技术上说这些语句不是必要的,但是使用它们可以让您不需用冗长且完全限定名称来引用数据库对象。
按照惯例,这些语句出现在代码文件的顶部或顶部附近,在命名空间或类声明之前。
Imports Oracle.DataAccess.Client ' VB.NET
using Oracle.DataAccess.Client; // C#
import Oracle.DataAccess.Client; // J#ODP.NET Oracle managed provider
如果您添加了引用,Intellisense 将帮助您完成 Imports 或 using 语句的添加。
连接字符串和对象
Oracle 连接字符串和 Oracle 名称解析是不可分的。假定我们在 tnsnames.ora 文件中定义了一个数据库别名 OraDb,如下:
OraDb=
(DESCRIPTION=
(ADDRESS_LIST=
(ADDRESS=(PROTOCOL=TCP)(HOST=OTNSRVR)(PORT=1521))
)
(CONNECT_DATA=
(SERVER=DEDICATED)
(SERVICE_NAME=ORCL)
)
)
OraDb 别名定义客户端的数据库地址连接信息。要使用上面所述的在 tnsnames.ora 文件中定义的 OraDb 别名,您需要使用以下语法:
Dim oradb As String = "Data Source=OraDb;User Id=scott;Password=tiger;" ' VB.NET
string oradb = "Data Source=OraDb;User Id=scott;Password=tiger;"; // C#
不过,您可以修改连接字符串,这样就不需用 tnsnames.ora 文件。只需用在 tnsnames.ora 文件中定义别名的语句替换别名即可。
' VB.NET
Dim oradb As String = "Data Source=(DESCRIPTION=" _
+ "(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=OTNSRVR)(PORT=1521)))" _
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));" _
+ "User Id=scott;Password=tiger;"
// C#
string oradb = "Data Source=(DESCRIPTION="
+ "(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=ORASRVR)(PORT=1521)))"
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));"
+ "User Id=scott;Password=tiger;";
正如您在上面看到的那样,用户名和口令是以不加密的文本形式嵌入到连接字符串中的。这是创建连接字符串的最简单的方法。然而,从安全的角度而言不加密文本的方法是不可取的。尤其是,您需要了解编译的 .NET 应用程序代码仅比不加密文本形式的源代码文件稍微安全一点。可以非常简便地反编译 .NET DLL 和 EXE 文件,进而查看原始的不加密文本形式的内容。(加密实际上是正确的解决方案,但这个主题与我们这里的讨论相差太远。)
接下来,您必须从连接类中完成一个连接对象的实例化。连接字符串必须与连接对象关联。
Dim conn As New OracleConnection(oradb) ' VB.NET
OracleConnection conn = new OracleConnection(oradb); // C#
注意,通过将连接字符串传递给连接对象的构造器(该构造器进行了重载),连接字符串与连接对象建立关联。构造函数的其他重载允许以下这些替代的语法:
Dim conn As New OracleConnection() ' VB.NET
conn.ConnectionString = oradb
OracleConnection conn = new OracleConnection(); // C#
conn.ConnectionString = oradb;
在连接字符串与连接对象建立关联之后,使用 Open 方法来创建实际的连接。
conn.Open() ' VB.NET
conn.Open(); // C#
我们将在稍后介绍错误处理。
Command 对象
Command 对象用于指定执行的 SQL 命令文本 — SQL 字符串或存储过程。类似于 Connection 对象,它必须从完成其类的实例化,并且它拥有一个重载的构造函数。在本示例中,ODP.NET 将执行一个 SQL 查询,返回部门表 (DEPT) 中部门编号 (DEPTNO) 为 10 的部门名称 (DNAME)。
Dim sql As String = "select dname from dept where deptno = 10" ' VB.NET
Dim cmd As New OracleCommand(sql, conn)
cmd.CommandType = CommandType.Text
string sql = "select dname from dept where deptno = 10"; // C#
OracleCommand cmd = new OracleCommand(sql, conn);
cmd.CommandType = CommandType.Text;
不同的重载,语法的结构稍微有点不同。Command 对象有用于执行命令文本的方法。不同的方法适用于不同类型的 SQL 命令。
检索标量值
从数据库中检索数据可以通过实例化一个 OracleDataReader 对象并使用 ExecuteReader 方法(它返回一个 OracleDataReader 对象)来实现。可以通过将列名或以零为基数的列序号传递给 OracleDataReader 来访问返回的数据。
Dim dr As OracleDataReader = cmd.ExecuteReader() ' Visual Basic
dr.Read()
Label1.Text = dr.Item("dname") ' retrieve by column name
Label1.Text = dr.Item(0) ' retrieve the first column in the select list
Label1.Text = dr.GetString(0) ' return a .NET data type
Label1.Text = dr.GetOracleString(0) ' return an Oracle data type
某些类型的存取程序用于返回 .NET 原生数据类型,其他的存取程序用于返回 Oracle 原生数据类型。所有这些存取程序都提供 C#、Visual Basic 或任何其他 .NET 语言版本。以零为基数的序号被传递给存取程序,以指定返回哪一列。
OracleDataReader dr = cmd.ExecuteReader(); // C#
dr.Read();
label1.Text = dr["dname"].ToString(); // C# retrieve by column name
label1.Text = dr.GetString(0).ToString(); // return a .NET data type
label1.Text = dr.GetOracleString(0).ToString(); // return an Oracle data type
在这个简化的示例中,DNAME 的返回值是一个字符串,它用来设置标签控件的文本的属性值(也是一个字符串)。但如果检索的是 DEPTNO(不是一个字符串),那么将出现数据类型不匹配的情况。当源数据类型与目标数据类型不匹配时,.NET 运行时将尝试隐式地转换数据类型。有时数据类型不兼容,则隐式转换将失败,从而引发异常。但即使可以进行隐式转换,使用显式数据类型转换仍比用隐式数据类型转换好。
到整数的显式转换显示如下:
Label1.Text = CStr(dr.Item("deptno")) ' Visual Basic integer to string cast
在隐式转换上,C# 的容错能力不如 Visual Basic。您必须自己执行显式转换:
label1.Text = dr.GetInt16("deptno").ToString(); // C#
您可以显式地转换标量值以及数组。
关闭并清除
当不在需要到数据库的连接时,可以调用连接对象的 Close 方法或 Dispose 方法来关闭此连接。Dispose 方法调用 Close 方法。
conn.Close() ' Visual Basic
conn.Dispose() ' Visual Basic
conn.Close(); // C#
conn.Dispose(); // C#
作为可选项,C# 提供了一种在连接超出范围时自动清除连接的特殊语法。使用 using 关键字可启用这一特性。
using (OracleConnection conn = new OracleConnection(oradb))
{
conn.Open();
OracleCommand cmd = new OracleCommand();
cmd.Connection = conn;
cmd.CommandText = "select dname from dept where deptno = 10";
cmd.CommandType = CommandType.Text;
OracleDataReader dr = cmd.ExecuteReader();
dr.Read();
label1.Text = dr.GetString(0);
}
此外,OracleCommand 包括 Dispose 方法;OracleDataReader 包括 Close 方法和 Dispose 方法。关闭并删除 .NET 对象可以释放系统资源,从而确保高效的应用程序性能,这在高负载情况下尤为重要。您可以试验在上机操作 1(从数据库中检索数据)和上机操作 2(增加交互性)中学到的一些概念。
错误处理
当错误发生时,.NET 应用程序应当适当地处理错误并通过一条有意义的消息通知用户。
Try-Catch-Finally 结构的错误处理是 .NET 语言的一部分。下面是使用 Try-Catch-Finally 语法的一个相对最小的示例:
' Visual Basic
Try
conn.Open()
Dim cmd As New OracleCommand
cmd.Connection = conn
cmd.CommandText = "select dname from dept where deptno = " + TextBox1.Text
cmd.CommandType = CommandType.Text
If dr.Read() Then
Label1.Text = dr.Item("dname") ' or use dr.Item(0)
End If
Catch ex As Exception ' catches any error
MessageBox.Show(ex.Message.ToString())
Finally
' In a real application, put cleanup code here.
End Try
// C#
try
{
conn.Open();
OracleCommand cmd = new OracleCommand();
cmd.Connection = conn;
cmd.CommandText = "select dname from dept where deptno = " + textBox1.Text;
cmd.CommandType = CommandType.Text;
if (dr.Read()) // C#
{
label1.Text = dr["dname"].ToString();
// or use dr.GetOracleString(0).ToString()
}
}
catch (Exception ex) // catches any error
{
MessageBox.Show(ex.Message.ToString());
}
finally
{
// In a real application, put cleanup code here.
}
虽然这种方法将适当地捕获尝试从数据库中获取数据时发生的任何错误,但这种方法对用户却不友好。例如,看看下面这条在数据库不可用时显示的消息。
图 6:捕获到一个 ORA-12545 错误,并向用户显示。
Oracle DBA 或开发人员很清楚 ORA-12545 的意义,但是最终用户不清楚。一种更好的解决方案是添加一条额外的 Catch 语句来捕获最常见的数据库错误并显示对用户友好的消息。
Catch ex As OracleException ' catches only Oracle errors
Select Case ex.Number
Case 1
MessageBox.Show("Error attempting to insert duplicate data.")
Case 12545
MessageBox.Show("The database is unavailable.")
Case Else
MessageBox.Show("Database error: " + ex.Message.ToString())
End Select
Catch ex As Exception ' catches any error
MessageBox.Show(ex.Message.ToString())
catch (OracleException ex) // catches only Oracle errors
{
switch (ex.Number)
{
case 1:
MessageBox.Show("Error attempting to insert duplicate data.");
break;
case 12545:
MessageBox.Show("The database is unavailable.");
break;
default:
MessageBox.Show("Database error: " + ex.Message.ToString());
break;
}
}
catch (Exception ex) // catches any error
{
MessageBox.Show(ex.Message.ToString());
}
注意上面的代码示例中的两条 Catch 语句。如果没有捕获到任何 Oracle 错误,那么将跳过第一条 Catch 语句分支,让第二条 Catch 语句来捕获其他任何类型的非 Oracle 错误。在代码中,应该根据从特殊到一般的顺序对 Catch 语句排序。在实施了用户友好的异常处理代码之后,ORA-12545 错误消息显示如下:
图 7:ORA-12545 错误的用户友好的错误消息
无论是否发生错误,Finally 代码块总会执行。清除代码应包含在此代码块中。如果您不使用 Using 或 using,应在 Finally 代码块中删除您的连接和其他对象。
利用 DataReader 检索多个值
到目前为止,我们的示例仅说明了如何检索单个值。OracleDataReader 可以检索多列和多行的值。首先进行多列、单行的查询:
select deptno, dname, loc from dept where deptno = 10
要获取列的值,可以使用以零为基数的序号或列名。序号与查询中的顺序相关。因而,可以在 Visual Basic 中通过使用 dr.Item(2) 或 dr.Item("loc") 来检索 LOC 列的值。
下面是将 DNAME 和来自上一查询的 LOC 列串连起来的代码片段:
Label1.Text = "The " + dr.Item("dname") + " department is in " + dr.Item("loc") ' VB
label1.Text = "The " + dr["dname"].ToString() + " department is in " +
dr["loc"].ToString(); // C#
现在我们进行返回多行的查询:
select deptno, dname, loc from dept
要处理从 OracleDataReader 中返回的多行,需要某种类型的循环结构。此外,需要一个可以显示多行的控件。OracleDataReader 是一个仅正向的只读游标,因此不能将其与可更新或完全可滚动的控件(如 Windows Forms DataGrid 控件)捆绑在一起。OracleDataReader 与 ListBox 控件兼容,如以下代码片段所示:
While dr.Read() ' Visual Basic
ListBox1.Items.Add("The " + dr.Item("dname") + " department is in " + dr.Item("loc"))
End While
while (dr.Read()) // C#
{
listBox1.Items.Add("The " + dr["dname"].ToString() + " department is in " +
dr["loc"].ToString());
}
上机操作 3(利用 OracleDataReader 检索多列和多行)重点介绍了这些概念中的一部分。
总结
本文向您介绍了使用 VS.NET 编程语言访问 Oracle 数据库的过程。您现在应该能够连接数据库并检索多列和多行。
John Paul Cook (johnpaulcook@email.com) 是居住在休斯顿的一位数据库和 .NET 顾问。他撰写了许多关于 .NET、Oracle 和其他主题的文章,并从 1986 年以来一直开发关系数据库应用程序。他目前的兴趣包括 Visual Studio 2005 和 Oracle 10g。他是 Oracle 认证 DBA 和 Microsoft MCSD for .NET。
上机操作 1:从数据库中检索数据
前提条件是您已经创建了一个工程并添加了一个引用(如本文前面部分所示)。
首先向 Windows 表单添加一个按钮控件和一个标签控件。务必在这些控件上方保留空间,以便在上机操作 2 中添加控件。
图 8:包含按钮和标签控件的表单(上机操作 1)
添加代码,它们用于从 Oracle 数据库中检索数据并在表单上显示结果。将代码放在按钮的单击事件处理程序中。开始这项任务的最容易的方式是双击该按钮,因为它将为事件处理程序创建一个 stub。
图 9:单击事件处理程序 stub。
在 Public Class 声明之前添加 VB.NET Imports 语句,或在命名空间声明之前添加 C# using 语句。
Imports System.Data ' VB.NET
Imports Oracle.DataAccess.Client ' ODP.NET Oracle managed provider
using System.Data; // C#
using Oracle.DataAccess.Client; // ODP.NET Oracle managed provider
在 Private Sub 和 End Sub 语句之间添加 VB.NET 版本的单击事件处理程序代码(确保用您的服务器的主机名称替代 OTNSRVR):
Dim oradb As String = "Data Source=(DESCRIPTION=(ADDRESS_LIST=" _
+ "(ADDRESS=(PROTOCOL=TCP)(HOST=OSRVR)(PORT=1521)))" _
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));" _
+ "User Id=scott;Password=tiger;"
Dim conn As New OracleConnection(oradb) ' VB.NET
conn.Open()
Dim cmd As New OracleCommand
cmd.Connection = conn
cmd.CommandText = "select dname from dept where deptno = 10"
cmd.CommandType = CommandType.Text
Dim dr As OracleDataReader = cmd.ExecuteReader()
dr.Read() ' replace this statement in next lab
Label1.Text = dr.Item("dname") ' or dr.Item(0), remove in next lab
dr.Dispose()
cmd.Dispose()
conn.Dispose()
将以下单击事件处理程序的 C# 代码添加到按钮的单击事件处理程序中的括号 { 和 } 之间(确保用您的服务器的主机名称替代 OTNSRVR):
string oradb = "Data Source=(DESCRIPTION=(ADDRESS_LIST="
+ "(ADDRESS=(PROTOCOL=TCP)(HOST=ORASRVR)(PORT=1521)))"
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));"
+ "User Id=scott;Password=tiger;";
OracleConnection conn = new OracleConnection(oradb); // C#
conn.Open();
OracleCommand cmd = new OracleCommand();
cmd.Connection = conn;
cmd.CommandText = "select dname from dept where deptno = 10";
cmd.CommandType = CommandType.Text;
OracleDataReader dr = cmd.ExecuteReader();
dr.Read(); // replace this statement in next lab
label1.Text = dr["dname"].ToString(); // remove in next lab
dr.Dispose();
cmd.Dispose();
conn.Dispose();
运行应用程序。单击按钮。您将看到以下内容:
图 10:成功检索到数据
上机操作 2:增加交互性
既然已在代码中实施了数据库访问的基础,下一步是为应用程序增加交互性。与运行硬编码的查询不同,可以添加一个文本框控件来接受用户输入的部门号码(即 DEPTNO)。
向表单中添加一个文本框控件和另一个标签控件(如下所示):将 Label2 控件的文本属性设为 Enter DEPTNO:并确保没有设置 TextBox1 的 Text 属性。
图 11:包含按钮和标签控件的表单(上机操作 2 )
修改定义 select 字符串的代码:
cmd.CommandText = "select dname from dept where deptno = " + TextBox1.Text 'VB.NET
cmd.CommandText = "select dname from dept where deptno = " + textBox1.Text; // C#
运行应用程序。在 deptno 中输入 10 测试应用程序。输入一个无效的 DEPTNO(例如 50)重新测试应用程序。应用程序将终止。
图 12:一个未处理的异常
修改代码防止在输入无效的 DEPTNO 时出现错误。让我们回顾一下,ExecuteReader 方法实际返回一个对象。将包含 dr.Read 的代码行替换为以下全部语句。
If dr.Read() Then ' Visual Basic
Label1.Text = dr.Item("dname").ToString()
Else
Label1.Text = "deptno not found"
End If
if (dr.Read()) // C#
{
label1.Text = dr["dname"].ToString();;
}
else
{
label1.Text = "deptno not found";
}
输入不存在的 DEPTNO 数字测试应用程序。现在应用程序不再终止。输入字母 A 代替数字,然后单击按钮。应用程序终止。很明显,我们的应用程序需要更好的方法来处理错误。
可能有人会指出,应用程序应当不充许用户进行导致错误的无效输入,但根本上应用程序必须添加强健的错误处理功能。不是所有的错误都是可预防的,因此必须具备错误处理功能。
上机操作 3:使用 DataReader 检索多行和多列
既然已检索了单个值,下一步是使用 DataReader 检索多行和多列。在表单中添加一个 ListBox 控件来显示结果。
在表单中添加一个 ListBox 控件。重新调整控件的大小,填满表单的大部分宽度(如下所示)。
图 13:添加了 ListBox 的表单
从查询中删除 where 子句,并添加以下列:
cmd.CommandText = "select deptno, dname, loc from dept" ' VB.NET
cmd.CommandText = "select deptno, dname, loc from dept"; // C#
查询结果将在一个 while 循环中读取,并将填充 ListBox 控件。修改 VB.NET 代码,最终结果如下:
Dim oradb As String = "Data Source=(DESCRIPTION=(ADDRESS_LIST=" _
+ "(ADDRESS=(PROTOCOL=TCP)(HOST=OTNSRVR)(PORT=1521)))" _
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));" _
+ "User Id=scott;Password=tiger;"
Dim conn As New OracleConnection(oradb) ' Visual Basic
conn.Open()
Dim cmd As New OracleCommand
cmd.Connection = conn
cmd.CommandText = "select deptno, dname, loc from dept";
cmd.CommandType = CommandType.Text
Dim dr As OracleDataReader = cmd.ExecuteReader()
While dr.Read()
ListBox1.Items.Add("The " + dr.Item("dname") + _
" department is in " + dr.Item("loc"))
End While
dr.Dispose()
cmd.Dispose()
conn.Dispose()
修改您的 C# 代码,最终结果如下:
string oradb = "Data Source=(DESCRIPTION=(ADDRESS_LIST="
+ "(ADDRESS=(PROTOCOL=TCP)(HOST=ORASRVR)(PORT=1521)))"
+ "(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=ORCL)));"
+ "User Id=scott;Password=tiger;";
OracleConnection conn = new OracleConnection(oradb); // C#
conn.Open();
OracleCommand cmd = new OracleCommand();
cmd.Connection = conn;
cmd.CommandText = "select deptno, dname, loc from dept";
cmd.CommandType = CommandType.Text;
OracleDataReader dr = cmd.ExecuteReader();
while (dr.Read())
{
listBox1.Items.Add("The " + dr["dname"].ToString() +
" department is in " + dr["loc"].ToString());
}
dr.Dispose();
cmd.Dispose();
conn.Dispose();
运行应用程序。ListBox 应填充了 DEPT 表中的所有部门名称和位置。供下载的代码中已经具备了错误处理功能。