Ruby on Rails 实战圣经

使用 Rails 5.0+ 及 Ruby 2.3+

欢迎留下 E-mail,若有更新消息可以通知您。若您有任何意见、鼓励或勘误,也欢迎来信给我。愿意赞助支持的话,这是我的支付宝微信

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:

rails g model category

这个指令会产生几个档案

category.rb
category_test.rb
categories.yml
xxxxxxxx_create_categories.rb

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

class CreateCategories < ActiveRecord::Migration[5.1]
  def change
    create_table :categories do |t|
      t.string :name
      t.integer :position
      t.timestamps
    end

    add_column :events, :category_id, :integer
    add_index :events, :category_id
  end
end

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

bin/rake db:migrate

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

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

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

class Category < ApplicationRecord
end

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

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

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

透过 console,我们可以轻易的练习操作 ActiveRecord。

资料表关联设计

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

rails g model attendee name:string event_id:integer

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

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

class Event < ApplicationRecord
    has_many :attendees # 复数
    #...
end

class Attendee < ApplicationRecord
    belongs_to :event # 单数
end

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

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

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

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

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

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

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

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

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

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

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

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

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

范例六,删除

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

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

class Category < ApplicationRecord
    has_many :events
end

class Event < ApplicationRecord
  belongs_to :category, :optional => true
  # ...
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就是外部键字段:

rails g model location name:string event_id:integer

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

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

class Event < ApplicationRecord
    has_one :location # 单数
    #...
end

class Location < ApplicationRecord
    belongs_to :event # 单数
end

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

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

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

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

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

e = Event.first
l = e.build_location( :name => 'Hsinchu' )
l.save
e.location
l.event

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

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

多对多关联many-to-many

has_one diagram

has_one diagram

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

rails g model group name:string
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

class Event < ApplicationRecord
    has_many :event_groupships
    has_many :groups, :through => :event_groupships
end

class EventGroupship < ApplicationRecord
    belongs_to :event
    belongs_to :group
end

class Group < ApplicationRecord
    has_many :event_groupships
    has_many :events, :through => :event_groupships
end

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

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

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

g = Group.create( :name => 'ruby taiwan' )
e1 = Event.first
e2 = Event.create( :name => 'ruby tuesday' )
EventGroupship.create( :event => e1, :group => g )
EventGroupship.create( :event => e2, :group => g )
g.events
e1.groups
e2.groups

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

常用的关连参数

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

增加条件范围

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

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

甚至是串连where条件:

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

删除依赖的资料

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

class Event < ApplicationRecord
  has_one :location, :dependent => :destroy
  has_many :attendees, :dependent => :destroy
end

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

线上参考资源

》回到页首