2013年5月10日 星期五

ASP.NET MVC 的 Model 使用 Enterprise Library 6 Data Access Application Block

上一篇「ASP.NET MVC 的 Model 使用 ADO.NET」向大家說明 ASP.NET MVC 的 Model 不一定非要使用 ADO.NET Entity Framework,也是可以使用一般的 ADO.NET 來處理向資料庫存取的部分,我必須老實跟大家說,我很少直接使用 ADO.NET 來處理資料存取的操作,如果專案不使用 ADO.NET Entity Framework 這類的 ORM Solution 時,我就會使用 Enterprise Library Data Access Application Block 來處理資料的存取操作。

EntLib DAAB 一樣也是在 ADO.NET 的基礎上,並不像 ADO.NET Entity Framework 是一種有別於 ADO.NET 完全不同的開發方式與觀念,EntLib DAAB 一樣是使用 DbCommand, DataReader, DataSet, DataTable 等,一樣要給 SQL Statemet 或是 Stroed Procedure 名稱等等,只需要改變一些程式的寫法,就可以夠過使用 EntLib DAAB 讓處理資料時可以更加方便。

這篇文章就以「ASP.NET MVC 的 Model 使用 ADO.NET」的架構內容繼續做開發,因為有建立 Domain 類別、Repository 介面,所以只需要另外增加一個類別庫專案,然後在 Web 專案中將原本的 Sample.Repository.ADONET 做替換,如此就不需要去更動到原本的 Sample.Repository.ADONET 內容,ASP.NET MVC Web 專案也只需做小部分的調整就可以讓 Model 使用到不一樣的資料處理方式。

 


Repository.EntLibDAAB

在 Repository.Implement 的方案資料夾內新增一個類別庫專案「Sample.Repository.EntLibDAAB」,然後加入 Sample.Domain 與 Sample.Repository.Interface 的參考,

image

建立 EmployeeRepository.cs 並且繼承實作 Sample.Repository.Interface 的 IEmployeeRepository,

image

接著我們要使用 Nuget 把 EntLib 6 DAAB 套件加入到 Sample.Repository.EntLibDAAB 裡,這邊要提醒一下,Enterprise Library 6 只能安裝到使用 .NET Framework 4.5 的專案,所以如果是使用 .NET Framework 4.0 的專案,可以參考我之前的文章「讓專案透過 NuGet 安裝 Enterprise Library 5.0 - Data Access Application Block」。

開啟 Nuget 後使用關鍵字「"Enterprise Library Data Access Application Block"」查詢,找到之後就安裝到專案,

image

安裝完成後會在專案裡加入兩個參考,

image

接下來我們來完成 EmployeeRepository 的方法實作內容,首先我會建立一個抽象類別,這是讓 EmployeeRepository 的基礎類別,在這個基礎類別裡加入一些基礎屬性與建構式,讓 EmployeeRepository 可以使用 EntLib DAAB,如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Practices.EnterpriseLibrary.Data;
 
namespace Sample.Repository.EntLibDAAB
{
    public abstract class BaseRepository
    {
        private DatabaseProviderFactory factory = new DatabaseProviderFactory();
 
        private Database db;
        protected Database Db
        {
            get
            {
                if (this.db == null)
                {
                    this.db = this.factory.Create(this.ConnectionStringName);
                }
                return this.db;
            }
        }
 
        private string connectionStringName;
        protected string ConnectionStringName
        {
            get
            {
                return connectionStringName;
            }
            set 
            { 
                connectionStringName = value; 
            }
        }
 
        public BaseRepository(string connectionStringName)
        {
            this.ConnectionStringName = connectionStringName;
        }
 
    }
}

這邊我不打算解釋太多有關 EntLib DAAB 的細節,所以不了解為何必須要透過 DatabaseProviderFactory 去建立 Database 的物件,然後使用 ConnectionStringName 而不是給完整的 ConnectionString 內容,像這些的基礎內容,日後有機會再做說明,或是等不及就是要馬上了解的朋友,可以透過以下的連結得到相關的詳細資訊,

Enterprise Library 6 - April Document Download - patterns & practices – Enterprise Library
https://entlib.codeplex.com/releases/view/64243

Huan-Lin 學習筆記: Data Access Application Block 入門

http://huan-lin.blogspot.tw/2012/09/data-access-application-block.html

 

建立好 BaseRepository 類別之後,讓 EmployeeRepository 去繼承,

namespace Sample.Repository.EntLibDAAB
{
    public class EmployeeRepository : BaseRepository, IEmployeeRepository
    {
        public EmployeeRepository(string connectionStringName)
            : base(connectionStringName)
        { 
        }
 
        public Employee GetOne(int id)
        {
            throw new NotImplementedException();
        }
 
        public IEnumerable<Employee> GetEmployees()
        {
            throw new NotImplementedException();
        }
    }
}

現在要實作方法的程式內容,使用 DAAB 來取得資料,有三種方式可以做取得資料以及類別對應的操作,首先來看看基本的操作方式,

 

基本操作

使用 EntLib DAAB 存取資料,操作順序跟使用 ADO.NET 的方式類似,但還是有點不同,

  1. 建立 Database 物件
  2. 建立 DbCommand
  3. 執行 DbCommand 以取得資料

建立 Database 物件的步驟是在 BaseRepository,當建立一個 EmployeeRepository 的實例後就會建立一個 Database 物件,接著就是方法裡準備好 SQL Statement 或是 Stored Procedure,依據需求加入 DbParameter,最後執行 DbCommand 然後使用 DataSet 或 DataReader 取得資料,

namespace Sample.Repository.EntLibDAAB
{
    public class EmployeeRepository : BaseRepository, IEmployeeRepository
    {
        public EmployeeRepository(string connectionStringName)
            : base(connectionStringName)
        { 
        }
 
        /// <summary>
        /// Gets the one.
        /// </summary>
        /// <param name="id">The id.</param>
        /// <returns></returns>
        public Employee GetOne(int id)
        {
            string sqlStatement = "select * from Employees where EmployeeID = @EmployeeID";
 
            Employee item = new Employee();
 
            using (DbCommand comm = Db.GetSqlStringCommand(sqlStatement))
            {
                var param = comm.CreateParameter();
                param.ParameterName = "EmployeeID";
                param.Value = id;
                comm.Parameters.Add(param);
 
                using (IDataReader reader = this.Db.ExecuteReader(comm))
                {
                    if (reader.Read())
                    {
                        for (int i = 0; i < reader.FieldCount; i++)
                        {
                            PropertyInfo property = item.GetType().GetProperty(reader.GetName(i));
 
                            if (property != null && !reader.GetValue(i).Equals(DBNull.Value))
                            {
                                ReflectionHelper.SetValue(property.Name, reader.GetValue(i), item);
                            }
                        }
                    }
                }
            }
            return item;
        }
 
        /// <summary>
        /// Gets the employees.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.NotImplementedException"></exception>
        public IEnumerable<Employee> GetEmployees()
        {
            List<Employee> employees = new List<Employee>();
 
            string sqlStatement = "select * from Employees order by EmployeeID";
 
            using (DbCommand comm = Db.GetSqlStringCommand(sqlStatement))
            using (IDataReader reader = this.Db.ExecuteReader(comm))
            {
                while (reader.Read())
                {
                    Employee item = new Employee();
 
                    for (int i = 0; i < reader.FieldCount; i++)
                    {
                        PropertyInfo property = item.GetType().GetProperty(reader.GetName(i));
 
                        if (property != null && !reader.GetValue(i).Equals(DBNull.Value))
                        {
                            ReflectionHelper.SetValue(property.Name, reader.GetValue(i), item);
                        }
                    }
                    employees.Add(item);
                }
            }
            return employees;
        }
 
    }
}

 

進階操作 - 使用 MapBuilder BuildAllProperties

上面所看到的是使用 EntLib DAAB 的基本操作,其實跟我們原本使用 ADO.NET 取得資料的方式很類似,而取回資料後對應到指定類別的屬性,這部份的工作還是使用了自己做的反射方法來處理,其實如果我們所取回的資料欄位名稱與要對應的類別屬性一致的話,其實可以使用 EntLib DAAB 所提供的 MapBuilder<T>.BuilAllProperties() 方法,那麼中間那一段我們要處理反射的部份就不需要了,程式實作內容如下,

namespace Sample.Repository.EntLibDAAB
{
    public class EmployeeRepository : BaseRepository, IEmployeeRepository
    {
        public EmployeeRepository(string connectionStringName)
            : base(connectionStringName)
        { 
        }
 
        /// <summary>
        /// Gets the one.
        /// </summary>
        /// <param name="id">The id.</param>
        /// <returns></returns>
        public Employee GetOne(int id)
        {
            string sqlStatement = "select * from Employees where EmployeeID = @EmployeeID";
 
            using (DbCommand comm = Db.GetSqlStringCommand(sqlStatement))
            {
                var param = comm.CreateParameter();
                param.ParameterName = "EmployeeID";
                param.Value = id;
                comm.Parameters.Add(param);
 
                using (IDataReader reader = this.Db.ExecuteReader(comm))
                {
                    if (reader.Read())
                    {
                        // 把 reader 物件中的欄位值塞給 Employee 物件的對應屬性
     IRowMapper<Employee> mapper = MapBuilder<Employee>.BuildAllProperties();
                        Employee item = mapper.MapRow(reader);
                        return item;
                    }
                    return null;
                }
            }
        }
 
        /// <summary>
        /// Gets the employees.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.NotImplementedException"></exception>
        public IEnumerable<Employee> GetEmployees()
        {
            List<Employee> employees = new List<Employee>();
 
            string sqlStatement = "select * from Employees order by EmployeeID";
 
            using (DbCommand comm = Db.GetSqlStringCommand(sqlStatement))
            using (IDataReader reader = this.Db.ExecuteReader(comm))
            {
                while (reader.Read())
                {
                    IRowMapper<Employee> mapper = MapBuilder<Employee>.BuildAllProperties();
                    Employee item = mapper.MapRow(reader);
                    employees.Add(item);
                }
            }
            return employees;
        }
    }
}

 

進階操作 -  使用 DataAccessor

EntLib DAAB 還有提供另一個資料存取的方式,搭配使用 DataAccessor,這個進階的操作方式在之前的文章也有介紹過「簡述 Oracle + Enterprise Library 5.0 Data Access Application Block 的操作」,使用 DataAccessor 的方式必須要搭配 IRowMapper 與 IParameterMapper,會使用到這種處理方式大多會是在從資料所取得的資料並不是完全對應指定物件類別的屬性,可能物件屬性會是由兩個欄位的資料所組合而成(例如 FullName = First Name + Last Name),所以無法使用反射的方式也無法使用 MapBuilder<T>.BuilAllProperties() 方法來處理資料對應,更為詳細的說明可以參閱 MSDN 文件的內容,

MSDN - Returning Data as Objects for Client Side Querying

http://msdn.microsoft.com/en-us/library/ff664431(v=PandP.50).aspx

程式實作如下,

namespace Sample.Repository.EntLibDAAB
{
    public class EmployeeRepository : BaseRepository, IEmployeeRepository
    {
        public EmployeeRepository(string connectionStringName)
            : base(connectionStringName)
        { 
        }
 
        /// <summary>
        /// Gets the one.
        /// </summary>
        /// <param name="id">The id.</param>
        /// <returns></returns>
        public Employee GetOne(int id)
        {
            string sqlStatement = "select * from Employees where EmployeeID = @EmployeeID";
 
            DataAccessor<Employee> accessor =
                this.Db.CreateSqlStringAccessor<Employee>(
                    sqlStatement,
                    new EmployeeIDParameterMapper(),
                    new EmployeeMapper());
 
            return accessor.Execute(new object[] { id }).FirstOrDefault();
        }
 
        /// <summary>
        /// Gets the employees.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.NotImplementedException"></exception>
        public IEnumerable<Employee> GetEmployees()
        {
            string sqlStatement = "select * from Employees order by EmployeeID";
 
            DataAccessor<Employee> accessor =
                this.Db.CreateSqlStringAccessor<Employee>(sqlStatement, new EmployeeMapper());
 
            return accessor.Execute();
        }
    }
 
    public class EmployeeIDParameterMapper : IParameterMapper
    {
        public void AssignParameters(DbCommand command, object[] parameterValues)
        {
            var param = command.CreateParameter();
            param.ParameterName = "EmployeeID";
            param.Value = parameterValues[0];
            command.Parameters.Add(param);
        }
    }
 
    public class EmployeeMapper : IRowMapper<Employee>
    {
        public Employee MapRow(IDataRecord reader)
        {
            Employee item = new Employee();
 
            for (int i = 0; i < reader.FieldCount; i++)
            {
                PropertyInfo property = item.GetType().GetProperty(reader.GetName(i));
 
                if (property != null && !reader.GetValue(i).Equals(DBNull.Value))
                {
                    ReflectionHelper.SetValue(property.Name, reader.GetValue(i), item);
                }
            }
            return item;
        }
    }
}

在 EmployeeMapper 這個繼承 IRowMapper 的類別裡,我並沒有一個一個屬性與每一筆 record 的 Field 資料做 mapping 處理,還是使用自己的 ReflectionHelper 方法,如同我前面所說的,如果有需要對屬性做一些加工處理的話,就是在繼承 IRowMapper 的類別裡的 MapRow() 方法中去處理。

以上三種就是在 EntLib DAAB 中常見的三種取得資料與對應到自定類別的處理方式,每一種方式都有可能會用到,所以就看實際需求來選擇使用,就不要再來問我說該使用哪一種比較好,因為是要視情況來選擇使用。

 

Web 專案裡的使用

完成了 Sample.Repository.EntLibDAAB 之後,Sample.Web.MVC 專案加入該專案的參考,不需要移除原本的 Sample.Repository.ADONET,

image

再來就是修改 EmployeeController 裡原本使用 Sample.Repository.ADONET 的內容,原本使用 Sample.Repository.ADONET 的命名空間,改為使用 Sample.Repository.EntLibDAAB,

image

因為 Sample.Repository.EntLibDAAB 的 EmployeeRepository 建構式並不是給資料庫連結字串的內容,而是給資料庫連結字串的 Name,

image

經過上面的調整之後,Sample.Web.MVC 網站專案對資料庫的 Data Access 就會是使用 Sample.Repository.EntLibDAAB,對 Web 專案只有增加專案參考的加入、Controller 內修改所使用 Repository 物件的命名空間以及修改 Repository 物件建構式的參數值,無須大費周章的去做大幅度的程式改寫,也保留了日後 Web 專案再改回使用 Sample.Repository.ADONET 的彈性。

 

相關系列文章

ASP.NET MVC

ASP.NET MVC 的 Model 使用 ADO.NET

ASP.NET MVC 的 Model 使用 Enterprise Library 6 Data Access Application Block

ASP.NET MVC - 使用 Simple Injector 讓 Model 三選一

ASP.NET WebForm

ASP.NET WebForm 使用分層的 Repository 類別庫專案

ASP.NET WebForm 使用 Simple Injector 選擇不同的 Repository

範例原始檔

ASP.NET MVC 與 ASP.NET WebForm 使用 Simple Injector 切換選擇不同 Repository 原始碼下載

 


之前有朋友向我詢問該怎麼評估選擇使用 EF 還是使用 EntLib DAAB,其實這兩個要怎麼選用是要看專案的狀況,我的回答如下(稍作修改):

DAAB 只是加強與簡化原本傳統 ADO.NET 存取的程式寫法與操作,其實還是一樣是在程式裡玩轉 ADO.NET,一樣還是要面對 DataReader, DataSet 等等,EF 是個 ORM Solution,EF 底層還是 ADO.NET,但是開發人員將會很少有機會需要再去直接處理 ADO.NET 的操作。

所以一個是 ORM 的應用,而另一個是傳統 ADO.NET 的應用,這就需要看開發團隊或是開發的專案是不是適合使用 ORM Solution。

如果專案所面對的資料庫中含有大量的 Stored Procedure,程式對於資料的 CRUD 有很多需要透過 SP 才能完成,而專案的程式沒有明確的物件導向(不是用了 C# or VB.NET 所開發的專案就是物件導向程式開發),以上的狀況大部分都有的話,那個還是繼續維持 ADO.NET 來做資料存取,而我會建議導入使用 EntLib DAAB。

如果是新的專案、團隊有 OOAD OOP 的能力,沒有任何既有專案的包袱或是需要考慮其他專案的話,那麼就使用 ADO.NET Entity Framework 或是其他 ORM Solution。

 

參考連結:

Enterprise Library 6 - April Document Download - patterns & practices – Enterprise Library
https://entlib.codeplex.com/releases/view/64243

Huan-Lin 學習筆記: Data Access Application Block 入門

http://huan-lin.blogspot.tw/2012/09/data-access-application-block.html

MSDN - Returning Data as Objects for Client Side Querying

http://msdn.microsoft.com/en-us/library/ff664431(v=PandP.50).aspx

 

延伸閱讀:

簡述 Oracle + Enterprise Library 5.0 Data Access Application Block 的操作

讓專案透過 NuGet 安裝 Enterprise Library 5.0 - Data Access Application Block

EntLib Data Access Application Block + Oralce 出現「只接受非 Null 的 OracleParameter 型別物件,不接受 OracleParameter 物件」錯誤


以上

8 則留言:

  1. 您好,我最近在使用Enterprise Library 6.0 製作工具
    想請問一下 我們公司有將SQLITE的 CODE 加密從新編譯
    (基本上API都沒改過 只是在 open 的時候做加解密)
    導致 無法利用EP 6.0 開啟,請問有辦法更改
    EP 6.0的SQLITE.DLL 參考用我們公司改過的去編譯
    或是有其他方法可以讀取我們自己加密過的SQLITEDB的方法嗎?

    回覆刪除
  2. Hello, 基本上我不是很清楚你所描述的狀況,
    首先你說到「將SQLITE的 CODE 加密重新編譯」這邊不清楚你是將什麼給加密?
    然後「請問有辦法更改 EP 6.0的SQLITE.DLL 參考用我們公司改過的」
    那個 EntLib 6 本身並沒有所謂的 SQLite.dll,那是外部由我們加入到專案然後再設定資料連線,
    所以這應該無關 EntLib 6 的問題,因為 EntLib DAAB 本身就是重新包裝 ADO.NET 的操作。

    我會建議你,另外建立一個簡單的專案,資料操作的部份改用 ADO.NET,
    再這個專案裡去加入那些你所重新編譯、加密的 SQLite.dll 與 SQLite db 檔,
    然後以 ADO.NET 的方式去做存取操作,看看這樣的方式是否可行,
    如果使用一般的 ADO.NET 做存取都有問題的話,那就是要看加解密的方式或是重新編譯的 dll 是否有問題。

    回覆刪除
  3. 感謝您的回答
    還有很抱歉沒有把問題描述清楚
    我這裡整理一下再從新發問一次
    公司將Sqlite原始檔中的API加入加解密的規則後從新編譯
    我們再用從新編譯過後的Sqlite建立DB
    所以如果要開起公司建立出來的DB就要用公司編譯的Sqlite.dll
    關於使用上其實跟一般的Sqlite是一樣的
    因為加密是做在Sqlite的裡面
    只是讓外部人員沒辦法用原始的Sqlite開啟DB而已
    使用ADO.NET和公司改過的Sqlite.dll是可以開啟DB的
    不過因為公司系統上的架構是使用Enterprise Library
    我的問題是要如何用Enterprise Library使用公司的Sqlite
    去開啟我的DB

    回覆刪除
    回覆
    1. 關於你的問題,我仍然無法給你答案或是建議方式。

      因為你將 SQLite 原始碼加入自己的程式然後「重新」編譯過,這一部分我就無法掌握,
      另外你也有說到,以一般 ADO.NET 方式是可以正常存取的,
      我不曉得你的 EntLib DAAB 是如何配置 SQLite,
      所以我只能建議你查看一下網路的其他相關文章了,例如以關鍵字「Enterprise Library SQLite」做查詢。

      刪除
  4. 其實我主要想問的是
    Enterprise 是怎麼使用SQLITE的DLL檔
    因為我的專案中完全沒有SQLITE的DLL
    配置方面我就是用他的配置編輯器上選他提供的SQLITE如此而已
    我想知道為什麼他可以靠一個SQLITE的字串
    不需要DLL就從SQLITE中抓取資料
    亦或是我對他流程上理解錯誤

    回覆刪除
    回覆
    1. ex:
      http://entlibcontrib.codeplex.com/wikipage?title=SQLiteDataProvider41
      http://blog.csdn.net/gdjlc/article/details/6075959

      刪除
  5. 您好:
    想請問一下, 關於需要用到DataAccessor的時機, 還是不太懂
    您說: 所取得的資料並不是完全對應指定物件類別的屬性

    您是指, 如果DB存的資料類似: 02-123455-003 這樣區碼-電話-分機的資料
    就一定得用DataAccessor處理?

    一般DB內存的資料會需要用DataAccessor處理嗎?

    thanks !

    回覆刪除
    回覆
    1. Hello, 你好
      我有提供範例程式原始碼,你可以下載回去之後來看,
      我在文章裡是以「FullName = First Name + Last Name」為例,跟你所提到的「電話號碼=區碼+電話+分機」的情況類似,
      有時候我們在展示層所使用到的資料是會來自多個欄位所組成,
      這時候我們使用傳統的資料存取方法的話,那麼還需要在顯示地方去做資料的組合,
      如果會在很多地方都需要使用到這個組合資料的話,也就是每個用到的地方就需要去做一次組合的操作,
      為了讓操作不用那麼繁瑣,會另外建立一個類別,而這麼類別就有電話號碼這個屬性,
      而這時候可以使用 DataAccessor 並且配合 IRowMapper 與 IParameterMapper,
      可以簡化從資料庫取得原始資料後的 Mapping 操作,
      我建議你可以看「簡述 Oracle + Enterprise Library 5.0 Data Access Application Block 的操作」這一篇底下的 DataAccessor 使用,
      http://kevintsengtw.blogspot.tw/2011/12/oracle-enterprise-library-50-data.html#.U8jn3_mSwlQ
      .
      這篇文章之所以會提到 DataAccessor 的使用,是因為一開始將資料從資料庫取出後要 Mapping 到專案的指定類別,
      一開始是使用反射的方式來做表格欄位與類別屬性的對應,
      如果我類別的某個屬性並不是由單一個表格欄位而是多個表格欄位所組成的時候,就無法使用反射的方式來做 Mapping,
      這個時候就可以使用 DataAccessor 並配合 IRowMapper 與 IParameterMapper 來完成這樣的資料對映,
      .
      當然不用反射也不用 DataAccessor 的方式也可以完成這樣的操作,
      就是一個欄位對映一個類別屬性的方式來完成,
      但是當表格欄位數量很多的時候,如果還是在程式裡一行一行去做一個欄位對映一個屬性的撰寫,
      這樣的開發效率就會很差,所以才會漸進去使用反射,然後再進而說明可以使用 DataAccesssor 來簡化。
      .
      我覺得你有些誤會了 DataAccessor,這個是屬於比較進階的使用了,
      一般的情況下,可以使用原本的資料存取方式就可以,先習慣以傳統方式的對映操作,
      並沒有規定多個欄位的資料組合就一定要使用 DataAccessor,
      而會不會需要用到,我是認為你要先瞭解它之後才去思考要不要用。
      .
      如果你是剛接觸 Enterprise Libary DAAB 的話,先對 EntLib 有個基本的瞭解,
      可以先下載「Enterprise Library 6 - Hands-on Labs」來看,其中包含 PDF 文件檔以及程式範例檔,
      其中 DAAB 的部分就有一個簡單的例子在說明 DataAccessor 的使用。
      http://www.microsoft.com/en-us/download/details.aspx?id=40286
      .
      我之所以會在文章裡介紹與說明,這是我實際用於專案之後的心得整理,
      因為我有經歷過整個資料存取方法的調整過程,
      然後我使用 EntLib 也已經很長一段時間(2007年至今
      我在文章裡有未盡詳細說明並且也跳過很多的步驟,
      所以我還是希望你可以在瞭解過 EntLib DAAB 的基本使用之後再回過頭來看這個地方,
      到那個時候就應該能夠明白了。

      刪除

提醒

千萬不要使用 Google Talk (Hangouts) 或 Facebook 及時通訊與我聯繫、提問,因為會掉訊息甚至我是過了好幾天之後才發現到你曾經傳給我訊息過,請多多使用「詢問與建議」(在左邊,就在左邊),另外比較深入的問題討論,或是有牽涉到你實作程式碼的內容,不適合在留言板裡留言討論,請務必使用「詢問與建議」功能(可以夾帶檔案),謝謝。

最近的留言