Sinatra 使用 Nginx 优化面向侧面的架构

vincenting · 2015年11月11日 · 最后由 hammer 回复于 2015年12月30日 · 10556 次阅读

面向侧面的程序设计(aspect-oriented programming,AOP),通过将解决特定领域问题的代码从业务逻辑中独立出来,从而提高代码的可维护性。

从主关注点中分离出横切关注点是面向侧面的程序设计的核心概念。分离关注点使得解决特定领域问题的代码从业务逻辑中独立出来,业务逻辑的代码中不再含有针对特定领域问题代码的调用,业务逻辑同特定领域问题的关系通过侧面来封装、维护,这样原本分散在在整个应用程序中的变动就可以很好的管理起来。 - 维基百科


示例是根据最近正在负责的 APP 后端项目简化版,需求简单说如下:

  1. APP 端会对所有请求进行加密,服务器端要对加密结果进行校验,确保正确以及未篡改;
  2. 通过手机号来登录,采用基本的 token 机制验证登录;
  3. 有企业、小组以及员工的层级关系,后期必须考虑根据公司来分表/集群;
  4. 提供涉及到权限的 REST 风格的接口(某种程度上类似 Postgrest,但是进行了拓展,后面会有专门文章介绍)

Version 1st.

思路:首先整个目前项目的主关注点 (core concern) 是 REST 风格的资源服务器 —— 即通过约定俗成的风格来对应具体的数据/资源操作。在这个功能外,需要完成的其他关注点包括:

  • 所有请求加密校验
  • 登录验证
  • 资源的权限管理以及获取

于是,在 Sinatra 中,可以通过 extensions 的方式将请求加密校验完成,配合 before 来进行统一处理:

require 'sinatra/base'

module Sinatra

  module RequestHeadersVerify

    module Helpers
      def headers_valid?
        # 此处省略真实业务代码
        false
      end
    end

    def self.registered(app)
      app.helpers RequestHeadersVerify::Helpers

      app.before do
        unless headers_valid?
          halt 400, json(ResponseErrror::InvalidHeadersError.new)
        end
      end
    end
  end

  register RequestHeadersVerify
end

最终采用"中间件"的方式,在请求的最前面一层(横切关注点 crosscutting concerns)将非法请求进行拦截。

于是紧接着第二个流程,验证用户是否登录,与获取当前联系人所在的公司、小组、以及其管理的小组信息一样,这里最快速/方便的做法就是通过 helpers 来实现:

require 'sinatra/base'

module Sinatra 
  module UserSessionHelpers
    HTTP_USER_TOKEN_KEY = 'HTTP_AUTH_TOKEN'

    def current_user
      @current_user ||= (
        user = User.first token: env[HTTP_USER_TOKEN_KEY]
        halt 400, json(ResponseErrror::InvalidTokenError.new) unless user
        user
      )
    end
  end

  module OrganizationHelpers
    # 这里省略掉相关 helpers 代码
  end

  helpers UserSessionHelpers
  helpers OrganizationHelpers
end

最终在 REST 相关的构建代码中,就不需要去考虑用户请求加密的内容,也不需要去考虑用户是否登录(因为如果需要使用到用户信息但是用户没有登录,会直接抛出错误返回)。只需要按照约定的设计风格,把请求的内容在校验了内容和权限后,转成对应的数据库操作,最终再按照约定的内容返回。

Version 2nd.

第一版已经尽可能的考虑到 解决特定领域问题的代码从业务逻辑中独立出来。但是现实开发里面经常会涉及到多人开发、跨语言合作、更快速的迭代等等的问题,最终需要把他们拆成独立的低耦合度的 Server。于是随之而来的是如何在服务间进行通讯/共享数据。

这里的方案选择通常会根据实际业务以及难易程度来权衡,例如最快捷的 webServer 的方式内部通信,稍微复杂点的基于 TCP 的 RPC 通信方案(例如 thrift),或者某些特殊的情景,例如是生产者/消费者关系的话,则可能通过 MQ 来进行通信。最终我们采用的是通过 Nginx 的 lua 模块来将 server 以面向侧面的思路耦合

首先,Nginx 的 Lua 模块可以做什么?如果可以,单纯 nginx 和 lua 就可以完成完整的 web 服务。可以连接 redis、memcache、postgresql 等等,同时可以取得请求的所有内容,可以设置返回的头部、正文。配合 lua 的对数据处理能力,基本功能都可以实现。同时 nginx 的 lua 模块整体都是异步,所以性能也相对较好。当然也可以通过 lua 脚本来控制权限,如果验证通过则继续下面的操作,例如是 proxy_pass 代理,简单的示例如下文:

location = /foo {
    access_by_lua_block {
        -- check the client IP address is in our black list
        if ngx.var.remote_addr == "132.5.72.3" then
            ngx.exit(ngx.HTTP_FORBIDDEN)
        end

        -- check if the URI contains bad words
        if ngx.var.uri and
            string.match(ngx.var.request_body, "evil")
        then
            return ngx.redirect("/terms_of_use.html")
        end

        -- tests passed
    }

    proxy_pass http://blah.blah.com;
 }

不过,我们这里将用到的主要还是 proxy_pass, 和 ngx.location.capture,基本代码如下:

-- 禁止任何以下划线开始的请求地址
if string.sub(ngx.var.uri, 2, 2) == "_" then
  ngx.exit(404)
end

local cjson = require "cjson"

local custom_header_prefix = "V-"
local request_args = ngx.req.get_uri_args(64)
local request_body = ngx.req.read_body()
local request_path = ngx.var.uri
local request_method = ngx["HTTP_"..ngx.req.get_method()]

for header, _ in pairs(ngx.req.get_headers()) do
  if string.upper(string.sub(header, 1, 2)) == crm_header_prefix then
    ngx.req.clear_header(header);
  end
end

function res_with_json(body, status)
  ngx.header["Content-Type"] = "application/json"
  ngx.print(body)
  return ngx.exit(status)
end

function request_to_server(uri)
  -- 发起请求至其他地址并取得结果
  res = ngx.location.capture(uri..request_path, {
    body = request_body,
    args = request_args,
    method = request_method,
  })
  local json_response = cjson.decode(res.body)

  -- 解析返回内容
  if not json_response.next == true then
    res_with_json(res.body, res.status)
  end
  for key, value in json_response.params do
     ngx.req.set_header(custom_header_prefix..string.upper(key), value)
  end
  return false
end

上面的代码主完成了清理用户恶意提交的请求头,以及 request_to_server 的代码,实现了将原请求内容转发给另一个接口并获得请求后的内容。得到请求结果后,验证请求的参数。

同时在 nginx 里面通过 stream 和 proxy_pass 的方式来配置多个内部地址:

upstream authentication-server {
    server 192.168.21.1:6011;
    server 192.168.21.2:6011;
}

server {
    location /_authentication {
      proxy_redirect off;
      proxy_set_header Host      $host;
      proxy_set_header X-Real-IP $remote_addr;

      rewrite  ^/_authentication/(.*)  /$1 break;
      proxy_pass http://authentication-server;
    }
}

于是,将两个结合起来,就可以实现通过 lua 脚本把原请求的所有参数,包括头部、正文、请求地址、请求方法都带过去,请求另一个地址(和 proxy_pass 类似),并且可以得到最终返回的结果处理

拥有这个能力后,便是本文的重点了:在 Sinatra 的第一版本中,最终都是 ruby 代码不断调用方法,来完成整个请求的流程。那如果我们把整个流程的打通交给 Nginx 的话该如何实现呢?

  1. 当一个请求进入后,通过 request_to_server 的能力,把请求依次转发给负责 横切关注点 的服务,例如用户请求校验以及登录校验服务、用户的组织架构服务,最终再去调用主关注点,即本文中的资源服务器;
  2. 每次请求完后,根据前一个流程的返回值决定是否进入下一个流程,例如示例中的 lua 脚本是通过返回的 json 里面的 next 参数来决定是否继续往下走。如果没有这个参数则直接返回当前服务的返回值不再继续请求下去;
  3. 如果出现了 next: true 这个关键字,则将返回值中的其他内容以请求头的形式传递给下一个服务,且每个服务都会完全信赖这些请求头(所以请求刚进来的时候需要做一些请求头和请求地址处理)。

如果到这里都没有太大问题,你应该可以理解我的意图了。即 Nginx 通过 Lua 脚本来依次请求 横切关注点服务器,如果一路顺畅(每次都有 next: true),最终会把携带有 横切关注点服务返回的内容的 headers 带给主关注点服务。

于是,在本需求里面,为了保证可拓展性和低耦合性,最终分为了三个服务:

  • 负责请求加密鉴权,用户登录、密码修改的用户验证服务
  • 负责管理企业结构、获取用户权限的组织架构服务
  • 负责具体的 REST 请求处理的资源服务
  1. 当一个用户登录的请求过来,因为密码错误或者加密鉴权失败,会在用户验证服务就直接返回错误。因为返回内容中没有 next: true 字段,所以直接返回结果;
  2. 当一个用户发起了一个发送短信验证码的服务,这个只是用户验证服务的职能,没有必要继续向下走,于是返回了一个没有 next: true 字段的返回值,于是 Nginx 直接返回结果;
  3. 当用户登录的时候,虽然通过了用户验证服务的校验,但是该服务无法获取更多的用户信息,于是把该请求继续传递到组织架构服务,组织架构服务在请求头中拿到了手机号信息,于是直接返回了该手机号所对应的详细信息;
  4. 现在发起了一个资源操作的请求,因为用户验证服务无法识别,所以只返回了手机号给 nginx,nginx 继续请求组织架构服务,因为组织架构服务也不能处理,所以继续返回了详细的个人信息给 nginx,nginx 最终拿到这些信息,都通过头部请求了资源服务,然后因为这里是主关注点,也是流程里面最后一个节点,所以通过 proxy_pass 给了资源服务。

最终,这样做的优势:

  1. 利用 Nginx 异步的优势来弥补 ruby 服务先天性 IO 处理的不足;
  2. 目前只实现了第一条线,即从用户验证 -> 组织信息 -> 资源服务器的顺序,后面如果有需要,可以随时实现其他顺序,而只需要按照在请求头里面加上相应的参数即可,减低耦合性;
  3. 三个模块都有各自的业务和特点,可以针对模块去设计缓存方案,而且可以分模块去设计集群方案;
  4. 对于开发者而言,更容易完成单个服务的测试用例,而不需要过多在开发过程中关注联调。

原文:使用 Nginx 优化面向侧面的架构

翻译真搞笑,面向侧面。。。。

#1 楼 @googya 用了维基百科里面的说法 😓

楼主的头像好相似 @agentzh

听起来把 lua 换成 nodejs 也不错

#4 楼 @bydmm 虽然是可以的 - -

#3 楼 @hammer 特意仔细看了下,因为都有眼镜加上脸长么。。。

aspect-oriented 之前接触的都翻译为面向切面

谢谢,学习了

这样等于是把部分业务逻辑放到了 nginx 这个层面,对于服务的搭建和从整体架构角度,真的是一个好事?

#9 楼 @leiz_me 业务逻辑还是在模块中的,nginx 只是相当于一个耦合。例如一个资源的请求的生命流程:首先默认经过 authorization 的服务,然后 authorization 服务通过返回当前的验证结果给 nginx,nginx 只是把这个验证结果写到请求头里面去,交给最终的资源服务器,资源服务器拿到请求头后就知道当前请求的验证结果(而无需再去请求 authorization 服务询问结果),然后返回最终的值给 nginx。nginx 在整个流程中只是起到胶水的作用。

作为一个 Ruby 工程师能想到用 Lua+Nginx 实现一个面向侧面的服务,的确不容易。

好处就是当你那咸鱼干式的 Ruby 代码被公司资深而活跃的 Java 程序员们用丰腴的 Java 语言重构后,还能剩下一些 Lua+Nginx 代码,因为你是面向侧面的,无视正面的变革。

不过我相信公司精明的 Java 工程师一定会发现您的设计上的致命问题:性能、可维护性和兼容性等。所以更好的替代方案将驾着七色彩云面世:Apache + Java 的面向侧面的架构!当然,如果不够,还会有人见人爱的 Tomcat。可能都不需要用 JDK6, JDK4 就差不多了,不需要太多变化。

我已经感觉到你情绪上的变化了,您可能不知道,我写 Java 的代码行数可能比你读的代码行数还要多。我知道现在 Ruby 是有点火了,你有深入调研过原因吗?据我所知,这波互联网浪潮已经让我们许多资深的 Java 程序员倒下了。如果没有他们倒下,Ruby 能有今天?

走笔至此,默默感恩!

OpenResty 已经被老罗带火了 囧

需要 登录 后方可回复, 如果你还没有账号请 注册新账号