ORM 是一種討厭的反模式

2016-08-13    分類:JAVA開發、程式設計開發、設計模式、首頁精華0人評論發表於2016-08-13

本文由碼農網 – 孫騰浩原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

TL;DR(“Too Long; Didn’t Read.”太長不想看,可以看這段摘要 )ORM是一種討厭的反模式,違背了所有物件導向的原則。將物件分隔開並放入被動的資料包中。ORM沒有理由存在任何應用中,無論是小的網路應用還是涉及增刪改查上千張表的企業系統。用什麼來替代它呢?SQL物件(SQL-speaking objects)。

ORM如何工作

Object-relational mapping(ORM)是一種連結關係型資料庫和麵向物件語言(比如Java)的技術。在各種語言中有大量ORM的實現;比如:Java中的Hibernate,Ruby on Rails中的ActiveRecord,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

如果我們在Java應用中對這張表增刪改查(CRUD:create, read, update, and delete).首先,我們建立一個 Post 類(很抱歉程式碼很長,我儘量簡潔一點)

@Entity
@Table(name = "post")
public class Post {
  private int id;
  private Date date;
  private String title;
  @Id
  @GeneratedValue
  public int getId() {
    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;
  }
}

在Hibernate做任何操作前,我們要建立一個session工廠:

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

這個工廠能在每次我們需要操作 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();
}

當session準備就緒,下面是我們從資料庫表中獲取所有 post:

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

我認為到這就很簡單了。Hibernate是一個強大的引擎,連結資料庫,執行SQL SELECT請求,然後取得資料。然後例項化 Post物件並裝填資料。當我們得到物件時,它已經有了資料,比如我們上面使用 getTitle()。

當我們想進行反向操作,將一個物件存入資料庫,操作相同,順序相反。我們先例項化 Post物件,裝填資料,然後讓Hibernate儲存它:

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

這就是所有的ORM如何工作。基本原則總是一樣的——ORM物件就是資料的信使。我們與ORM框架互動。框架與資料庫互動。物件只是幫助我們向框架傳送請求並處理響應。除了get和set,沒有其他方法,我們甚至不知道資料庫在哪。

這就是物件-關係對映如何工作。

你或許會問哪裡有問題?到處!

ORM有什麼問題?

講真的,這有什麼問題?Hibernate成為最受歡迎的Java庫已有10多年,幾乎所有處理SQL的應用都在使用它,每個Java教程都會介紹Hibernate(或其他ORM,比如TopLink和OpenJPA)作為資料庫連線應用,它已成為一個標準。我還要認為它有問題嗎?當然。

我想說整個ORM的構想就是有問題的,它的發明簡直是物件導向程式設計裡NULL指標之後的第二大錯誤。

事實上,我並不是第一個指出這個問題的人,有眾多知名作者討論這個話題,包括Martin Fowler寫的OrmHate(雖然不是反對ORM,但是也值得關注),Jeff Atwood寫的 Object-Relational Mapping Is the Vietnam of Computer Science,Ted Neward寫的The Vietnam of Computer Science,Laurie Voss寫的ORM Is an Anti-Pattern等等。

然而,我的論點不同於上面幾位,儘管他們的論點實用又有根據,比如“ORM很慢”或“資料庫升級困難”,他們沒抓住重點。你可以在Bozhidar Bozhanov的ORM Haters Don’t Get It這篇部落格中找到很棒的論點。

重點是ORM沒有在物件中封裝資料庫的互動,而是把固定的資料和活動的互動分隔開。一部分在物件中儲存資料,另一部分由ORM引擎實現(session工廠)來與資料庫互動資料。上面這張圖展示了ORM做了什麼。

我作為 post資料的操作者,需要處理兩個元件:一是ORM,二是返回的”刪減版”(指只有get和set方法)物件。物件導向程式設計強調的是關注單一的切入點,也就是物件。但在ORM中,我需要關注兩個切入點——ORM引擎和我們甚至不能稱之為物件的”東西”。

因為這嚴重違背了物件導向的正規化,我們可以從很多德高望重的論文中找到實用的解決方案,我可以再提供更多的解決方案。

SQL不再隱藏(SQL Is Not Hidden),ORM使用者常使用SQL(或其他方言,比如HQL)。上面例子中,我們呼叫 session.createQuery(“FROM Post”) 來獲取所有 post。即使這不是SQL,但很像SQL,所以關係模型並沒有封裝在物件中。反而暴露在整個應用中,每個人操作物件時無可避免的需要處理關係模型,來獲取或儲存資料。所以ORM並沒有隱藏和包裹SQL,反而使其暴露在整個應用中。

難於測試。如果某個物件處理post的陣列,它必須處理 SessionFactory的例項。我們如何mock這個依賴呢?看上面的程式碼,你就會意識到單元測試會有多繁瑣和麻煩。相反,我們可以編寫整合測試,用整個應用連結到虛構的測試PostgreSQL。這樣,我們就不需要mock一個 SessionFactory,但這樣的測試會很慢,更重要的是,我們虛構的和資料庫無關這個物件會和資料庫例項衝突。這是個可怕的設計。

我再次重申,ORM的實際問題就是這種後果,本質缺點就是ORM將物件分隔開,嚴重違反了物件的含義。

SQL物件(SQL-Speaking Objects)

有什麼解決方案?讓我給你舉個例子,我們來設計一個 Post類,我們需要將它分成兩個類:Post和 Posts,單個和多個。我曾在我的一篇文章中提到過,一個好的物件是現實生活中實體的抽象,我們來實踐這一原則。我們有兩個實體:資料庫表和表格行,這就是為什麼我們建立兩個類:Post展示表,Post展示行。

我也曾在文章中說過,每個物件應該關聯並實現一個介面,讓我們先來建立兩個介面,當然我們的物件是不可變的,Posts應該是這樣的:

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

單一的 Post應該是這樣的:

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

遍歷資料庫中的所有的post:

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互動,或許它只是把資料儲存在txt檔案中。Posts帶給我們的是獲取post列表和建立新post的能力,具體實現很好地隱藏在其中,現在讓我們看一看如何實現這兩個類。

我將使用jcabi-jdbc作為JDBC包裹,當然你也可以使用其他你喜歡的JDBC,這無所謂,重點是與資料庫的互動要隱藏在物件中,讓我們開始實現 PgPosts類(“pg”表示PostgreSQL)。

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>() {
            @Override
            public Post map(final ResultSet rset) {
              return new PgPost(
                this.dbase,
                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))
    );
  }
}

然後我們建立 PgPost類來實現 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));
  }
}

下面就是我們用剛剛建立的類來和資料庫互動:

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());

你可以在這看到完整例子.這是一個開源的web app使用PostgreSQL來實現上面提到的-SQL-speaking objects.

效能如何?

我能聽到你的驚呼“效能怎麼樣?”在上面幾行程式碼中,我們建立了和資料庫的冗餘連結。首先我們用 SELECT id來檢索post的ID,然後為了獲取它們的title,我們對應每個post傳送 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;
  }
}

注意:這個裝飾器並不知道PostgreSQL或JDBC,它僅僅是 POST物件的裝飾,並快取資料和title。通常,這個裝飾器是不可變的。

現在我們來建立 Posts的另一個實現,其返回一個”不可變”的物件:

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>() {
            @Override
            public Post map(final ResultSet rset) {
              return new ConstPost(
                new PgPost(
                  ConstPgPosts.this.dbase,
                  rset.getInteger(1)
                ),
                Utc.getTimestamp(rset, 2),
                rset.getString(3)
              );
            }
          }
        )
      );
  }
}

現在所有post通過這個 iterate()方法返回,並且從資料庫中取到了資料裝配在新的類中。

使用裝飾器和對相同介面的眾多實現,你可以組合任意的功能,最重要的是擴充套件功能的同時,不要增加設計的複雜度,因為類的大小不會增長,我們使用新的高聚合的類,它們更小巧。

事務又如何?

每個物件應該在其單獨的事務中執行,並且將 SELECT和 INSERT封裝在一起,這需要內建事務,資料庫提供非常棒的支援。如果沒有這樣的支援,建立一個會話事務物件必須接受一個”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;
    }
  }
}

然後,當你想在一個事務中進行一系列的操作,像下面這樣:

new Txn(dbase).call(
  new Callable<Integer>() {
    @Override
    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();
    }
  }
);

這段程式碼會建立一個新的post並提交一個comment.如果任何呼叫失敗,整個事務將回滾。

對於我來說,這就是物件導向,我稱它為”SQL-speaking objects”,因為它們知道如何與資料庫伺服器通過SQL互動,這是它們的能力,完美封裝在其內部。

譯文連結:http://www.codeceo.com/article/orm-is-offensive-anti-pattern.html
英文原文:ORM Is an Offensive Anti-Pattern
翻譯作者:碼農網 – 孫騰浩
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章