ORM是明显的反模式

14-12-02 banq
                   

作为Java和Ruby程序员与架构师的Yegor昨天发表一篇博文:ORM Is an Offensive Anti-Pattern,认为ORM是一个可怕的反模式,违反了所有的面向对象原则,撕裂了对象,将它们变成哑巴和被动的数据袋,没有任何借口在任何应用程序中使用ORM,无论是成千上万的小型Web应用或企业级的基于数据表的CRUD操作系统(ORM包括Java的Hibernate/JPA,python的django,),那么取而代之是什么?会讲SQL的对象 (SQL-speaking object)。

ORM是如何工作的

对象关系数据库ORM技术或模式是使用面向对象技术如Java访问一个关系数据库,每个语言都有ORM实现,如Java的Hibernate,Ruby的active record, PHP的Doctrine, Python的 SQLAlchemy,在java中,ORM甚至被设计为标准,如JPA。

首先,让我们看看ORM是如何工作的,。 让我们使用Java,PostgreSQL,Hibernate。 假设我们有一个表在数据库中,称为post :

+-----+------------+--------------------------+
| id  | date       | title                    |
+-----+------------+--------------------------+
|   9 | 10/24/2014 | How to cook a sandwich   |
|  13 | 11/03/2014 | My favorite movies       |
|  27 | 11/17/2014 | How much I love my job   |
+-----+------------+--------------------------+
<p>

现在,我们需要为这个表产生Java应用的CRUD方式(增删改查),首先,我们曾经一个Post类:

@Entity
@Table(name = "post")
public class Post {
  private int id;
  private Date date;
  private String title;

  @Id
  @GeneratedValue
  public int get[author]Id[/author]() {
    return this.id;
  }

  @Temporal(TemporalType.TIMESTAMP)
  public Date getDate() {
    return this.date;
  }

  public Title getTitle() {
    return this.title;
  }

  public void setDate(Date when) {
    this.date = when;
  }

  public void setTitle(String txt) {
    this.title = txt;
  }
}
<p>

在使用Hibernate操作之前,我们得创建一个session工厂:

SessionFactory factory = new AnnotationConfiguration()
  .configure()
  .addAnnotatedClass(Post.class)
  .buildSessionFactory();
<p>

工厂每次我们要使用Post对象时产生一个session,每次使用session应当如下使用代码包装:

Session session = factory.openSession();
try {
  Transaction txn = session.beginTransaction();
  // your manipulations with the ORM, see below
  txn.commit();
} catch (HibernateException ex) {
  txn.rollback();
} finally {
  session.close();
}
<p>

当session准备好后,下面我们就可以从数据表中获取所有的post:

List posts = session.createQuery("FROM Post").list();
for (Post post : (List<Post>) posts){
  System.out.println("Title: " + post.getTitle());
}
<p>

我认为这是清楚的, Hibernate是一个强大的连接到数据库的引擎,通过执行必要的SQL SELECT请求,获取检索数据。 然后它创建了类Post的实例,并将数据装入其中。 当这个对象过来时,它填满了数据,我们应该使用getter方法将这些数据取出,比如我们使用 getTitle() 方法。

当我们想做一个反向操作,将一个对象发送到数据库,我们做的都基本相同,只不过以相反的顺序。 我们创建类Post的一个实例 文章,然后塞进入数据,请求Hibernate保存它:

Post post = new Post();
post.setDate(new Date());
post.setTitle("How to cook an omelette");
session.save(post);
<p>

这是几乎是每一个ORM工作原理。 基本原则始终是相同的——ORM装有数据的贫血对象。 我们谈论的是ORM框架,这些框架与数据库交互, 对象只有帮助我们将请求发送给ORM框架,并理解其响应。 除了getter和setter,对象没有其他方法。 他们甚至不知道他们来自哪个数据库。

这是对象关系映射是如何工作的。

也许你会问,怎么了?

ORM怎么了?

说真的,有什么错吗? Hibernate是最流行的Java库,已经超过10年了。 世界上几乎每一个SQL-intensive应用程序都是使用它。 每个Java教程会提及Hibernate(或者 其他一些ORM 像TopLink或OpenJPA)用于database-connected应用程序。 它实际上是一个标准, 然而我还要说它错了? 是的。

我声称整个ORM背后的想法是错误的。 它的发明是也许OOP领域空引用之后第二大错误 。

其实,我不是唯一一个说这样的事情的人,绝对不是第一个。 很多非常受人尊敬的作者已经发表关于这个主题,包括 Matinfowler的OrmHate , Jeff Atwood的对象关系映射是计算机科学的越南战争 ,Ted Neward的计算机科学的越南 , Laurie Voss的ORM是一种反模式,等等还有许多其他人。

然而我的观点不同于他们所说的,尽管他们的理由是来自实践且有效,如ORM是慢的,数据库升级很难等,他们错失了主要点,你能从Bozhidar Bozhanov的ORM Haters Don’t Get It文章中看到非常好的实战回答。

ORM主要点并不是封装数据库交互到一个对象,释放数据,遍历时撕开了坚固且聚合的living organism(实体),对象的一部分保持数据,而另外一部分是在ORM引擎(session factory)内部执行,这些引擎知道如何处理这些数据,并且转换它到关系数据库,看看这张图,它模拟了ORM如何工作:


我作为Post文章的读者,得和两个组件打交道,1是ORM,2是返回一个砍了头的对象,我打交道的行为应该有一个单点(操作一个组件),这个对象才是OOP,而在ORM这种情况下,我得与两个点打交道,ORM和数据对象,甚至我们都不能称之为对象。

因为这个可怕的和明显地违反了面向对象范式,我们已经有很多实际中受人尊敬的出版物都在提到这个问题,这里举例一些:

SQL并没有隐藏,ORM的用户应该会使用SQL(或者方言如HQL),看看上面案例,我们调用session.createQuery("FROM Post")是为了获得所有文章Posts,即使它不是SQL,也很类型,关系模型并没有被封装到对象中,相反,它暴露在整个应用程序中,使用这个对象的每个人都必须与关系数据库打交道,以获得或保存什么,这样ORM并没有隐藏和封装SQL,而是污染了整个应用程序。

难以测试。当一些对象要与Posts文章列表交互时,它需要处理一个实例 SessionFactory 。 我们如何在测试中模拟这种依赖性? 我们必须创建一个它的模拟吗? 这个任务有多复杂? 看看上面的代码,你将意识到冗长和繁琐的单元测试。 相反,我们可以编写集成测试并将整个应用程序连接到一个测试版本的PostgreSQL。 在这种情况下,没有必要模拟一个 SessionFactory ,但这种测试将会相当缓慢,更重要的是,我们却将一个并没有和数据库有关系的数据对象却作为数据库的实例对象进行测试。非常糟糕的设计。

我要再次重申。 上面ORM问题导致的后果。 ORM基本缺点是撕裂了对象,可怕和明显违反了对象理念:一个对象是什么

待续见下贴:

[该贴被banq于2014-12-02 11:55修改过]

                   

3
banq
2014-12-02 14:08

让我们设计一下类Post,我们将其分解为两个类Post和Posts,单数与复数,好的对象总是真实世界的抽象,比如实际中我们有数据表和表行,这就是为什么我们会有两个类,Posts代表表,而Post代表行。

每个对象都应该在合约和接口下工作,那么就设计两个接口,这些接口是不可变的Posts是:

interface Posts {
  Iterable<Post> iterate();
  Post add(Date date, String title);
}
<p>

单个Post是:

interface Post {
  int id();
  Date date();
  String title();
}
 

下面是列出数据表中所有的Posts:

 Posts posts = // we'll discuss this right now
for (Post post : posts.iterate()){
  System.out.println("Title: " + post.title());
}
 

下面是创建一个新的post:

 Posts posts = // we'll discuss this right now
posts.add(new Date(), "How to cook an omelette");
 

如你所见,我们现在拥有真正的对象。 他们负责所有操作,完美地隐藏他们的实现细节。 没有事务、会议或工厂。 我们甚至不知道这些对象实际上是和PostgreSQL有关系, 如果把数据保存在文本文件中也无关系。 所有我们需要的的是Post 是一个能够为我们列出所有帖子, 创建一个新的Post。 实现细节是完全隐藏在里面。

现在让我们来看看我们如何实现这两个类。

这里使用jcabi-jdbc作为JDBC包装器,你可以使用你喜欢的普通JDBC方式,这些都没有关系,重要的是你的数据交换被隐藏在对象中,开始Posts和气实现PgPosts,pg代表PostreSQL:

final class PgPosts implements Posts {
  private final Source dbase;
  public PgPosts(DataSource data) {
    this.dbase = data;
  }
  public Iterable<Post> iterate() {
    return new JdbcSession(this.dbase)
      .sql("SELECT id FROM post")
      .select(
        new ListOutcome<Post>(
          new ListOutcome.Mapping<Post>() {
            @[author][author][author]Override[/author][/author][/author]
            public Post map(final ResultSet rset) {
              return new PgPost(rset.getInteger(1));
            }
          }
        )
      );
  }
  public Post add(Date date, String title) {
    return new PgPost(
      this.dbase,
      new JdbcSession(this.dbase)
        .sql("INSERT INTO post (date, title) VALUES (?, ?)")
        .set(new Utc(date))
        .set(title)
        .insert(new SingleOutcome<Integer>(Integer.class))
    );
  }
}
<p>

下面是Post接口的实现:

final class PgPost implements Post {
  private final Source dbase;
  private final int number;
  public PgPost(DataSource data, int id) {
    this.dbase = data;
    this.number = id;
  }
  public int id() {
    return this.number;
  }
  public Date date() {
    return new JdbcSession(this.dbase)
      .sql("SELECT date FROM post WHERE id = ?")
      .set(this.number)
      .select(new SingleOutcome<Utc>(Utc.class));
  }
  public String title() {
    return new JdbcSession(this.dbase)
      .sql("SELECT title FROM post WHERE id = ?")
      .set(this.number)
      .select(new SingleOutcome<String>(String.class));
  }
}
<p>

这就是整个与数据库交互场景,最后是使用:

Posts posts = new PgPosts(dbase);
for (Post post : posts.iterate()){
  System.out.println("Title: " + post.title());
}
Post post = posts.add(new Date(), "How to cook an omelette");
System.out.println("Just added post #" + post.id());
<p>

你可以在这里看到完整案例。

性能如何

我能听到你的尖叫,“性能怎么样? ”上面脚本几行我们制造许多冗余的数据库往返。首先,我们根据id检索post id ,然后,为了得到他们的标题,我们额外的从每个帖子查询 SELECT title 。 这是低效的,或者简单地说,太慢了。

不用担心,这是面向对象编程,这意味着它是灵活! 让我们创建一个装饰 PgPost 这将在它的构造函数接受所有缓存它在内部的数据:

final class ConstPost implements Post {
  private final Post origin;
  private final Date dte;
  private final String ttl;
  public ConstPost(Post post, Date date, String title) {
    this.origin = post;
    this.dte = date;
    this.ttl = title;
  }
  public int id() {
    return this.origin.id();
  }
  public Date date() {
    return this.dte;
  }
  public String title() {
    return this.ttl;
  }
}
<p>

值得注意的是,装饰者不知道PostgreSQL 和JDBC,它只是粉刷Post类型的

对象,并预先缓存日期和标题,装饰者也是不可变的。

改变Posts实现,返回不变的Post对象:

final class ConstPgPosts implements Posts {
  // ...
  public Iterable<Post> iterate() {
    return new JdbcSession(this.dbase)
      .sql("SELECT * FROM post")
      .select(
        new ListOutcome<Post>(
          new ListOutcome.Mapping<Post>() {
            @[author][author][author]Override[/author][/author][/author]
            public Post map(final ResultSet rset) {
              return new ConstPost(
                new PgPost(rset.getInteger(1)),
                Utc.getTimestamp(rset, 2),
                rset.getString(3)
              );
            }
          }
        )
      );
  }
}
<p>

事务如何实现?

每一个对象应该处理自己的事务并封装它们,这会导致嵌套了数据库支持的事务。下面是使用callable是的事务类

final class Txn {
  private final DataSource dbase;
  public <T> T call(Callable<T> callable) {
    JdbcSession session = new JdbcSession(this.dbase);
    try {
      session.sql("START TRANSACTION").exec();
      T result = callable.call();
      session.sql("COMMIT").exec();
      return result;
    } catch (Exception ex) {
      session.sql("ROLLBACK").exec();
      throw ex;
    }
  }
}
<p>

然后你可以封装一些对象行为在一个事务中,如下:

new Txn(dbase).call(
  new Callable<Integer>() {
    @[author][author][author]Override[/author][/author][/author]
    public Integer call() {
      Posts posts = new PgPosts(dbase);
      Post post = posts.add(new Date(), "How to cook an omelette");
      posts.comments().post("This is my first comment!");
      return post.id();
    }
  }
);
<p>

这段代码将创建一个新的post并增加一个评论到这个Post。如果其中一个调用失败,整个事务将会回滚。

这就是我的面向对象,称为"SQL-speaking"对象,因为它们知道如何和数据库对话,这是它们应该做的,因此封装在它们的边界内。

[该贴被banq于2014-12-02 14:11修改过]

banq
2014-12-02 14:15

个人认为这种做法只适合Post很小的情况,如果Post是一个复杂的实体,有很多字段包括子对象,这样将数据库 缓存与业务子对象封装在一起,也就是组合在一起,虽然符合封装,但是内部职责不清,违背单一职责原理,然后会将Posts 改为专门负责数据库仓储操作的接口PostsRepository,然后Post从仓储或工厂中制造出来,这就走上了DDD仓储设计的道路。

clonalman
2014-12-05 06:51

2014-12-02 11:52 "@banq"的内容

SQL并没有隐藏..

难以测试..

interface Posts {

Iterable<Post> iterate();

Post add(Date date, String title);

}

必须搞清楚的是sql或hql的隐藏是否是orm的工作?

引入任何框架,测试必然多出一些准备工作?

所谓“对象理念”并不等同于在集合中持 久对象,对于这一点ORM也能实现

如果要更DDD一点,第一个持久入口点是Repository,而后才是Collection

[该贴被admin于2014-12-05 13:17修改过]

[该贴被admin于2014-12-05 13:17修改过]

joeaniu
2014-12-23 15:21

仓储Repository显然比Posts实现灵活多了,既能隐藏细节,又能适应聚合对象的需求。

2Go 1 2 下一页