现代Web应用的一个普遍需求是多子域名,每个用户可以访问到用户特定的子域名,比如说Slack就为每个聊天室创建了一个单独的子域名。这篇文章讲述了如何在Phoenix应用中设置多个子域名。
我们知道,Phoenix可以创建Umbrella应用,其下放置多个App,每个App分配不同的端口,如4000,4001,4002等,前端再用Nginx做反向代理,这样也可以实现子域名的功能,但是无法实现类似Slack的那种功能,用户无法方便地设置自己的子域名。
创建项目
新建Phoenix项目,名为subdomainer
mix phoenix.new subdomainer
启动应用
mix phoenix.server
修改hosts,增加如下这条,我们可以在本地通过这些域名访问本机127.0.0.1,部署至服务器上可以使用泛域名解析。
127.0.0.1 subdomainer.dev foo.subdomainer.dev bar.subdomainer.dev
我们可以通过以下三个地址访问应用:
- http://subdomainer.dev:4000
- http://foo.subdomainer.dev:4000
- http://bar.subdomainer.dev:4000
目前这些地址都指向了同一个页面,我们将修改代码来使不同子域名访问的页面各不相同。
判断子域名是否设置
我们首先需要配置应用的根域名,因为你没法保证子域名的数量,在这个例子中,根域名是subdomainer.dev
,子域名是foo.subdomainer.dev
。当然,我们也可以使用app.subdomainer.dev
作为根域名,foo.app.subdomainer.dev
作为子域名。也就是将我们的多子域名应用放在一个二级域名之下。以区别我们的主应用,如www.subdomainer.dev
。而www和app两个应用可以放在一个umbrella下。
修改config/config.exs
中的config :subdomain, Subdomain.Endpoint
代码块:
url: [host: "localhost"],
修改为:
url: [host: "subdomainer.dev"],
我们还需要修改endpoint来获知URL里是否是子域名。
创建lib/subdomainer/plugs/subdomain.ex
defmodule Subdomainer.Plug.Subdomain do
import Plug.Conn
@doc false
def init(default), do: default
@doc false
def call(conn, router) do
case get_subdomain(conn.host) do
subdomain when byte_size(subdomain) > 0 ->
conn
|> router.call(router.init({}))
_ -> conn
end
end
defp get_subdomain(host) do
root_host = Subdomainer.Endpoint.config(:url)[:host]
String.replace(host, ~r/.?#{root_host}/, "")
end
end
这里我们实现了plug必须的call/2
函数。这里第二个参数是如果子域名存在,我们将使用的module。
String.replace(host, ~r/.?#{root_host}/, "")
返回了子域名名称:
"foo.subdomainer.com" -> "foo"
"foo.app.subdomainer.com" -> "foo.app"
如果subdomain长度大于0,即URL里包含subdomain,那么进入router。
在lib/subdomainer/endpoint.ex
的plug :router, Subdomainer.Router
之前增加
plug Subdomainer.Plug.Subdomain, Subdomainer.SubdomainRouter
这里我们指定SubdomainRouter模块作为子域名的Router。
现在我们运行应用会有如下错误提示:
undefined function: Subdomainer.SubdomainRouter.init/1 (module Subdomainer.SubdomainRouter is not available)
因为我们还未创建这个router。
添加子域名路由
创建web/subdomain_router.ex
用于存放子域名的router。
defmodule Subdomainer.SubdomainRouter do
use Subdomainer.Web, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
end
scope "/", Subdomainer do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
end
# Other scopes may use custom stacks.
# scope "/api", Subdomainer do
# pipe_through :api
# end
end
现在就可以运行了。我们还想让subdomain访问不同的页面。
scope "/", Subdomainer.Subdomain do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
end
这里改变了scope后面的Subdomainer.Subdomain
,所以PageController就会在subdomain文件夹下寻找对应文件。
创建web/controllers/subdomain/page_controller.ex
,为子域名创建特定的Controller
defmodule Subdomainer.Subdomain.PageController do
use Subdomainer.Web, :controller
def index(conn, _params) do
text(conn, "Subdomain home page")
end
end
现在也可以运行,访问子域名,会看到如下错误提示:
(exit) an exception was raised: ** (Plug.Conn.AlreadySentError) the response was already sent
这也容易解决,我们只需要避免找到子域名后的plugs的运行即可。
在lib/subdomainer/plugs/subdomain.ex
添加Plug.Conn.halt/1
def call(conn, router) do
case get_subdomain(conn.host) do
subdomain when byte_size(subdomain) > 0 ->
conn
|> router.call(router.init({}))
|> halt
_ -> conn
end
end
自定义子域名响应
最后要做的是根据子域名响应对应的内容。我们可以把subdomain信息添加到Plug.Conn的private storage中
def call(conn, opts) do
case get_subdomain(conn.host) do
subdomain when byte_size(subdomain) > 0 ->
conn
|> put_private(:subdomain, subdomain)
|> router.call(router.init({}))
|> halt
_ -> conn
end
end
然后在Subdomainer.Subdomain.PageController
中获取信息:
def index(conn, _params) do
text(conn, "Subdomain home page for #{conn.private[:subdomain]}")
end
全部完成!现在我们再次访问 http://subdomainer.dev:4000, http://foo.subdomainer.dev:4000 http://bar.subdomainer.dev:4000
来看看效果吧!
最终效果
这仅仅是一个开始,你可以根据此来扩展引用。一个常见需求是从数据库中搜索subdomain是否存在,如果不存在,则返回404。Subdomainer.SubdomainRouter
在请求中获取了subdomain,你可以添加一个plug到pipeline中,来在controller动作之前检查subdomain是否存在。
参考
[1] http://blog.gazler.com/blog/2015/07/18/subdomains-with-phoenix/