这是n层架构的适当实现吗?

3
我已经学习了一年左右的C#,并试图在学习过程中融入最佳实践。通过StackOverflow和其他网络资源,我认为我正在正确地分离我的关注点,但现在我有些怀疑,在将整个网站转换到这种新架构之前,我想确保我走的是正确的道路。
当前网站是旧的ASP VBscript,并且存在一个相当丑陋的数据库(没有外键等)。因此,至少在.NET的第一个版本中,我不想使用或学习任何ORM工具。
我有以下项目,它们位于不同的命名空间中,并设置了UI层只能看到DTO和Business层,而Data层只能从Business层看到。这里是一个简单的例子: productDTO.cs
public class ProductDTO
{
    public int ProductId { get; set; }
    public string Name { get; set; }

    public ProductDTO()
    {
        ProductId = 0;
        Name = String.Empty;
    }
}

productBLL.cs

public class ProductBLL
{

    public ProductDTO GetProductByProductId(int productId)
    {
        //validate the input            
        return ProductDAL.GetProductByProductId(productId);
    }

    public List<ProductDTO> GetAllProducts()
    {
        return ProductDAL.GetAllProducts();
    }

    public void Save(ProductDTO dto)
    {
        ProductDAL.Save(dto);
    }

    public bool IsValidProductId(int productId)
    {
        //domain validation stuff here
    }
}

productDAL.cs

public class ProductDAL
{
    //have some basic methods here to convert sqldatareaders to dtos


    public static ProductDTO GetProductByProductId(int productId)
    {
        ProductDTO dto = new ProductDTO();
        //db logic here using common functions 
        return dto;
    }

    public static List<ProductDTO> GetAllProducts()
    {
        List<ProductDTO> dtoList = new List<ProductDTO>();
        //db logic here using common functions 
        return dtoList;
    }

    public static void Save(ProductDTO dto)
    {
        //save stuff here
    }

}

在我的用户界面中,我会这样做:
ProductBLL productBll = new ProductBLL();
List<ProductDTO> productList = productBll.GetAllProducts();

为了保存:

ProductDTO dto = new ProductDTO();
dto.ProductId = 5;
dto.Name = "New product name";
productBll.Save(dto);

我完全偏离了正确的方向吗? 我是否也应该在我的BLL中具有相同的属性,而不是将DTO传回UI? 请告诉我什么是错误的,什么是正确的。 请记住我还不是专家。

我想将接口实现到我的架构中,但我还在学习如何做到这一点。


在我的工作中,我们维护一个项目,它的架构非常相似。现在我知道了这些,我会尝试使用ORM(我喜欢NHibernate)。我可能有偏见,但当你开始构建这样的架构时,你就会有一些只是将信息传递给下一层的类。NHibernate有非常好的查询API,这将为您节省很多时间。并且没有规定说您必须拥有外键才能使用NHibernate(虽然它们显然很有帮助)。 - dana
尝试学习NHibernate,但我对c#/ASP.net并不是非常熟悉,现在要掌握它们有点超出我的能力范围。此外,数据库表名和字段名都是五花八门的。感谢您的评论。 - jpshook
4个回答

2

您需要考虑添加以下内容:验证、属性更改通知、数据绑定等等。当将每个类分离到多个类(DAL、BLL等)中时,常见的一个问题是您最终需要复制大量代码。另一个问题是如果您需要这些类之间有一些亲密关系,您将不得不创建内部成员(接口、字段等)。

这就是我会做的事情,构建一个独特而一致的领域模型,就像这样:

public class Product: IRecord, IDataErrorInfo, INotifyPropertyChanged
{
    // events
    public event PropertyChangedEventHandler PropertyChanged;

    // properties
    private int _id;
    public virtual int Id
    {
        get
        {
            return _id;
        }
        set
        {
            if (value != _id)
            {
                _id = value;
                OnPropertyChanged("Id");
            }
        }
    }

    private string _name;
    public virtual string Name
    {
        get
        {
            return _name;
        }
        set
        {
            if (value != _name)
            {
                _name = value;
                OnPropertyChanged("Name");
            }
        }
    }

    // parameterless constructor (always useful for serialization, winforms databinding, etc.)
    public Product()
    {
        ProductId = 0;
        Name = String.Empty;
    }

    // update methods
    public virtual void Save()
    {
       ValidateThrow();
       ... do save (insert or update) ...
    }

    public virtual void Delete()
    {
       ... do delete ...
    }    

    // validation methods
    public string Validate()
    {
       return Validate(null);
    }

    private void ValidateThrow()
    {
      List<Exception> exceptions = new List<Exception>();
      SummaryValidate(exceptions,memberName);
      if (exceptions.Count != 0)
         throw new CompositeException(exceptions);
    }

    public string Validate(string memberName)
    {
      List<Exception> exceptions = new List<Exception>();
      SummaryValidate(exceptions,memberName);
      if (exceptions.Count == 0)
        return null;

      return ConcatenateAsString...(exceptions);
    }

    string IDataErrorInfo.Error
    {
      get
      {
         return Validate();
      }
    }

    string IDataErrorInfo.this[string columnName]
    {
      get
      {
        return validate(columnName);
      }
    }

    public virtual void SummaryValidate(IList<Exception> exceptions, string memberName)
    {
       if ((memberName == null) || (memberName == "Name"))
       {
         if (!... validate name ...)
            exceptions.Add(new ValidationException("Name is invalid");
       }
    }

    protected void OnPropertyChanged(string name)
    {
       OnPropertyChanged(new PropertyChangedEventArgs(name));
    }

    // property change notification
    protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        if ((PropertyChanged != null)
            PropertyChanged(this, e);
    }

    // read from database methods
    protected virtual Read(IDataReader reader)
    {
      Id = reader.GetInt32(reader.GetOrdinal("Id"));
      Name = = reader.GetString(reader.GetOrdinal("Id"));
      ...
    }

    void IRecord.Read(IDataReader reader)
    {
      Read(reader);
    }

    // instance creation methods
    public static Product GetById(int id)
    {
        // possibly use some cache (optional)
        Product product = new Product();
        using (IDataReader reader = GetSomeReaderForGetById...(id))
        {
            if (!reader.Read())
              return null;

            ((IRecord)product).Read(reader);
            return product;
        }
    }

    public static List<Product> GetAll()
    {
        // possibly use some cache (optional)
        List<Product> products = new List<Product>(); // if you use WPF, an ObservableCollection would be more appropriate?
        using (IDataReader reader = GetSomeReaderForGetAll...(id))
        {
            while (reader.Read())
            {
              Product product = new Product();
              ((IRecord)product).Read(reader);
              products.Add(product);
            }
        }
        return products;
    }
}

// an interface to read from a data record (possibly platform independent)
public interface IRecord
{
  void Read(IDataReader reader);
}

产品可以返回自身吗?为什么要传递reader呢,即使它是IDataReader,这看起来有点奇怪。不幸的是,我只能理解你说的四分之一... - jpshook
@Developr - 是的,在这种情况下,Product 返回它自己。为什么不呢?.NET Framework 本身就有许多这样的例子。 - Simon Mourier
我不同意让对象处理自己的持久化。在我的建议中,我建议另一个类来处理对象的持久化 - 这将有助于您将来转换为使用ORM,因为原始对象仍然可以单独使用。 - Alex Lo
@Alex - 嗯,我相信有些人会不同意 :-) 我不需要任何ORM。 - Simon Mourier
@Simon,是的,我确定,并且对于小问题来说,我确信这种方法很有效。OP暗示要在某个时候使用ORM,所以我提到了这种方法的一个弱点。你如何处理在你的方案中引用其他对象的对象? - Alex Lo
@Alex - 嗯,我的经验表明这对于任何项目规模都可以实现。关于引用对象,我会使用这些其他对象的ID(例如,引用对象本身有1个属性,N个引用对象的键有N个属性),没有什么花哨的东西。 - Simon Mourier

2
Cade有一个很好的解释。为了避免贫血的领域模型,您可以考虑以下几点:
  • 将DTO对象作为您的领域对象(只需将其称为“Product”)
  • IsValidProductId可以在Product上,当调用setter时,您可以验证它是否有效,如果无效则抛出异常
  • 实现关于名称的某些规则
  • 如果还有其他与Product交互的对象,我们可能会有更有趣的事情要讨论

那么,当我需要一个包含5,000个以上项目的列表时,我真的想要承担5000多个BLL验证等的成本吗?此外,为什么我要将带有其他方法的对象传递给我的DAL?我不应该只传递数据吗?我的BLL是否应该变成只有产品,然后使用我的DTO在它和DAL之间传递数据? - jpshook
@Developr 如果你需要一个5000+项目的列表 - 那是一个不同的问题。在这种情况下,你需要查看将要操作5000个项目的过程,并决定它是否可以更接近数据库(即在SQL中)完成,如果不能,那么该操作能否在轻量级版本的对象上进行操作(我称之为摘要),也许是为了下拉或查找缓存(在这里,你只需要ID和描述)。领域模型是涵盖整个问题领域的类网络。因此,一个类并不足以说明设计是糟糕/贫血的。 - Cade Roux
你担心运行哪些验证?它比你的DAL需要更多的方法,但又怎样?如果你想要,你可以为它创建一个仅限于DAL的接口。"我难道不应该只传递数据吗?" => 不,你需要带有行为的对象。 - Alex Lo
@Alex Lo - 所以,基本上,我需要将我的BLL和DTO合并吗?还是你是说我应该只是将我的DTO重命名为Products并添加一些方法?那么我怎么知道哪些方法应该放在BLL中呢? - jpshook
@Cade Roux - 我有许多类似于这些的其他类,大部分是按数据库表划分的,但有些是相互关联的。 - jpshook
我会从创建一个“产品”开始,然后再创建一个“ProductMapper”或“ProductPersistanceService”。产品具有数据和业务行为,如果需要分离,则可以创建单独的BBL接口和数据接口。 “ProductPersistanceService”从数据库中获取产品并保存它们。 - Alex Lo

1

贫血领域是指产品或其他类别并未实现除数据设置器和获取器之外的任何领域行为。

例如,产品领域对象应该暴露一些方法,进行一些数据验证,有一些真正的业务逻辑。

否则,BLL版本(领域对象)几乎与DTO无异。

http://martinfowler.com/bliki/AnemicDomainModel.html

ProductBLL productBll = new ProductBLL();
List<ProductDTO> productList = productBll.GetAllProducts();

问题在于您预设了您的模型是贫血的,并将DTO暴露给业务层消费者(UI或其他)。
您的应用程序代码通常希望使用而不是任何BLL或DTO或其他实现类。它们不仅对应用程序员级别的思考意义不大,对于表面上理解问题域的领域专家来说也意义不大。因此,它们只应在您处理管道时可见,而不是在您设计浴室时可见,如果您明白我的意思。
我将我的BLL对象命名为业务域实体的名称。DTO在业务实体和DAL之间是内部的。当域实体没有比DTO更多的功能时,它就是贫血的。
另外,我还要补充一点,我经常省略显式DTO类,让域对象进入具有在配置文件中定义的组织存储过程的通用DAL,并从普通的数据读取器中加载到其属性中。通过闭包,现在可以拥有非常通用的DAL,其中回调函数让您插入参数。
我建议坚持最简单的可能工作方式:
public class Product {
    // no one can "make" Products
    private Product(IDataRecord dr) {
        // Make this product from the contents of the IDataRecord
    }

    static private List<Product> GetList(string sp, Action<DbCommand> addParameters) {
        List<Product> lp = new List<Product>();
        // DAL.Retrieve yields an iEnumerable<IDataRecord> (optional addParameters callback)
        // public static IEnumerable<IDataRecord> Retrieve(string StoredProcName, Action<DbCommand> addParameters)
        foreach (var dr in DAL.Retrieve(sp, addParameters) ) {
            lp.Add(new Product(dr));
        }
        return lp;
    }

    static public List<Product> AllProducts() {
        return GetList("sp_AllProducts", null) ;
    }

    static public List<Product> AllProductsStartingWith(string str) {
        return GetList("sp_AllProductsStartingWith", cm => cm.Parameters.Add("StartsWith", str)) ;
    }

    static public List<Product> AllProductsOnOrder(Order o) {
        return GetList("sp_AllProductsOnOrder", cm => cm.Parameters.Add("OrderId", o.OrderId)) ;
    }
}

然后,您可以将明显的部分移出到DAL中。 DataRecords用作DTO,但它们的生命周期非常短 - 它们的集合实际上从未存在过。

这是一个针对SqlServer的DAL.Retrieve,它是静态的(您可以看到它足够简单,以便更改为使用CommandText); 我有一个版本封装了连接字符串(因此它不是静态方法):

    public static IEnumerable<IDataRecord> SqlRetrieve(string ConnectionString, string StoredProcName,
                                                       Action<SqlCommand> addParameters)
    {
        using (var cn = new SqlConnection(ConnectionString))
        using (var cmd = new SqlCommand(StoredProcName, cn))
        {
            cn.Open();
            cmd.CommandType = CommandType.StoredProcedure;

            if (addParameters != null)
            {
                addParameters(cmd);
            }

            using (var rdr = cmd.ExecuteReader())
            {
                while (rdr.Read())
                    yield return rdr;
            }
        }
    }

之后你可以转向完整的框架。


我确实理解你所说的只使用Product,但是那么我需要在我的BLL中重复使用大部分与我的DTO相同的属性,对吗?实体/领域对象如何从DAL返回自身?还是需要一个单独的服务类?我只需要一个好的具体方法来完成它,以便我可以重复和集成。 - jpshook
@开发者 这是业务逻辑,因此似乎产品不适合被认为是贫血的候选对象。 - Cade Roux
那么,你的意思是我应该将DTO与BLL合并,并将其视为我的实体?还是我仍然需要DTO在我的领域和DAL之间传递数据? - jpshook
感谢Cade的帮助.. 有点明白了。所以,如果我仍然想使用DTO和DAL来在DAL和BLL/entity之间获取/传递数据,那没问题,只要我停止依赖DTO在UI上,并集中于领域方面。因此,我需要在我的BLL/entity/Domain对象中重复大部分DTO属性,对吗? - jpshook
@开发者 你可以在任何框架中添加更多的层和更多的复制/序列化,但我认为仅仅为了在构建对象之前快速关闭连接而复制整个数据集的开销只有在你的对象非常复杂且昂贵时才有意义。此时,似乎你也不想构建很多这样的对象,因此连接时间不应该是一个问题。如果单行连接拉取了大量数据,则可能是BLOB,因此简单的I/O是问题所在。 - Cade Roux
显示剩余13条评论

1

关于使用ORM的其他人的说法 - 随着您的模型扩展,如果没有ORM,您将会有很多代码重复。但我想评论一下您的“5000个”问题。

复制一个类并不会创建它的方法的5000个副本。它只会创建数据结构的副本。在域对象中具有业务逻辑不会失去效率。如果某些业务逻辑不适用,则可以创建装饰对象的子类以实现特定目的,但其目的是创建与您预期使用相匹配的对象,而不是提高效率。贫血设计模型并不更有效。

此外,请考虑如何在应用程序中使用数据。我无法想象自己曾经使用过像“GetAllOfSomething()”这样的方法,除了可能是参考列表。检索数据库中的所有内容的目的是什么?如果是为了执行某些过程、数据操作、报告,您应该公开执行该过程的方法。如果您需要公开列表以供某些外部用途使用,例如填充网格,则公开一个IEnumerable并提供子集数据的方法。如果您从处理完整的内存数据列表的想法开始,随着数据的增长,您将面临严重的性能问题。


我应该把“get all”列为“get many”……在我的实际代码中,我使用ROW_Number和CTE来进行分页等操作。 - jpshook
我所读到的关于nHibernate的所有内容都说它非常慢,而我真的想学习最好的方法,而不仅仅是将其传递给一个工具。该网站非常庞大(类似于拥有1500个产品加上带有评论、论坛等流媒体视频),并且相对高流量(每月高达50万独立访客)。 - jpshook
好的 - 但不要返回一个列表,而是返回IEnumerable<T>。这样更加灵活,否则每次返回列表时,都会遍历两次:一次在创建时,另一次在类外部使用时。如果你确实需要一个列表,那么你可以只说List<T> myList = new List<T>(someEntityThatReturnsIEnumerable<T>) - 这可能与List<T> mylist = someEntityThatReturnsList相比,循环对循环来说是相同的。 - Jamie Treworgy
我个人没有使用过nHibernate,所以无法对此发表意见。但请记住,与编写代码相比,增加CPU功率是很便宜的。即使在您所讨论的规模下,我敢打赌您不会遇到太多麻烦。但是当您开始开发您的模型时,例如查看Simon Mourier的答案(这是一个很好的模型),请考虑每个属性中重复的代码量,例如验证、数据库操作、异常处理等。至少将映射中的常见功能封装到一个类中,这实际上是一个非常基本的ORM实现。 - Jamie Treworgy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接