Web应用的缓存设计模式

ORM缓存引言

从10年前的2003年开始,在Web应用领域,ORM(对象-关系映射)框架就开始逐渐普及,并且流行开来,其中最广为人知的就是Java的开源ORM框架Hibernate,后来Hibernate也成为了EJB3的实现框架;2005年以后,ORM开始普及到其他编程语言领域,其中最有名气的是Ruby on rails框架的ORM - ActiveRecord。如今各种开源框架的ORM,乃至ODM(对象-文档关系映射,用在访问NoSQLDB)层出不穷,功能都十分强大,也很普及。

然而围绕ORM的性能问题,也一直有很多批评的声音。其实ORM的架构对插入缓存技术是非常容易的,我做的很多项目和产品,但凡使用ORM,缓存都是标配,性能都非常好。而且我发现业界使用ORM的案例都忽视了缓存的运用,或者说没有意识到ORM缓存可以带来巨大的性能提升。

ORM缓存应用案例

我们去年有一个老产品重写的项目,这个产品有超过10年历史了,数据库的数据量很大,多个表都是上千万条记录,最大的表记录达到了9000万条,Web访问的请求数每天有300万左右。

老产品采用了传统的解决性能问题的方案:Web层采用了动态页面静态化技术,超过一定时间的文章生成静态HTML文件;对数据库进行分库分表,按年拆表。动态页面静态化和分库分表是应对大访问量和大数据量的常规手段,本身也有效。但它的缺点也很多,比方说增加了代码复杂度和维护难度,跨库运算的困难等等,这个产品的代码维护历来非常困难,导致bug很多。

进行产品重写的时候,我们放弃了动态页面静态化,采用了纯动态网页;放弃了分库分表,直接操作千万级,乃至近亿条记录的大表进行SQL查询;也没有采取读写分离技术,全部查询都是在单台主数据库上进行;数据库访问全部使用ActiveRecord,进行了大量的ORM缓存。上线以后的效果非常好:单台MySQL数据库服务器CPU的IO Wait低于5%;用单台1U服务器2颗4核至强CPU已经可以轻松支持每天350万动态请求量;最重要的是,插入缓存并不需要代码增加多少复杂度,可维护性非常好。

总之,采用ORM缓存是Web应用提升性能一种有效的思路,这种思路和传统的提升性能的解决方案有很大的不同,但它在很多应用场景(包括高度动态化的SNS类型应用)非常有效,而且不会显著增加代码复杂度,所以这也是我自己一直偏爱的方式。因此我一直很想写篇文章,结合示例代码介绍ORM缓存的编程技巧。

今年春节前后,我开发自己的个人网站项目,有意识的大量使用了ORM缓存技巧。对一个没多少访问量的个人站点来说,有些过度设计了,但我也想借这个机会把常用的ORM缓存设计模式写成示例代码,提供给大家参考。我的个人网站源代码是开源的,托管在github上:robbin_site

ORM缓存的基本理念

我在2007年的时候写过一篇文章,分析ORM缓存的理念:ORM对象缓存探讨 ,所以这篇文章不展开详谈了,总结来说,ORM缓存的基本理念是:

  • 以减少数据库服务器磁盘IO为最终目的,而不是减少发送到数据库的SQL条数。实际上使用ORM,会显著增加SQL条数,有时候会成倍增加SQL。
  • 数据库schema设计的取向是尽量设计 细颗粒度 的表,表和表之间用外键关联,颗粒度越细,缓存对象的单位越小,缓存的应用场景越广泛
  • 尽量避免多表关联查询,尽量拆成多个表单独的主键查询,尽量多制造 n + 1 条查询,不要害怕“臭名昭著”的 n + 1 问题,实际上 n + 1 才能有效利用ORM缓存

利用表关联实现透明的对象缓存

在设计数据库的schema的时候,设计多个细颗粒度的表,用外键关联起来。当通过ORM访问关联对象的时候,ORM框架会将关联对象的访问转化成用主键查询关联表,发送 n + 1条SQL。而基于主键的查询可以直接利用对象缓存。

我们自己开发了一个基于ActiveRecord封装的对象缓存框架:second_level_cache ,从这个ruby插件的名称就可以看出,实现借鉴了Hibernate的二级缓存实现。这个对象缓存的配置和使用,可以看我写的ActiveRecord对象缓存配置

下面用一个实际例子来演示一下对象缓存起到的作用:访问我个人站点的首页。 这个页面的数据需要读取三张表:blogs表获取文章信息,blog_contents表获取文章内容,accounts表获取作者信息。三张表的model定义片段如下,完整代码请看models

class Account < ActiveRecord::Base
  acts_as_cached
  has_many :blogs
end

class Blog < ActiveRecord::Base
  acts_as_cached
  belongs_to :blog_content, :dependent => :destroy 
  belongs_to :account, :counter_cache => true
end

class BlogContent < ActiveRecord::Base
  acts_as_cached
end

传统的做法是发送一条三表关联的查询语句,类似这样的:

SELECT blogs.*, blog_contents.content, account.name 
    FROM blogs 
    LEFT JOIN blog_contents ON blogs.blog_content_id = blog_contents.id 
    LEFT JOIN accounts ON blogs.account_id = account.id

往往单条SQL语句就搞定了,但是复杂SQL的带来的表扫描范围可能比较大,造成的数据库服务器磁盘IO会高很多,数据库实际IO负载往往无法得到有效缓解。

我的做法如下,完整代码请看home.rb

@blogs = Blog.order('id DESC').page(params[:page])

这是一条分页查询,实际发送的SQL如下:

SELECT * FROM blogs ORDER BY id DESC LIMIT 20

转成了单表查询,磁盘IO会小很多。至于文章内容,则是通过blog.content的对象访问获得的,由于首页抓取20篇文章,所以实际上会多出来20条主键查询SQL访问blog_contents表。就像下面这样:

DEBUG -  BlogContent Load (0.3ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 29 LIMIT 1
DEBUG -  BlogContent Load (0.2ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 28 LIMIT 1
DEBUG -  BlogContent Load (1.3ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 27 LIMIT 1
......
DEBUG -  BlogContent Load (0.9ms)  SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 10 LIMIT 1

但是主键查询SQL不会造成表的扫描,而且往往已经被数据库buffer缓存,所以基本不会发生数据库服务器的磁盘IO,因而总体的数据库IO负载会远远小于前者的多表联合查询。特别是当使用对象缓存之后,会缓存所有主键查询语句,这20条SQL语句往往并不会全部发生,特别是热点数据,缓存命中率很高:

DEBUG -  Cache read: robbin/blog/29/1
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blogcontent/29/0
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blog/28/1
......
DEBUG -  Cache read: robbin/blogcontent/11/0
DEBUG -  Cache read: robbin/account/1/0
DEBUG -  Cache read: robbin/blog/10/1
DEBUG -  Cache read: robbin/blogcontent/10/0
DEBUG -  Cache read: robbin/account/1/0

拆分n+1条查询的方式,看起来似乎非常违反大家的直觉,但实际上这是真理,我实践经验证明:数据库服务器的瓶颈往往是磁盘IO,而不是SQL并发数量。因此 拆分n+1条查询本质上是以增加n条SQL语句为代价,简化复杂SQL,换取数据库服务器磁盘IO的降低 当然这样做以后,对于ORM来说,有额外的好处,就是可以高效的使用缓存了。

按照column拆表实现细粒度对象缓存

数据库的瓶颈往往在磁盘IO上,所以应该尽量避免对大表的扫描。传统的拆表是按照row去拆分,保持表的体积不会过大,但是缺点是造成应用代码复杂度很高;使用ORM缓存的办法,则是按照column进行拆表,原则一般是:

  • 将大字段拆分出来,放在一个单独的表里面,表只有主键和大字段,外键放在主表当中
  • 将不参与where条件和统计查询的字段拆分出来,放在独立的表中,外键放在主表当中

按照column拆表本质上是一个去关系化的过程。主表只保留参与关系运算的字段,将非关系型的字段剥离到关联表当中,关联表仅允许主键查询,以Key-Value DB的方式来访问。因此这种缓存设计模式本质上是一种SQLDB和NoSQLDB的混合架构设计

下面看一个实际的例子:文章的内容content字段是一个大字段,该字段不能放在blogs表中,否则会造成blogs表过大,表扫描造成较多的磁盘IO。我实际做法是创建blog_contents表,保存content字段,schema简化定义如下:

CREATE TABLE `blogs` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `blog_content_id` int(11) NOT NULL,
  `content_updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
);

CREATE TABLE `blog_contents` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` mediumtext NOT NULL,
  PRIMARY KEY (`id`)
);

blog_contents表只有content大字段,其外键保存到主表blogs的blog_content_id字段里面。

model定义和相关的封装如下:

class Blog < ActiveRecord::Base
  acts_as_cached
  delegate :content, :to => :blog_content, :allow_nil => true
  
  def content=(value)
    self.blog_content ||= BlogContent.new
    self.blog_content.content = value
    self.content_updated_at = Time.now
  end
end

class BlogContent < ActiveRecord::Base
  acts_as_cached
  validates :content, :presence => true
end    

在Blog类上定义了虚拟属性content,当访问blog.content的时候,实际上会发生一条主键查询的SQL语句,获取blog_content.content内容。由于BlogContent上面定义了对象缓存acts_as_cached,只要被访问过一次,content内容就会被缓存到memcached里面。

这种缓存技术实际会非常有效,因为: 只要缓存足够大,所有文章内容可以全部被加载到缓存当中,无论文章内容表有多么大,你都不需要再访问数据库了 更进一步的是: 这张大表你永远都只需要通过主键进行访问,绝无可能出现表扫描的状况 为何当数据量大到9000万条记录以后,我们的系统仍然能够保持良好的性能,秘密就在于此。

还有一点非常重要: 使用以上两种对象缓存的设计模式,你除了需要添加一条缓存声明语句acts_as_cached以外,不需要显式编写一行代码 有效利用缓存的代价如此之低,何乐而不为呢?

以上两种缓存设计模式都不需要显式编写缓存代码,以下的缓存设计模式则需要编写少量的缓存代码,不过代码的增加量非常少。

写一致性缓存

写一致性缓存,叫做write-through cache,是一个CPU Cache借鉴过来的概念,意思是说,当数据库记录被修改以后,同时更新缓存,不必进行额外的缓存过期处理操作。但在应用系统中,我们需要一点技巧来实现写一致性缓存。来看一个例子:

我的网站文章原文是markdown格式的,当页面显示的时候,需要转换成html的页面,这个转换过程本身是非常消耗CPU的,我使用的是Github的markdown的库。Github为了提高性能,用C写了转换库,但如果是非常大的文章,仍然是一个耗时的过程,Ruby应用服务器的负载就会比较高。

我的解决办法是缓存markdown原文转换好的html页面的内容,这样当再次访问该页面的时候,就不必再次转换了,直接从缓存当中取出已经缓存好的页面内容即可,极大提升了系统性能。我的网站文章最终页的代码执行时间开销往往小于10ms,就是这个原因。代码如下:

def md_content  # cached markdown format blog content
  APP_CACHE.fetch(content_cache_key) { GitHub::Markdown.to_html(content, :gfm) }
end

这里存在一个如何进行缓存过期的问题,当文章内容被修改以后,应该更新缓存内容,让老的缓存过期,否则就会出现数据不一致的现象。进行缓存过期处理是比较麻烦的,我们可以利用一个技巧来实现自动缓存过期:

def content_cache_key
  "#{CACHE_PREFIX}/blog_content/#{self.id}/#{content_updated_at.to_i}"
end

当构造缓存对象的key的时候,我用文章内容被更新的时间来构造key值,这个文章内容更新时间用的是blogs表的content_updated_at字段,当文章被更新的时候,blogs表会进行update,更新该字段。因此每当文章内容被更新,缓存的页面内容的key就会改变,应用程序下次访问文章页面的时候,缓存就会失效,于是重新调用GitHub::Markdown.to_html(content, :gfm)生成新的页面内容。 而老的页面缓存内容再也不会被应用程序存取,根据memcached的LRU算法,当缓存填满之后,将被优先剔除。

除了文章内容缓存之外,文章的评论内容转换成html以后也使用了这种缓存设计模式。具体可以看相应的源代码:blog_comment.rb

片段缓存和过期处理

Web应用当中有大量的并非实时更新的数据,这些数据都可以使用缓存,避免每次存取的时候都进行数据库查询和运算。这种片段缓存的应用场景很多,例如:

  • 展示网站的Tag分类统计(只要没有更新文章分类,或者发布新文章,缓存一直有效)
  • 输出网站RSS(只要没有发新文章,缓存一直有效)
  • 网站右侧栏(如果没有新的评论或者发布新文章,则在一段时间例如一天内基本不需要更新)

以上应用场景都可以使用缓存,代码示例:

def self.cached_tag_cloud
  APP_CACHE.fetch("#{CACHE_PREFIX}/blog_tags/tag_cloud") do
    self.tag_counts.sort_by(&:count).reverse
  end
end

对全站文章的Tag云进行查询,对查询结果进行缓存


<% cache("#{CACHE_PREFIX}/layout/right", :expires_in => 1.day) do %>

<div class="tag">
  <% Blog.cached_tag_cloud.select {|t| t.count > 2}.each do |tag| %>
  <%= link_to "#{tag.name}<span>#{tag.count}</span>".html_safe, url(:blog, :tag, :name => tag.name) %>
  <% end %>
</div>
......
<% end %>

对全站右侧栏页面进行缓存,过期时间是1天。

缓存的过期处理往往是比较麻烦的事情,但在ORM框架当中,我们可以利用model对象的回调,很容易实现缓存过期处理。我们的缓存都是和文章,以及评论相关的,所以可以直接注册Blog类和BlogComment类的回调接口,声明当对象被保存或者删除的时候调用删除方法:

class Blog < ActiveRecord::Base
  acts_as_cached
  after_save :clean_cache
  before_destroy :clean_cache
  def clean_cache
    APP_CACHE.delete("#{CACHE_PREFIX}/blog_tags/tag_cloud")   # clean tag_cloud
    APP_CACHE.delete("#{CACHE_PREFIX}/rss/all")               # clean rss cache
    APP_CACHE.delete("#{CACHE_PREFIX}/layout/right")          # clean layout right column cache in _right.erb
  end
end

class BlogComment < ActiveRecord::Base
  acts_as_cached
  after_save :clean_cache
  before_destroy :clean_cache
  def clean_cache
    APP_CACHE.delete("#{CACHE_PREFIX}/layout/right")     # clean layout right column cache in _right.erb
  end
end  

在Blog对象的after_savebefore_destroy上注册clean_cache方法,当文章被修改或者删除的时候,删除以上缓存内容。总之,可以利用ORM对象的回调接口进行缓存过期处理,而不需要到处写缓存清理代码。

对象写入缓存

我们通常说到缓存,总是认为缓存是提升应用读取性能的,其实缓存也可以有效的提升应用的写入性能。我们看一个常见的应用场景:记录文章点击次数这个功能。

文章点击次数需要每次访问文章页面的时候,都要更新文章的点击次数字段view_count,然后文章必须实时显示文章的点击次数,因此常见的读缓存模式完全无效了。每次访问都必须更新数据库,当访问量很大以后数据库是吃不消的,因此我们必须同时做到两点:

  • 每次文章页面被访问,都要实时更新文章的点击次数,并且显示出来
  • 不能每次文章页面被访问,都更新数据库,否则数据库吃不消

对付这种应用场景,我们可以利用对象缓存的不一致,来实现对象写入缓存。原理就是每次页面展示的时候,只更新缓存中的对象,页面显示的时候优先读取缓存,但是不更新数据库,让缓存保持不一致,积累到n次,直接更新一次数据库,但绕过缓存过期操作。具体的做法可以参考blog.rb

# blog viewer hit counter
def increment_view_count
  increment(:view_count)        # add view_count += 1
  write_second_level_cache      # update cache per hit, but do not touch db
                                # update db per 10 hits
  self.class.update_all({:view_count => view_count}, :id => id) if view_count % 10 == 0
end

increment(:view_count)增加view_count计数,关键代码是第2行write_second_level_cache,更新view_count之后直接写入缓存,但不更新数据库。累计10次点击,再更新一次数据库相应的字段。另外还要注意,如果blog对象不是通过主键查询,而是通过查询语句构造的,要优先读取一次缓存,保证页面点击次数的显示一致性,因此 _blog.erb 这个页面模版文件开头有这样一段代码:

<% 
  # read view_count from model cache if model has been cached.
  view_count = blog.view_count
  if b = Blog.read_second_level_cache(blog.id)
    view_count = b.view_count
  end
%>

采用对象写入缓存的设计模式,就可以非常容易的实现写入操作的缓存,在这个例子当中,我们仅仅增加了一行缓存写入代码,而这个时间开销大约是1ms,就可以实现文章实时点击计数功能,是不是非常简单和巧妙?实际上我们也可以使用这种设计模式实现很多数据库写入的缓存功能。

常用的ORM缓存设计模式就是以上的几种,本质上都是非常简单的编程技巧,代码的增加量和复杂度也非常低,只需要很少的代码就可以实现,但是在实际应用当中,特别是当数据量很庞大,访问量很高的时候,可以发挥惊人的效果。我们实际的系统当中,缓存命中次数:SQL查询语句,一般都是5:1左右,即每次向数据库查询一条SQL,都会在缓存当中命中5次,数据主要都是从缓存当中得到,而非来自于数据库了。

其他缓存的使用技巧

还有一些并非ORM特有的缓存设计模式,但是在Web应用当中也比较常见,简单提及一下:

用数据库来实现的缓存

在我这个网站当中,每篇文章都标记了若干tag,而tag关联关系都是保存到数据库里面的,如果每次显示文章,都需要额外查询关联表获取tag,显然会非常消耗数据库。在我使用的acts-as-taggable-on插件中,它在blogs表当中添加了一个cached_tag_list字段,保存了该文章标记的tag。当文章被修改的时候,会自动相应更新该字段,避免了每次显示文章的时候都需要去查询关联表的开销。

HTTP客户端缓存

基于资源协议实现的HTTP客户端缓存也是一种非常有效的缓存设计模式,我在2009年写过一篇文章详细的讲解了:基于资源的HTTP Cache的实现介绍 ,所以这里就不再复述了。

用缓存实现计数器功能

这种设计模式有点类似于对象写入缓存,利用缓存写入的低开销来实现高性能计数器。举一个例子:用户登录为了避免遭遇密码暴力破解,我限定了每小时每IP只能尝试登录5次,如果超过5次,拒绝该IP再次尝试登录。代码实现很简单,如下:

post :login, :map => '/login' do
  login_tries = APP_CACHE.read("#{CACHE_PREFIX}/login_counter/#{request.ip}")
  halt 403 if login_tries && login_tries.to_i > 5  # reject ip if login tries is over 5 times
  @account = Account.new(params[:account])
  if login_account = Account.authenticate(@account.email, @account.password)
    session[:account_id] = login_account.id
    redirect url(:index)
  else
    # retry 5 times per one hour
    APP_CACHE.increment("#{CACHE_PREFIX}/login_counter/#{request.ip}", 1, :expires_in => 1.hour)
    render 'home/login'
  end
end

等用户POST提交登录信息之后,先从缓存当中取该IP尝试登录次数,如果大于5次,直接拒绝掉;如果不足5次,而且登录失败,计数加1,显示再次尝试登录页面。

Web应用的缓存设计模式

一、location正则写法

一个示例:

location  = / {
  # 精确匹配 / ,主机名后面不能带任何字符串
  [ configuration A ]
}

location  / {
  # 因为所有的地址都以 / 开头,所以这条规则将匹配到所有请求
  # 但是正则和最长字符串会优先匹配
  [ configuration B ]
}

location /documents/ {
  # 匹配任何以 /documents/ 开头的地址,匹配符合以后,还要继续往下搜索
  # 只有后面的正则表达式没有匹配到时,这一条才会采用这一条
  [ configuration C ]
}

location ~ /documents/Abc {
  # 匹配任何以 /documents/Abc 开头的地址,匹配符合以后,还要继续往下搜索
  # 只有后面的正则表达式没有匹配到时,这一条才会采用这一条
  [ configuration CC ]
}

location ^~ /images/ {
  # 匹配任何以 /images/ 开头的地址,匹配符合以后,停止往下搜索正则,采用这一条。
  [ configuration D ]
}

location ~* \.(gif|jpg|jpeg)$ {
  # 匹配所有以 gif,jpg或jpeg 结尾的请求
  # 然而,所有请求 /images/ 下的图片会被 config D 处理,因为 ^~ 到达不了这一条正则
  [ configuration E ]
}

location /images/ {
  # 字符匹配到 /images/,继续往下,会发现 ^~ 存在
  [ configuration F ]
}

location /images/abc {
  # 最长字符匹配到 /images/abc,继续往下,会发现 ^~ 存在
  # F与G的放置顺序是没有关系的
  [ configuration G ]
}

location ~ /images/abc/ {
  # 只有去掉 config D 才有效:先最长匹配 config G 开头的地址,继续往下搜索,匹配到这一条正则,采用
    [ configuration H ]
}


location ~ ^/(api\/)?image/ {
        expires 30d;
        add_header Pragma public;
        add_header Cache-Control "public";
        try_files $uri $uri/ @rewrite;
}

location 匹配以 /api/image/ 和 /image/  开头的 url

location ~ /js/./.js

已=开头表示精确匹配
如 A 中只匹配根目录结尾的请求,后面不能带任何字符串。
^~ 开头表示uri以某个常规字符串开头,不是正则匹配
~ 开头表示区分大小写的正则匹配;
~* 开头表示不区分大小写的正则匹配
/ 通用匹配, 如果没有其它匹配,任何请求都会匹配到
顺序 no优先级:
(location =) > (location 完整路径) > (location ^~ 路径) > (location ~,~* 正则顺序) > (location 部分起始路径) > (/)

上面的匹配结果

按照上面的location写法,以下的匹配示例成立:

/ -> config A
精确完全匹配,即使/index.html也匹配不了
/downloads/download.html -> config B
匹配B以后,往下没有任何匹配,采用B
/images/1.gif -> configuration D
匹配到F,往下匹配到D,停止往下
/images/abc/def -> config D
最长匹配到G,往下匹配D,停止往下
你可以看到 任何以/images/开头的都会匹配到D并停止,FG写在这里是没有任何意义的,H是永远轮不到的,这里只是为了说明匹配顺序
/documents/document.html -> config C
匹配到C,往下没有任何匹配,采用C
/documents/1.jpg -> configuration E
匹配到C,往下正则匹配到E
/documents/Abc.jpg -> config CC
最长匹配到C,往下正则顺序匹配到CC,不会往下到E

实际使用建议

所以实际使用中,个人觉得至少有三个匹配规则定义,如下:

#直接匹配网站根,通过域名访问网站首页比较频繁,使用这个会加速处理,官网如是说。
#这里是直接转发给后端应用服务器了,也可以是一个静态首页
# 第一个必选规则
location = / {
    proxy_pass http://tomcat:8080/index
}
# 第二个必选规则是处理静态文件请求,这是nginx作为http服务器的强项
# 有两种配置模式,目录匹配或后缀匹配,任选其一或搭配使用
location ^~ /static/ {
    root /webroot/static/;
}
location ~* \.(gif|jpg|jpeg|png|css|js|ico)$ {
    root /webroot/res/;
}
#第三个规则就是通用规则,用来转发动态请求到后端应用服务器
#非静态文件请求就默认是动态请求,自己根据实际把握
#毕竟目前的一些框架的流行,带.php,.jsp后缀的情况很少了
location / {
    proxy_pass http://tomcat:8080/
}
http://tengine.taobao.org/book/chapter_02.html
http://nginx.org/en/docs/http/ngx_http_rewrite_module.html

二、Rewrite规则

rewrite功能就是,使用nginx提供的全局变量或自己设置的变量,结合正则表达式和标志位实现url重写以及重定向。rewrite只能放在server{},location{},if{}中,并且只能对域名后边的除去传递的参数外的字符串起作用,例如 http://seanlook.com/a/we/index.php?id=1&u=str 只对/a/we/index.php重写。语法rewrite regex replacement [flag];

如果相对域名或参数字符串起作用,可以使用全局变量匹配,也可以使用proxy_pass反向代理。

表明看rewrite和location功能有点像,都能实现跳转,主要区别在于rewrite是在同一域名内更改获取资源的路径,而location是对一类路径做控制访问或反向代理,可以proxy_pass到其他机器。很多情况下rewrite也会写在location里,它们的执行顺序是:

执行server块的rewrite指令
执行location匹配

执行选定的location中的rewrite指令
如果其中某步URI被重写,则重新循环执行1-3,直到找到真实存在的文件;循环超过10次,则返回500 Internal Server Error错误。

2.1 flag标志位
last : 相当于Apache的[L]标记,表示完成rewrite
break : 停止执行当前虚拟主机的后续rewrite指令集
redirect : 返回302临时重定向,地址栏会显示跳转后的地址
permanent : 返回301永久重定向,地址栏会显示跳转后的地址
因为301和302不能简单的只返回状态码,还必须有重定向的URL,这就是return指令无法返回301,302的原因了。这里 last 和 break 区别有点难以理解:

last一般写在server和if中,而break一般使用在location中
last不终止重写后的url匹配,即新的url会再从server走一遍匹配流程,而break终止重写后的匹配
break和last都能组织继续执行后面的rewrite指令

2.2 if指令与全局变量
if判断指令
语法为if(condition){...},对给定的条件condition进行判断。如果为真,大括号内的rewrite指令将被执行,if条件(conditon)可以是如下任何内容:

当表达式只是一个变量时,如果值为空或任何以0开头的字符串都会当做false
直接比较变量和内容时,使用=或!=
~正则表达式匹配,~*不区分大小写的匹配,!~区分大小写的不匹配
-f和!-f用来判断是否存在文件
-d和!-d用来判断是否存在目录
-e和!-e用来判断是否存在文件或目录
-x和!-x用来判断文件是否可执行

例如:

if ($http_user_agent ~ MSIE) {
    rewrite ^(.*)$ /msie/$1 break;
} //如果UA包含"MSIE",rewrite请求到/msid/目录下

if ($http_cookie ~* "id=([^;]+)(?:;|$)") {
    set $id $1;
 } //如果cookie匹配正则,设置变量$id等于正则引用部分

if ($request_method = POST) {
    return 405;
} //如果提交方法为POST,则返回状态405(Method not allowed)。return不能返回301,302

if ($slow) {
    limit_rate 10k;
} //限速,$slow可以通过 set 指令设置

if (!-f $request_filename){
    break;
    proxy_pass  http://127.0.0.1;
} //如果请求的文件名不存在,则反向代理到localhost 。这里的break也是停止rewrite检查

if ($args ~ post=140){
    rewrite ^ http://example.com/ permanent;
} //如果query string中包含"post=140",永久重定向到example.com

location ~* \.(gif|jpg|png|swf|flv)$ {
    valid_referers none blocked www.jefflei.com www.leizhenfang.com;
    if ($invalid_referer) {
        return 404;
    } //防盗链
}

全局变量

下面是可以用作if判断的全局变量

$args : #这个变量等于请求行中的参数,同$query_string
$content_length : 请求头中的Content-length字段。
$content_type : 请求头中的Content-Type字段。
$document_root : 当前请求在root指令中指定的值。
$host : 请求主机头字段,否则为服务器名称。
$http_user_agent : 客户端agent信息
$http_cookie : 客户端cookie信息
$limit_rate : 这个变量可以限制连接速率。
$request_method : 客户端请求的动作,通常为GET或POST。
$remote_addr : 客户端的IP地址。
$remote_port : 客户端的端口。
$remote_user : 已经经过Auth Basic Module验证的用户名。
$request_filename : 当前请求的文件路径,由root或alias指令与URI请求生成。
$scheme : HTTP方法(如http,https)。
$server_protocol : 请求使用的协议,通常是HTTP/1.0或HTTP/1.1。
$server_addr : 服务器地址,在完成一次系统调用后可以确定这个值。
$server_name : 服务器名称。
$server_port : 请求到达服务器的端口号。
$request_uri : 包含请求参数的原始URI,不包含主机名,如:”/foo/bar.php?arg=baz”。
$uri : 不带请求参数的当前URI,$uri不包含主机名,如”/foo/bar.html”。
$document_uri : 与$uri相同。
例:http://localhost:88/test1/test2/test.php
$host:localhost
$server_port:88
$request_uri:http://localhost:88/test1/test2/test.php
$document_uri:/test1/test2/test.php
$document_root:/var/www/html
$request_filename:/var/www/html/test1/test2/test.php

2.3 常用正则

. : 匹配除换行符以外的任意字符
? : 重复0次或1次
+ : 重复1次或更多次
* : 重复0次或更多次
\d :匹配数字
^ : 匹配字符串的开始
$ : 匹配字符串的介绍
{n} : 重复n次
{n,} : 重复n次或更多次
[c] : 匹配单个字符c
[a-z] : 匹配a-z小写字母的任意一个
小括号()之间匹配的内容,可以在后面通过$1来引用,$2表示的是前面第二个()里的内容。正则里面容易让人困惑的是\转义特殊字符。

2.4 rewrite实例
例1:

http {
    # 定义image日志格式
    log_format imagelog '[$time_local] ' $image_file ' ' $image_type ' ' $body_bytes_sent ' ' $status;
    # 开启重写日志
    rewrite_log on;

    server {
        root /home/www;

        location / {
                # 重写规则信息
                error_log logs/rewrite.log notice;
                # 注意这里要用‘’单引号引起来,避免{}
                rewrite '^/images/([a-z]{2})/([a-z0-9]{5})/(.*)\.(png|jpg|gif)$' /data?file=$3.$4;
                # 注意不能在上面这条规则后面加上“last”参数,否则下面的set指令不会执行
                set $image_file $3;
                set $image_type $4;
        }

        location /data {
                # 指定针对图片的日志格式,来分析图片类型和大小
                access_log logs/images.log mian;
                root /data/images;
                # 应用前面定义的变量。判断首先文件在不在,不在再判断目录在不在,如果还不在就跳转到最后一个url里
                try_files /$arg_file /image404.html;
        }
        location = /image404.html {
                # 图片不存在返回特定的信息
                return 404 "image not found\n";
        }
}

对形如/images/ef/uh7b3/test.png的请求,重写到/data?file=test.png,于是匹配到location /data,先看/data/images/test.png文件存不存在,如果存在则正常响应,如果不存在则重写tryfiles到新的image404 location,直接返回404状态码。

例2:

rewrite ^/images/(.*)_(\d+)x(\d+)\.(png|jpg|gif)$ /resizer/$1.$4?width=$2&height=$3? last;
对形如/images/bla_500x400.jpg的文件请求,重写到/resizer/bla.jpg?width=500&height=400地址,并会继续尝试匹配location。

⌘ + number 切换标签页

⌘ + ←/→ 按方向切换标签页

⌘ + ⏎ 切换全屏

⌘ + f 查找

⌘ + d 垂直分屏,⌘ + shift + d 水平分屏。使用⌘ + ]和⌘ + [在最近使用的分屏直接切换.而⌘ + opt + ←/→切换到指定位置的分屏。

⌘ + t 新的标签页

⌘ + w 关闭当前标签页

⌘ + ; 自动补全历史命令。

⌃ + u 清空当前行。

⌃ + a 到行首

⌃ + e 行末

⌃ + f/b 前进后退,相当于左右方向键,原po说比移开手按方向键更快,我倒是觉得更加难按。

⌃ + d 删除当前字符

⌃ + h 删除之前的字符

⌃ + w 删除光标前的单词

⌃ + k 删除到文本末尾

之所以会去查iterm2的快捷键,是因为我觉得iterm2终端下单词间的移动非常低效。即我只可以通过⌃ + a/e控制到行首和行尾的移动,而不能按照单词移动。按照单词移动大概是用vim用的太顺手了吧。最终找到的解是ESC + b/f(b后退一个单词,f前进一个单词)。然而这未免太难按了吧!最终通过item2的Key bind解决了这个问题。将其映射为⌥ + ←/→。

安装

  1. 需要一个google 账号
  2. 需要创建一个 google api project, 创建项目
  3. 安装类库
  4. 一个 laravel 项目

授权

理解 api 认证 和 如何认证是非常重要的. 所有的 API 或者是简易访问或者认证访问. 大多数 API 调用都需要经过认证. 这些需要差看相关文档.

简单 API 访问

这些API调用不会访问任何私有用户数据。您的应用程序必须将自己身份验证为属于您的Google API控制台项目的应用程序。这是衡量项目使用情况以进行统计目的所必需的。

API key 是用来认证身份的重要依据, 没一个 简单 API 认证的请求都需要携带 key.

认证 API 访问(Oauth2.0)

这些API调用访问私有用户数据。在您可以调用它们之前,有权访问私有数据的用户必须授予您的应用程序访问权限。因此,必须对您的应用程序进行身份验证,用户必须为您的应用程序授予访问权限,并且必须对用户进行身份验证才能授予该访问权限。所有这一切都是通过 OAuth 2.0 和为其编写的库来完成的。

几个重要概念:

Scope

每个API都定义了一个或多个声明允许一组操作的作用域。例如,API可能具有只读和读写范围。当您的应用程序请求访问用户数据时,该请求必须包含一个或多个范围。用户需要批准您的应用程序请求的访问范围。

refresh and access tokens

刷新和访问令牌:当用户授予您的应用程序访问权限时,OAuth 2.0 授权服务器会为您的应用程序提供刷新和访问令牌。这些令牌仅对请求的范围有效。您的应用程序使用访问令牌来授权API 调用。访问令牌过期,但刷新令牌不会。您的应用程序可以使用刷新令牌来获取新的访问令牌。

client ID and client secret

客户端 ID 和客户端密钥:这些字符串唯一标识您的应用程序并用于获取令牌。它们是在Google API控制台的 API 访问面板上为您的 Google API 控制台项目创建的。有三种类型的客户端ID,因此请确保为您的应用程序获取正确的类型:

  • Web application client IDs
  • Installed application client IDs
  • Service Account client IDs

Building and calling a service

这一节描述了如和运行 一个具体的 API 服务, 向服务端发起请求, 并获取回复.

Build the client object

客户端对象是库中类和配置的主要容器。


$client = new Google_Client();
$client->setApplicationName("My Application");
$client->setDeveloperKey("MY_SIMPLE_API_KEY");
Build the service object

通过查询来调用服务以服务特定对象。这些是通过构造服务对象并将Google_Client实例传递给它来创建的。Google_Client包含服务所需的IO,身份验证和其他类,并且该服务会通知客户端在对用户进行身份验证时使用哪些范围来提供默认值。

$service = new Google_Service_Books($client);
Calling an API

每个 API 都提供资源和方法,通常在链中。这些可以通过$ service-> resource-> method(args)形式从服务对象访问。大多数方法需要一些参数,然后接受包含可选参数的数组的最终参数。例如,使用 Google Books API,我们可以调用列出与特定字符串匹配的卷,并添加可选的过滤器参数。

$optParams = array('filter' => 'free-ebooks');
$results = $service->volumes->listVolumes('Henry David Thoreau', $optParams);
  
处理结果

有两种主要类型的响应 - 项目和项目集合。每个都可以作为对象或数组进行访问。集合实现了Iterator接口,因此可以在foreach和其他构造中使用。

foreach ($results as $item) {
  echo $item['volumeInfo']['title'], "<br /> \n";
}

使用 Oauth2.0 处理 服务端应用

适用于PHP的Google API客户端库支持使用OAuth 2.0进行服务器到服务器的交互,例如Web应用程序和Google服务之间的交互。对于此方案,您需要一个服务帐户,该帐户属于您的应用程序而不是单个最终用户。您的应用程序代表服务帐户调用 Google API,因此用户不会直接参与。此方案有时称为“两条腿 OAuth ”或“2LO”。(相关术语“三足OAuth”是指您的应用程序代表最终用户调用Google API并且有时需要用户同意的情况。)

如果您有Google Apps域 - 例如,如果您使用G Suite,则Google Apps域管理员可以授权应用程序代表Google Apps域中的用户访问用户数据。例如,使用Google Calendar API将事件添加到Google Apps域中所有用户的日历的应用程序将使用服务帐户代表用户访问Google Calendar API。授权服务帐户代表域中的用户访问数据有时被称为“将域范围授权”委托给服务帐户。

概览

要支持服务器到服务器的交互,请首先在API控制台中为项目创建服务帐户。如果您要访问 Google Apps 域中用户的用户数据,请将域范围内的访问权委托给该服务帐户。然后,您的应用程序准备使用服务帐户的凭据进行授权的API调用,以从 OAuth 2.0 auth服务器请求访问令牌。最后,您的应用程序可以使用访问令牌来调用Google API。

创建一个服务账号

服务帐户的凭证包括生成的唯一电子邮件地址,客户端ID和至少一个公钥/私钥对。如果您的应用程序在 Google App Engine 上运行,则在您创建项目时会自动设置服务帐户。如果您的应用程序未在 Google App Engine 或 Google Compute Engine 上运行,则必须在 Google API 控制台中获取这些凭据。要生成服务帐户凭据,或查看已生成的公用凭证,请执行以下操作:

  1. Service accounts 页面.选择项目.
  2. 创建一个服务账户
  3. 在 Create service account 弹窗, 输入服务账户名称, 选择装设新的私钥.点击创建.

您的新公钥/私钥对会生成并下载到您的计算机上;它是此密钥的唯一副本。您有责任安全地存储它。

您可以随时返回 API 控制台查看客户端 ID,电子邮件地址和公钥指纹,或生成其他公钥/私钥对。有关 API 控制台中服务帐户凭据的更多详细信息,请参阅API控制台帮助文件中的服务帐户。记下服务帐户的电子邮件地址,并将服务帐户的私钥文件存储在应用程序可访问的位置。您的应用程序需要它们进行授权的API调用。

需要在 webmaster 应用中增加账户权限