多租户站点

作者:Reuven Lerner

长期以来,Web 应用程序领域一直发展迅猛。仅仅通过 Web 浏览器就能完成的事情令人惊叹——您不仅可以购买几乎任何东西,而且越来越多的网站提供“软件即服务”,通常缩写为 SaaS。其理念是,通过支付每月服务费,您就可以访问服务。成千上万种此类服务存在,它们处理从 Git 存储库(例如,GitHub 和 BitBucket)、电子邮件服务(例如,AWeber 和 MailChimp)、发票系统、时间跟踪系统、日历系统、电子商务系统、电子学习系统到您能想到的任何事物。

作为 Web 开发人员,您可以创建自己的 SaaS 应用程序。没错——只需一台 Linux 机器、一个数据库、一种编程语言和一个 Web 框架,您就可以创建一个新的 SaaS 应用程序。凭借一个好的想法、一些辛勤工作和良好的营销,您将走上成功 бизнеса 之路。

SaaS 的工作方式有多种模型。有时,您在系统上拥有一个用户名,并且您只是在与您对世界的视图进行交互。但有时,SaaS 应用程序会为您提供一个看起来完全是新域名的东西。因此,如果我在 SuperDuperSaas.com 上获得一个帐户,我所做的一切都将在 lerner.SuperDuperSaas.com 下进行。

允许这样做的程序被称为“多租户”应用程序。当然,每个新的子域名可能都涉及新虚拟机的推出。但是,也有一些方法可以让一台计算机,通过应用程序的单个实例,提供无限数量域名的相同错觉。而且,这样做并不像您想象的那么困难。

在本文中,我将探讨几种技术,这些技术使您能够创建和维护此类多租户应用程序。这些技术可以用于 SaaS 产品或任何其他应用程序中,在这些应用程序中,软件可以并且应该对各种主机名或域名做出不同的响应。

这一切都归功于 HTTP

HTTP,即超文本传输协议,是如此普遍,以至于大多数人几乎不会去思考它。即使像我这样几乎每天都在 Web 应用程序上工作的人,也知道 HTTP 的存在以及它的作用——然而,我并没有过多地思考它。但是,多租户应用程序的出现要归功于 Web 早期发展时期的增长。

我最早接触的 HTTP 版本是 1993 年的版本 0.9。该版本比我们今天所知的版本更简单,但它已经包含了基本的 GET 和 POST 操作——也就是说,您可以连接到端口 80 上的 HTTP 服务器,然后说


GET /

如果一切顺利,服务器会将主页的内容(通常使用 HTML 格式化)发送回 HTTP 客户端。届时,连接将关闭。

尽管 HTTP 0.9 在许多简单情况下运行良好,但 Web 的爆炸性增长意味着它对于许多复杂情况来说不够好。一个特别常见且特别痛苦的情况是 Web 托管公司:HTTP 0.9 要求每个网站都有自己的 IP 地址。如果您设置了一个基于 Linux 的服务器,该服务器具有单个 IP 地址但有多个主机名,则 HTTP 服务器无法区分它们。

当 HTTP 1.0 发布并要求在操作和路径名中发送“Host”标头时,这种情况发生了变化。现在,一个简单的请求看起来像


GET / HTTP/1.0
Host: lerner.co.il

第一行发生了变化,使其包含了正在使用的 HTTP 的版本号。这样做是为了与 HTTP 0.9 客户端向后兼容。第二行被定义为几个“请求标头”中的第一个,即可以从客户端发送到服务器的名称-值对。

这些请求标头的范围多年来一直在扩大,现在包括从主机名到 cookie 到内容类型到缓存信息的所有内容。但对于我在本文中的目的而言,此请求中最重要的部分是“Host”请求标头。鉴于服务器现在可以区分不同的主机,即使在同一个 IP 地址上,也可以让单个服务器为任意数量的不同域名和主机名提供 Web 托管功能。

换句话说,现在可以让同一个 Web 服务器为 CompanyA.com 和 CompanyB.com 提供托管,而两者都不知道或看不到对方。Web 服务器会知道将对 CompanyA.com 的请求路由到一个程序和 HTML 文件目录,并将 CompanyB.com 的请求路由到第二个完全独立的程序和 HTML 文件目录。

对于任何了解域名、主机名和 DNS 的人来说,这可能是显而易见的,但从服务器的角度来看,它并不关心是否必须区分 CompanyA.com 和 CompanyB.com,或者 abc.CompanyA.com 和 def.CompanyA.com。也就是说,同一域名内的不同主机名与不同域名的处理方式类似。诚然,DNS 和 HTTP 服务器配置文件使将 *.CompanyA.com 发送到同一位置变得更容易,但最终,您的 HTTP 服务器会看到不同的主机名,因此可以做出不同的反应。

“虚拟主机”(Virtual hosts),正如它们后来被称为的那样,共享一个 IP 地址和一台计算机,因此从程序员或 IT 管理员的角度来看,它们都在同一个保护伞下。从外部世界的角度来看,这些是完全不同的网站。也许它们共享一个 IP 地址,因此共享一个托管提供商,但那是它们唯一共同的东西。

多租户

如今,在同一个 HTTP 服务器下服务不同的主机名是微不足道的。正如我之前指出的那样,您只需告诉 Apache(或 nginx,或您使用的任何 HTTP 服务器)这两个主机存在于不同的目录中,并且应该区别对待它们。有了这样的配置,不同主机名之间就没有任何联系。这实际上使得将网站从一台机器移动到另一台机器变得更容易。您只需获取虚拟主机的配置文件,并将其与程序和静态资产(即 HTML 文件和图像)一起移动到另一台机器。

实际上,廉价的按需 Web 托管的庞大产业可能已经使这成为服务器分配和使用的最常见方式。即使是我自己的个人服务器,在任何给定时间也有五到十个不同的虚拟主机,用于个人项目和客户端应用程序的演示。

多租户应用程序颠覆了这个想法。您将拥有许多相同应用程序的不同实例,而不是使用单个服务器、单个 IP 地址来服务大量不同的应用程序,每个应用程序都有自己的主机名。也就是说,您将使 CompanyA.com 和 CompanyB.com 不仅指向同一个 IP 地址,而且还指向同一个 Web 应用程序实例。

在您考虑到现代版本的 HTTP 始终传递“Host”标头,并且所有 HTTP 请求标头都可用于 Web 应用程序之前,这听起来可能很奇怪,您可以编写一个可以在多个主机上工作的应用程序。考虑一下,BigCompany.com 有两个不同的部门,每个部门都有一个单独的网站。这两个网站在所有方面都应该完全相同,除了联系电话号码和地址应该反映用户访问的地区。

您可以在应用程序内部的“if”语句中使用“Host”请求标头,从而显示适当的信息。这是一个多租户站点的经典示例,尽管它肯定不是最复杂的示例之一。

Sinatra 的多租户

让我们使用 Sinatra 来实现上述场景,Sinatra 是一个非常小巧轻便的 Web 应用程序框架,用 Ruby 语言编写。在 2014 年 7 月的 LJ 杂志中,我介绍了一个类似的小型框架,名为 Flask,用 Python 编写。此类框架通常非常适合简单的站点和示例代码。

您可以通过多种方式创建 Sinatra 应用程序。我的偏好是在目录中执行此操作,并 साथ ही 包含 Gemfile 和 config.ru 文件。这花了我不到五分钟的时间在自己的计算机上完成设置。首先,我创建了一个名为“multiatf”的目录。在该目录中,我创建了一个名为“Gemfile”的文件,我将在其中命名我将用于此应用程序的 Ruby gems


source 'https://rubygems.org.cn'
gem "sinatra", :require => "sinatra/base"
gem 'shotgun'

第一行表示我想从 Rubygems.org(官方和标准位置)检索 gems。第二行表示我想使用“sinatra”gem,但我不希望 require “sinatra”,而是 require “sinatra/base”。最后,我命名了“shotgun”gem,它提供了 Sinatra 应用程序的自动重新加载——这正是我在开发应用程序时想要的那种东西。

在继续之前,我然后运行 bundle install,这确保了 Gemfile 中命名的所有 gems 都已安装。它创建一个名为“Gemfile.lock”的文件,其中列出了我将在应用程序中使用的每个 gem 的精确名称和版本。此列表包括我明确命名的 gems 以及我的命名 gems 所依赖的 gems。值得花时间查看 Gemfile.lock;它很可能会让您深入了解您的 Sinatra 和 Rails 应用程序是如何工作的。

接下来,我编写一个“config.ru”文件,有时也称为“rackup 文件”,它告诉 Rack(Ruby 的 HTTP 服务器和应用程序之间的标准接口)我的应用程序代码位于何处以及如何执行它。该文件如下所示


require 'bundler'

Bundler.require

require './multiatf.rb'
run Sinatra::Application

第一行加载“bundler”gem。Bundler 是 Ruby 世界中越来越不可或缺的 gem,因为它为您管理 gems 的版本,确保它们不会需要 gem 的冲突版本。加载 Bundler 后,您可以使用“require”类方法,该方法会检查您的 Gemfile.lock 并加载其中命名的 gems。

接下来,“require”语句读取当前目录中名为“multiatf.rb”的 Ruby 文件。这是实际的应用程序代码,也是我将编写和修改最多的文件。加载它意味着 Ruby 将读取代码的内容。在我的 Sinatra 应用程序的情况下,这意味着获取各种“get”和“post”声明,并将它们转换为适当的路由映射,以便为每个 URL 执行相应的代码块。

然后,一旦应用程序加载完成,config.ru 调用 Sinatra::Application。这会启动并运行应用程序。

组装应用程序的最后一步是 multiatf.rb 文件。这也只包含很少的代码,但可能非常大


require 'sinatra'

get '/' do
  "Hello from server '#{request.host}'"
end

第一行加载 Sinatra 代码。接下来是一些看起来有点像方法定义,但又不是方法定义的东西。相反,它告诉 Sinatra,如果有人向 / URL 发出请求,它应该返回一个字符串。在这种情况下,该字符串不是静态的,而是包含一个动态部分,包括“request.host”的值。您可以想象,此值会根据您使用的主机名而变化。

为了在我的开发机器上启动它,我运行了


shotgun multiatf.rb

这会产生输出,告诉我 Shotgun 现在正在端口 9393 上运行我的应用程序,使用 Ruby 的内置 WEBrick 服务器。我现在可以转到我的 Web 浏览器,加载 http://localhost:9393,并且由于我的 Sinatra 文件中的 get / 声明,该方法将被触发。我会收到一条友好的消息,告诉我


"Hello from server 'localhost'"

但是,如果不是 localhost 呢?如果我转到另一个服务器名称呢?例如,我在我的 /etc/hosts 文件中添加了以下两行


127.0.0.1 atf1
127.0.0.1 atf2

换句话说,当我告诉我的 Web 浏览器转到主机“atf1”时,它将转到 127.0.0.1,并且它将在“Host”HTTP 请求标头中发送服务器名称“atf1”。然后输出将是


Hello from server 'atf1'

“atf2”也是如此。

显示不同的内容

因此,您已经了解了如何根据服务器名称的值获得不同的输出。这个看似简单的事实为整个多租户系统世界打开了大门。例如,您可以想象一家公司以各种名称开展业务,该公司希望运行相同的 Web 应用程序,但显示当前的域名。您所要做的就是更改您的字符串或模板以反映当前的主机名。

在许多情况下,仅显示不同的主机名是不够的。您可能想要显示不同的公司名称或不同的地址。为了实现这一点,您需要一些额外的数据。做到这一点的最佳和最具可扩展性的方法是关系数据库,但您可以使用 Ruby 哈希来模拟一个,这对于本文的目的来说已经足够好了。

在这种情况下,让我们定义哈希,使其包含两个键,每个键对应一个要识别的主机。然后,让我们根据键从哈希中提取公司名称。

因此,我将 multiatf.rb 更改为如下所示


require 'sinatra'

hosts = {'atf1' => {name: 'First ATF site',
                    address: '111 Main Street'},
         'atf2' => {name: 'Second ATF site',
                    address: '222 Elm Street'}
        }

get '/' do
  "Welcome to '#{hosts[request.host][:name]}', located at
    ↪'#{hosts[request.host][:address]}'!"
end

这里的想法很简单,但效果却很深刻。这就是每个域名看起来都不同的方式,即使内容相同。您可以想象更进一步,根据主机名将不同的 CSS 样式表拉入 HTML 页面,或者让它显示不同的图片。

如果您正在使用关系数据库,则可以在表中输入每个新的租户站点,并为每个站点分配一个唯一的 ID 号。然后,您可以将该 ID 用作外键,在描述商品的表中添加(例如)此“site_id”值。例如,我的一个客户管理着大约 30 个不同的站点,每个站点都有自己的一组房地产产品。这 30 个站点实际上运行在单个 Web 应用程序上,使用单个数据库。但是,根据用户进入站点的主机名,该软件会显示一组不同的属性。这使得站点和软件易于管理、扩展和增长。每次需要添加新站点时,最大的任务是更新 SSL 证书,使其包含新的主机名。否则,系统会自动工作,(非技术)公司经理只需填写 HTML 表单即可在几分钟内创建新站点。该表单允许他们向“sites”表中添加新条目。主机名用于查找站点 ID,然后使用该站点 ID 的值来显示属性。

下个月,我将继续讨论这个主题,不仅讨论如何让同一个站点生成相似的内容,还讨论如何配置它,以便不同的用户可以管理自己的站点,而不会干扰整体软件和功能。

资源

Sinatra 主页位于 https://sinatra.ruby-lang.org.cn

有关 Ruby on Rails 中多租户站点的更多信息和想法,您可能需要阅读 Multitenancy with Rails,这是一本由 Ryan Bigg 撰写的电子书,可在 https://leanpub.com/multi-tenancy-rails 上获得。虽然这本书专门讨论 Rails 中的多租户,但它提供了许多适用于其他软件系统的想法和方法。

Reuven M. Lerner,一位资深的 Web 开发人员,提供 Python、Git、PostgreSQL 和数据科学方面的培训和咨询服务。他撰写了两本编程电子书(Practice Makes Python 和 Practice Makes Regexp),并发布了面向程序员的免费每周新闻通讯,网址为 http://lerner.co.il/newsletter。Reuven 的 Twitter 账号是 @reuvenmlerner,与妻子和三个孩子住在以色列的莫迪因。

加载 Disqus 评论