ActiveRecord - 基本操作与关联设计

All problems in computer science can be solved by another level of indirection(abstraction) - David Wheeler…except for the problem of too many layers of indirection. - Kevlin Henney’s corollary

请注意本章内容衔接前两章,请先完成前两章内容。

ORM 与抽象渗漏法则

ORM (Object-relational mapping ) 是一种对映射关联式资料与物件资料的程式技术。物件导向和从数学理论发展出来的关联式数据库,有着显着的区别,而 ORM 正是解决这个不匹配问题所产生的工具。它可以让你使用物件导向语法来操作关联式数据库,非常容易使用、撰码十分有效率,不需要撰写繁琐的SQL语法,同时也增加了程式码维护性。

不过,有些熟悉 SQL 语法的程式设计师反对使用这样的机制,因为直接撰写 SQL 可以确保操作数据库的执行效率,毕竟有些时候 ORM 产生出来的 SQL 效率不是最佳解,而你却不一定有经验能够意识到什么时候需要担心或处理这个问题。

知名软件人 Joel Spolsky (他有两本中文翻译书值得推荐:约耳趣谈软件和约耳续谈软件,悦知出版) 有个理论:抽象渗漏法则:所有重大的抽象机制在某种程式上都是有漏洞的。有非常多程式设计其实都是在建立抽象机制,C 语言简化了组合组言的繁杂、动态语言如 Ruby 简化了 C 语言、TCP 协定简化了 IP 通讯协定,甚至车子的挡风玻璃跟雨刷也简化了下雨的事实。

但是这些抽象机制或多或少都会力有未及的地方,用 C 语言撰写的 Linux 核心也包括少量组合语言、部分 Ruby 套件用 C 语言撰写扩充来增加效能、保证讯息会抵达 TCP 讯息,碰到 IP 封包在路由器上随机遗失的时候,你也只会觉得速度很慢、即使有挡风玻璃跟雨刷,开车还是必须小心路滑。

当某人发明一套神奇可以大幅提升效率的新程式工具时,就会听到很多人说:「应该先学会如何手动进行,然后才用这个神奇的工具来节省时间。」任何抽象机制都有漏洞,而唯一能完美处理漏洞的方法,就是只去弄懂该抽象原理以及所隐藏的东西。这是否表示我们应该永远只应该使用比较低阶的工具呢?不是这样的。而是应该依照不同的情境,选择效益最大的抽象化工具。以商务逻辑为多的 Web 应用程式,选择动态语言开发就相对合适,用 C 语言开发固然执行效率极高,但是完成相同的功能却需要极高的人月开发时数。如果是作业系统,使用无法随意控制内存分配的动态语言也显然不是个好主意。

能够意识到什么时候抽象化工具会产生渗漏,正是”有纯熟经验”的程式设计师和”新手”设计师之间的差别。ORM 虽然替我们节省了工作的时间,不过对资深的程式设计师来说,学习 SQL 的时间还是省不掉的。这一切都似乎表示,即使我们拥有愈来愈高阶的程式设计工具,抽象化也做得愈来愈好,要成为一个由高阶到低阶都纯熟的程式设计专家是愈来愈困难了(也越来越稀有及宝贵)。

建立新 Model

首先,让我们再示范如何建立一个 Model:

  1. rails g model category

这个指令会产生几个档案

  1. category.rb
  2. category_test.rb
  3. categories.yml
  4. xxxxxxxx_create_categories.rb

打开 xxxxxxxxcreate_categories.rb 你可以看到资料表的定义,让我们加上几个字段吧,除了建立categories表,同时也帮_events加上一个外部键让两个表可以关连起来,在后一章会用到:

  1. class CreateCategories < ActiveRecord::Migration[5.1]
  2. def change
  3. create_table :categories do |t|
  4. t.string :name
  5. t.integer :position
  6. t.timestamps
  7. end
  8. add_column :events, :category_id, :integer
  9. add_index :events, :category_id
  10. end
  11. end

接着执行以下指令便会产生出数据库资料表

  1. bin/rake db:migrate

db:migrate 指令会将上述的 Ruby 程式变成以下 SQL 执行。

  1. CREATE TABLE categories (
  2. "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  3. "name" varchar(255) DEFAULT NULL,
  4. "position" int(4) DEFAULT NULL,
  5. "created_at" datetime DEFAULT NULL,
  6. "updated_at" datetime DEFAULT NULL);

接着我们打开 category.rb 你可以看到

  1. class Category < ApplicationRecord
  2. end

这是一个继承 ApplicationRecord 的 Category 类别,你不需要定义这个Model有哪些字段,Rails会自动根据资料表纲要决定这个Model有哪些属性。

我们在学习 Ruby 的时候提过 irb 这个互动工具,而 Rails 也提供了特殊的 irb 接口叫做 console,让我们可以直接与 Rails 程式互动:

  1. bin/rails console (可以简写成 bin/rails c)

透过 console,我们可以轻易的练习操作 ActiveRecord。 TODO: 需要重写一段导论> Part 1 Basic 那章有非常基本的 Query 示范(可能需要改好一点)> Part 2 Query 那章则有完整的 Query 教学> 这里需要介于中间的介绍版本:### ActiveRecord 的特点1. 宣告风格2. 查询串接3. 惯例胜于设计—>

资料表关联设计

ActiveRecord可以用Associations来定义资料表之间的关联性,这是最被大家眼睛一亮ORM功能。到目前为止我们用了ActiveRecord来操作基本的数据库CRUD,但是还没充分发挥关联式数据库的特性,那就是透过primary keyforeign keys将资料表互相关连起来。

Primary Key主键是一张资料表可以用来唯一识别的字段,而Foreign Key外部键则是用来指向别张资料表的Primary Key,如此便可以产生资料表之间的关联关系。了解如何设计正规化关联式数据库请参考附录基础。

Primary Key这个字段在Rails中,照惯例叫做id,型别是整数且递增。而Foreign Key字段照惯例会叫做{model_name}_id,型别是整数。

一对多关联one-to-many

has_one diagram

一对多关联算是最常用的,延续Part1Event Model范例,一个Event拥有很多Attendee。我们来新增Attendee Model

  1. rails g model attendee name:string event_id:integer

执行bin/rake db:migrate产生attendees资料表。

分别编辑app/models/event.rbapp/models/attendee.rb

  1. class Event < ApplicationRecord
  2. has_many :attendees # 复数
  3. #...
  4. end
  5. class Attendee < ApplicationRecord
  6. belongs_to :event # 单数
  7. end

有个口诀可以记起来:有Foreign KeyModel,就是设定belongs_to的Model。在attendees资料表上有个event_idForeign Key

同样地,belongs_tohas_many这两个方法,会分别动态新增一些方法到AttendeeEvent Model上,让我们进入rails console实际操作数据库看看:

范例一,建立Attendee物件并关联到Event:

  1. e = Event.first
  2. a = Attendee.new( :name => 'ihower', :event => e )
  3. # 或 a = Attendee.new( :name => 'ihower', :event_id => e.id )
  4. a.save
  5. e.attendees # 这是阵列
  6. e.attendees.size
  7. Attendee.first.event

范例二,从Event物件中建立一个Attendee:

  1. e = Event.first
  2. a = e.attendees.build( :name => 'ihower' )
  3. a.save
  4. e.attendees

范例三,从Event物件中建立一个Attendee,并直接存进数据库:

  1. e = Event.first
  2. a = e.attendees.create( :name => 'ihower' )
  3. e.attendees

范例四,先建立Attendee物件再放到Event中:

  1. e = Event.first
  2. a = Attendee.create( :name => 'ihower' )
  3. e.attendees << a
  4. e.attendees

范例五,根据特定的Event查询Attendee

  1. e = Event.first
  2. e.id # 1
  3. a = e.attendees.find(3)
  4. attendees = e.attendees.where( :name => 'ihower' )

这样就可以写出限定在某个Event下的条件查询,用这种写法可以避免一些安全性问题,不会让没有权限的使用者搜寻到别的EventAttendee

范例六,删除

  1. e = Event.first
  2. e.attendees.destroy_all # 一笔一笔删除 e 的 attendee,并触发 attendee 的 destroy 回呼
  3. e.attendees.delete_all # 一次砍掉 e 的所有 attendees,不会触发个别 attendee 的 destroy 回呼

学到这里,还记得上一章建立的Category吗?它也要跟Event是一对多的关系,让我们补上程式吧:

  1. class Category < ApplicationRecord
  2. has_many :events
  3. end
  4. class Event < ApplicationRecord
  5. belongs_to :category, :optional => true
  6. # ...
  7. end

这里多了一个参数是 :optional => true,也就是允许 event 没有 category 的情况。

一对一关联one-to-one

has_one diagram

什么时候会需要一对一关联设计呢?直接合并在events table只用一个Event Model不也可以。会这样设计通常是为了节省查询量,例如Event有一个很包含非常多字段的event_detaileds table储存细节资料,一来只有进到详细页面才会用到、二来可能不是每一笔Event都有这个细节资料,所以拆表之后可以让绝大部分的操作都不需要去碰到event_detaileds table

一对一关联算是一对多关联的一种特例情况。假设一个Event拥有一个Location。来新增一个Location Model,其中的event_id就是外部键字段:

  1. rails g model location name:string event_id:integer

执行bin/rake db:migrate产生locations资料表。

分别编辑app/models/event.rbapp/models/location.rb

  1. class Event < ApplicationRecord
  2. has_one :location # 单数
  3. #...
  4. end
  5. class Location < ApplicationRecord
  6. belongs_to :event # 单数
  7. end

belongs_tohas_one这两个方法,会分别动态新增一些方法到LocationEvent Model上,让我们进入rails console实际操作数据库看看,透过Associations你会发现操作关联的物件非常直觉:

范例一,建立Location物件并关联到Event:

  1. e = Event.first
  2. l = Location.new( :name => 'Hsinchu', :event => e )
  3. # 等同于 l = Location.new( :name => 'Hsinchu', :event_id => e.id )
  4. l.save
  5. e.location
  6. l.event

Event.first会捞出events table的第一笔资料,如果你第一笔还在,那就会是Event.find(1)。同理,Event.last会捞出最后一笔。

范例二,从Event物件中建立一个Location:

  1. e = Event.first
  2. l = e.build_location( :name => 'Hsinchu' )
  3. l.save
  4. e.location
  5. l.event

范例三,直接从Event物件中建立一个Location:

  1. e = Event.first
  2. l = e.create_location( :name => 'Hsinchu' )
  3. e.location
  4. l.event

多对多关联many-to-many

has_one diagram

has_one diagram

另一种常见的关联模式则是多对多,一笔资料互相拥有多笔资料,例如一个Event有多个Group,一个Group有多个Event。多对多关联的实作必须多一个额外关联用的资料表(又叫作Join table),让我们来建立Group Model和关联用的EventGroupship Model,其中后者定义了两个Foreign Keys

  1. rails g model group name:string
  2. rails g model event_groupship event_id:integer group_id:integer

执行bin/rake db:migrate产生这两个资料表。

分别编辑app/models/event.rbapp/models/group.rbapp/models/event_groupship.rb

  1. class Event < ApplicationRecord
  2. has_many :event_groupships
  3. has_many :groups, :through => :event_groupships
  4. end
  5. class EventGroupship < ApplicationRecord
  6. belongs_to :event
  7. belongs_to :group
  8. end
  9. class Group < ApplicationRecord
  10. has_many :event_groupships
  11. has_many :events, :through => :event_groupships
  12. end

这个Join table笔者的命名习惯会是ship结尾,用以凸显它的关联性质。另外,除了定义Foreign Keys之外,你也可以自由定义一些额外的字段,例如记录是哪位使用者建立关联。

belongs_tohas_many我们见过了,这里多一种has_many :through方法,可以神奇地把EventGroup关联起来,让我们进入rails console实际操作数据库看看:

范例,建立双向关联记录:

  1. g = Group.create( :name => 'ruby taiwan' )
  2. e1 = Event.first
  3. e2 = Event.create( :name => 'ruby tuesday' )
  4. EventGroupship.create( :event => e1, :group => g )
  5. EventGroupship.create( :event => e2, :group => g )
  6. g.events
  7. e1.groups
  8. e2.groups

Rails还有一种旧式的has_and_belongs_to_many方法也可以建立多对多关系,不过已经很少使用,在此略过不提。

常用的关连参数

以上的关联方法belongs_tohas_onehas_many都还有一些可以客制的参数,让我们来介绍最常用的几个参数:

增加条件范围

上述关联宣告都可以再加上条件范围,例如加上order指定顺序:

  1. class Event < ApplicationRecord
  2. has_many :attendees, ->{ order("id DESC") }
  3. #...
  4. end

甚至是串连where条件:

  1. class Event < ApplicationRecord
  2. has_many :attendees, ->{ where(["created_at > ?", Time.now - 7.day]).order("id DESC") }
  3. #...
  4. end

删除依赖的资料

可以设定当物件删除时,也会顺便删除依赖它的资料:

  1. class Event < ApplicationRecord
  2. has_one :location, :dependent => :destroy
  3. has_many :attendees, :dependent => :destroy
  4. end

这样的话,当该@event删除时,也会跟着删除@event.location和所有@event.attendees了。

线上参考资源