用户、权限和多租户站点
在我的上一篇文章中,我开始研究多租户 Web 应用程序。这些应用程序只需运行一次,但可以通过各种主机名检索。正如我在那篇文章中解释的那样,即使是一个简单的应用程序也可以通过检查用于连接到 HTTP 服务器的主机名,然后根据该主机名显示不同的内容集来使其成为多租户应用程序。
对于一组简单的站点,这种技术可以很好地工作。但是,如果您正在处理多租户系统,则更可能需要更复杂的技术集。
例如,我最近一直在开发一组站点,以帮助人们练习他们的语言技能。每个站点都使用相同的软件,但显示不同的界面,以及(显然)不同的单词集。同样,我的一个客户长期运营着几十个有地理目标定位的站点。每个站点都使用相同的软件和数据库,但在外界看来是完全独立的。使用多租户架构的另一个原因是,如果您允许用户创建自己的站点,并且可能将用户添加到这些私有站点。
在本文中,我将描述如何设置上述所有类型的站点。我希望您会看到,创建这样的多租户系统不必太复杂,相反,它可以成为向各种受众提供单一软件服务的相对简单的方法。
识别站点在我的上一篇文章中,我解释了如何修改 /etc/passwd,以便将多个主机名与同一个 IP 地址关联起来。每个多租户站点都使用相同的想法。有限的 IP 地址集(有时只有一个 IP 地址)可以映射到更大数量的主机名和/或域名。当请求进入时,应用程序首先检查请求的是哪个站点,然后根据它决定做什么。
上个月文章中的示例使用了 Sinatra,这是一个用于 Web 开发的轻量级框架。诚然,您可以使用 Sinatra 做复杂的事情,但当涉及到使用数据库和大型项目时,我更喜欢使用 Ruby on Rails。所以在这里我使用 Rails,以及 PostgreSQL 中的后端。
为了做到这一点,您首先需要创建一个简单的 Rails 应用程序
rails new -d postgresql multiatf
然后在您的 PostgreSQL 安装中创建一个“multiatf”用户
createuser multiatf
最后,进入 multiatf 目录,并创建数据库
rake db:create
完成这些操作后,您现在有了一个可工作的(即使是非常简单的)Rails 应用程序。确保您的 /etc/hosts 文件中仍然有以下两行
127.0.0.1 atf1
127.0.0.1 atf2
当您启动 Rails 应用程序时
rails s
您可以访问 http://atf1:3000 或 http://atf2:3000,您应该看到相同的结果——即在您做任何事情之前从 Rails 应用程序获得的基本的“hello”。
接下来的步骤是创建一个默认控制器,它将为您的用户提供实际内容。您可以通过以下方式做到这一点
rails g controller welcome
现在您有了一个“welcome”控制器,您应该取消注释 config/routes.rb 中的相应路由
root 'welcome#index'
如果您再次启动服务器并访问 http://atf1:3000,您现在将收到错误消息,因为 Rails 知道要转到“welcome”控制器并调用“index”操作,但不存在这样的操作。因此,您必须进入您的控制器并添加一个操作
def index
render text: "Hello!"
end
完成这些操作后,访问您的主页会显示文本。
到目前为止,这不是很令人兴奋,也没有增加我在上一篇文章中探讨的内容。当然,您可以利用您的“index”方法正在呈现文本这一事实,并且您可以将值动态插入到您的文本中
def index
render text: "Hello, visitor to #{request.host}!"
end
但同样,这不是您可能想要的。您将希望在应用程序的多个位置使用主机名,这意味着您将在应用程序中反复调用“request.host”。更好的解决方案是在 before_action
声明中分配一个 @hostname
变量,这将确保它对系统中的每个人都生效。您可以在您的 welcome 控制器中创建此“before”过滤器,但鉴于这是您希望所有控制器和所有操作都具备的功能,我认为将其放在应用程序控制器中会更明智。
因此,您应该打开 app/controllers/application_controller.rb,并添加以下内容
before_action :get_hostname
def get_hostname
@hostname = request.host
end
然后,在您的 welcome 控制器中,您可以将“index”操作更改为
def index
render text: "Hello, visitor to #{@hostname}!"
end
果然,您的主机名现在将作为 @hostname 提供,并且可以在您站点的任何位置使用。
迁移到数据库在大多数情况下,您希望超越这个简单的方案。为了做到这一点,您应该在数据库中创建一个“hosts”表。这个想法是,“hosts”表将包含主机名和 ID 的列表。它也可能包含额外的配置信息(我在下面讨论)。但现在,您可以向系统添加一个新资源。我甚至建议使用 Rails 提供的内置 scaffolding 机制
rails g scaffold hosts name:string
为什么要使用 scaffold?我知道 scaffold 在 Rails 开发人员中非常不受欢迎,但当我开始一个简单的项目时,我实际上很喜欢它们。诚然,我最终需要删除和重写部分内容,但我喜欢能够快速前进,并且能够从一开始就探测和调整我的应用程序。
在 Rails 中创建 scaffold 意味着创建一个资源(即一个模型、一个控制器,它处理七个基本的 RESTful 操作和每个操作的视图),以及确保操作正常工作所需的基本测试。现在,在生产系统上,您可能不希望允许任何有互联网连接的人创建和修改现有主机。事实上,您稍后会修复这个问题。但就目前而言,这是一个设置事物的好方法,也是简单的方法。
您需要运行新创建的迁移
rake db:migrate
然后您需要将您的两个站点添加到数据库中。一种方法是修改 db/seeds.rb,它包含您希望在数据库中的初始数据。您可以在其中使用普通的 Active Record 方法调用,例如
Host.create([{name: 'atf1'}, {name: 'atf2'}])
在添加种子数据之前,请确保模型将强制执行一些约束。例如,在 app/models/host.rb 中,我添加了以下内容
validates :name, {:uniqueness => true}
这确保了每个主机名在“hosts”表中只出现一次。此外,它确保当您运行 rake db:seed
时,只会添加新主机;错误(包括尝试两次输入相同的数据)将被忽略。
完成上述操作后,您可以添加种子数据
rake db:seed
现在,您的“hosts”表中应该有两个记录
[local]/multiatf_development=# select name from hosts;
--------
| name |
--------
| atf1 |
--------
| atf2 |
--------
(2 rows)
完成这些操作后,您现在可以更改您的应用程序控制器
before_action :get_host
def get_host
@requested_host = Host.where(name: request.host).first
if @requested_host.nil?
render text: "No such host '#{request.host}'.", status: 500
return false
end
end
(顺便说一句,我在这里使用 @requested_host
,以免与将在 hosts_controller
中设置的 @host
变量冲突。)
@requested_host
不再是一个字符串,而是一个对象。它像之前的 @requested_host
一样,是一个在 before 过滤器中设置的实例变量,因此它在您的所有控制器和视图中都可用。请注意,现在有人有可能通过不在您的“hosts”表中的主机名访问您的站点。如果发生这种情况,@requested_host
将为 nil,您会给出适当的错误消息。
这也意味着您现在必须稍微更改您的“welcome”控制器
def index
render text: "Hello, visitor to #{@requested_host.name}!"
end
从字符串 @requested_host
到对象 @requested_host
的这种变化不仅仅是文本字符串。首先,您现在可以限制对您站点的访问,以便现在只能看到那些处于活动状态的主机。例如,让我们向“hosts”表添加一个新的布尔列 is_active
rails g migration add_is_active_to_hosts
在我的机器上,我然后编辑新的迁移
class AddIsActiveToHosts < ActiveRecord::Migration
def change
add_column :hosts, :is_active, :boolean, default: true,
↪null: false
end
end
根据这个定义,站点默认是活动的,并且每个站点都必须有一个 is_active
值。您现在可以更改应用程序控制器的 get_host
方法
def get_host
@requested_host = Host.where(name: request.host).first
if @requested_host.nil?
render text: "No such host '#{request.host}'.", status: 500
return false
end
if !@requested_host.is_active?
render text: "Sorry, but '#{@requested_host.name}'
↪is not active.", status: 500
return false
end
end
请注意,即使是一个简单的数据库现在也允许您检查以前不可能实现的两个条件。您想要限制可以在您的系统上使用的主机名,并且您希望能够通过数据库打开和关闭主机。如果我将“atf1”站点的 is_active
更改为 false
UPDATE Hosts SET is_active = 'f' WHERE name = 'atf1';
立即,我无法访问“atf1”站点,但“atf2”站点工作正常。
这也意味着您现在可以添加任意数量的站点——无需考虑主机或域名——只要它们都具有指向您的 IP 地址的 DNS 条目。添加新站点就像注册域名(如果尚未注册)、配置其 DNS 条目以使主机名指向您的 IP 地址,然后在您的 Hosts 表中添加新条目一样简单。
用户和权限当您使用此技术允许用户创建和管理自己的站点时,事情变得真正有趣起来。突然,这不仅仅是向不同的用户显示不同的文本,而是允许不同的用户登录到不同的站点。以上显示了如何拥有一组顶级管理员和可以登录到每个站点的用户。但是,在很多时候,您希望限制用户只能在特定站点上。
有很多方法可以处理这个问题。无论如何,您都需要创建一个“users”表和一个模型,用于处理您的用户及其注册和登录能力。我曾经犯过一个愚蠢的错误,自己实现这样的登录系统;现在,我只使用“Devise”,这个令人惊叹的 Ruby gem,它可以处理您能想到的几乎所有与注册和身份验证相关的事情。
我将以下行添加到我的项目的 Gemfile 中
gem 'devise'
接下来,我运行 bundle install
,然后
rails g devise:install
在命令行上。现在我已经安装了 Devise,我将创建一个用户模型
rails g devise user
这将创建一个新的“user”模型,其中包含所有 Devise 的好东西。但是在运行 Devise 提供的迁移之前,让我们对 Devise 迁移进行快速更改。
在迁移中,您将添加一个 is_admin
列,指示相关用户是否为管理员。这一行应该放在底部的 t.timestamps
行之前,它表示用户默认情况下不是管理员
t.boolean :is_admin, default: false, null: false
完成这些操作后,您现在可以运行迁移。这意味着用户可以登录到您的系统,但他们不必这样做。这也意味着您可以将用户指定为管理员。Devise 提供了一种方法,您可以使用该方法来限制对站点的特定区域的访问,仅限于已登录用户。这通常不是您想要放在应用程序控制器中的内容,因为这会阻止人们登录。但是,您可以通过将以下内容放在这些控制器的顶部来说明您的“welcome”和“host”控制器仅对注册和登录用户开放
before_action :authenticate_user!
通过以上操作,您已经使其只有注册和登录用户才能看到您的“welcome”控制器。您可能会争辩说这是一个愚蠢的决定,但就目前而言,我对它感到满意,它的智慧取决于您运行的应用程序类型。(例如,SaaS 应用程序,如 Basecamp 和 Harvest,就是这样做的。)感谢 Devise,我可以注册和登录,然后……好吧,我可以做任何我想做的事情,包括添加和删除主机。
最好限制您的用户,以便只有管理员才能查看或修改 hosts 控制器。您可以使用另一个 before_action
在该控制器的顶部执行此操作
before_action :authenticate_user!
before_action :only_allow_admins
before_action :set_host, only: [:show, :edit, :update, :destroy]
然后您可以定义 only_allow_admins
def only_allow_admins
if !current_user.is_admin?
render text: "Sorry, but you aren't allowed there",
↪status: 403
return false
end
end
请注意,上面的 before_action
过滤器假定 current_user
已经设置,并且它包含一个用户对象。您可以确定这是真的,因为您对 only_allow_admins
的调用只有在 authenticate_user!
已触发并允许执行继续的情况下才会发生。
这实际上不是什么大问题。您可以创建一个“memberships”表,将“users”和“hosts”连接成多对多关系。因此,每个用户都可以是任意数量的主机的成员。然后,您可以创建一个 before_action
例程,以确保不仅用户已登录,而且他们还是他们当前尝试访问的主机的成员。如果您想仅在用户的站点内向用户提供管理权限,您可以在 memberships 表上放置这样的列(例如,“is_host_admin”)。这允许用户成为他们可能想要的任意数量的站点的成员,但只能管理那些经过专门批准的站点。
多租户站点引发了许多其他问题和可能性。也许您希望每个站点都有不同的样式。这很好。您可以添加一个新的“styles”表,它有两列:“host_id”(一个数字,指向 host 表中的一行)和“style”,其中包含 CSS 的文本,您可以在运行时将其读入您的程序。通过这种方式,您可以让用户随心所欲地设置和重新设置样式。
在本文描述的架构中,假设所有数据都在同一个数据库中。我倾向于更喜欢使用这种架构,因为我认为它使管理员的生活更轻松。但是,如果您特别担心数据安全,或者如果您正受到巨大负载的压迫,您可能需要考虑不同的方法,例如为每个新租户站点启动一个新的云服务器。
另请注意,使用此系统,用户只需在整个站点上注册一次。在某些情况下,最终用户不希望在不同站点之间共享登录名。此外,在某些情况下(例如医疗记录),可能需要将信息分隔到不同的数据库中。在这种情况下,您可能仍然可以使用单个数据库,但在其中使用不同的“schemas”或命名空间。PostgreSQL 长期以来一直提供此功能,并且更多站点可能会利用它。
结论创建一个多租户站点,包括单独的管理员和权限,可能是一个快速而简单的过程。多年来,我为我的客户创建了多个这样的站点,并且在此期间它变得越来越容易。然而,归根结底,HTTP、IP 地址和数据库的组合才是真正让我能够创建如此灵活的 SaaS 应用程序的原因。
资源Devise 主页位于 https://github.com/plataformatec/devise。
有关 Ruby on Rails 中的多租户站点的信息和想法,您可能需要阅读 Ryan Bigg 编写的电子书Multitenancy with Rails,该书可在 https://leanpub.com/multi-tenancy-rails 获取。虽然本书专门介绍了 Rails 中的多租户,但它提供了许多适用于其他软件系统的想法和方法。
现已发售:Reuven M. Lerner 的Practice Makes Python我的新电子书Practice Makes Python现已发售。本书面向那些已经参加过 Python 课程或自学 Python,但希望对“Pythonic”做事方式感到更自在的人——使用内置数据结构、编写函数、使用函数式技术(如推导式)以及使用对象。
Practice Makes Python 包含 50 个练习,这些练习是我在美国、欧洲、以色列和中国近十年的现场培训课程中使用过的。每个练习都附带一个解决方案,以及对解决方案有效原因的详细描述,通常还附带替代方案。所有这些都旨在提高您对 Python 的熟练程度,以便您可以在工作中有效地使用它。
您可以在 http://lerner.co.il/practice-makes-python 阅读有关本书的更多信息。
Linux Journal 读者可以使用优惠券代码 LINUXJOURNAL 在结账时获得 10% 的折扣。问题或意见可以通过电子邮件发送给我,地址为 reuven@lerner.co.il 或在 Twitter 上 @reuvenmlerner。