Rails中的MIME类型解析规则

本文缘于在项目中遇到的一个问题,查阅了网上的资料和Rails源码后有一点收获,简单做个总结,有些地方不够全面,欢迎大家补充指正。

相关背景

Rails项目中经常可以看到类似如下代码:

respond_to do |format|
  format.html
  format.json { render json: @users }
  format.xml  { render xml: @users }
end

如果想获取xml格式的数据,就在请求路径后面增加.xml扩展名,比如localhost:3000/users.xml,这样就可以拿到xml格式的返回。

路径扩展名是Rails中MIME类型解析的一个影响因素,另一个影响因素是HTTP头字段Accept。

HTTP头字段Accept

当浏览器发送请求的时候,它也会通知服务器自己能处理的内容类型。访问网站的时候可以通过浏览器的开发者工具查看它们发送的Accept头。

下面是我的机器上不同浏览器发送的Accept头:

Chrome: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Firefox: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Safari: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

让我们看看Chrome的Accept头:

Chrome: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8

Chrome的头表明它可以处理html文档(text/html)、xhtml文档(application/xhtml+xml)、xml文档(application/xml)、webp和apng格式的图片,以及其它别的格式。

Accept头里面有一个q值,它表示优先级。HTTP标准里面是这么描述的(https://tools.ietf.org/html/rfc7231#section-5.3.1):

5.3.1. Quality Values

Many of the request header fields for proactive negotiation use a common parameter, named "q" (case-insensitive), to assign a relative "weight" to the preference for that associated kind of content. This weight is referred to as a "quality value" (or "qvalue") because the same parameter name is often used within server configurations to assign a weight to the relative quality of the various representations that can be selected for a resource.

The weight is normalized to a real number in the range 0 through 1, where 0.001 is the least preferred and 1 is the most preferred; a value of 0 means "not acceptable". If no "q" parameter is present, the default weight is 1.

简单说就是为了表示Accept中内容类型的优先级,使用一个参数q来作为权重,q是一个0到1之间的实数,最小是0.001,最大是1。如果是0表示不接受这种内容类型。如果没有指定,q默认值为1。

Accept内容类型的优先级除了与q值和顺序有关,还与类型具体化程度有关,越具体的类型优先级越高(https://tools.ietf.org/html/rfc7231#section-5.3.2):

Media ranges can be overridden by more specific media ranges or
specific media types. If more than one media range applies to a
given type, the most specific reference has precedence.

举RFC里的例子:

Accept: text/*, text/plain, text/plain;format=flowed, */*

这个Accept头里,类型优先级如下:

  1. text/plain;format=flowed
  2. text/plain
  3. text/*
  4. /

关于Accept优先级到此为止,不继续深究,有兴趣的同学可以阅读RFC原文。

遇到的问题

我们的项目采用前后端分离的写法,后端controller中有如下代码:

respond_to do |format|
  format.html do
    flash[:alert] = '用户密码被重置,请重新登录'
    redirect_to new_user_session_path
  end

  format.json do
    render(json: {
              code: 0,
              location: new_user_session_path,
              msg: '用户密码被重置,请重新登录'
            })
  end
end

前端发送的请求头中Accept字段如下:

Accept: application/json, text/plain, */*

按我的理解,接口应该返回json数据,结果返回的是html重定向。
经过后来几次测试、跟踪源码,现象如下:

Accept 返回
application/json, text/plain, */* html重定向
application/json, text/plain json数据
*/* 与respond_to代码块中声明格式的顺序有关,即format.htmlformat.json哪个在前,返回哪个

除了第一条,后面两条基本还是符合直觉的。接下来看看Rails判断返回格式的规则到底是什么样的?

Rails对MIME的解析

答案当然要从Rails源码中找(Read the fucking source code ^_^),涉及的函数如下(在文件rails/actionpack/lib/action_dispatch/http/mime_negotiation.rb中,代码细节先不用深究,后文还有分析):

# order是在respond_to代码块中声明的返回格式数组,formats函数返回前端需要的格式数组,见后面的定义
def negotiate_mime(order)
  formats.each do |priority|
    if priority == Mime::ALL
      return order.first
    elsif order.include?(priority)
      return priority
    end
  end

  order.include?(Mime::ALL) ? format : nil
end
# formats函数解析前端需要的格式,返回解析后的格式数组
# 其中第二个条件分支中的accepts函数会解析Accept字段,注意前提是有Accept头并且校验合法
def formats
  fetch_header("action_dispatch.request.formats") do |k|
    params_readable = begin
                        parameters[:format]
                      rescue ActionController::BadRequest
                        false
                      end

    v = if params_readable
      Array(Mime[parameters[:format]])
    elsif use_accept_header && valid_accept_header
      accepts
    elsif extension_format = format_from_path_extension
      [extension_format]
    elsif xhr?
      [Mime[:js]]
    else
      [Mime[:html]]
    end
    set_header k, v
  end
end
# accepts函数调用Mime::Type.parse完成真正的解析工作
def accepts
  fetch_header("action_dispatch.request.accepts") do |k|
    header = get_header("HTTP_ACCEPT").to_s.strip

    v = if header.empty?
      [content_mime_type]
    else
      Mime::Type.parse(header)
    end
    set_header k, v
  end
end
private

  # 这个正则表达式是用来判断是否是浏览器发出的请求,不是浏览器发出的请求才返回true
  BROWSER_LIKE_ACCEPTS = /,\s*\*\/\*|\*\/\*\s*,/

  # 校验函数,在formats中被调用,用来检查Accept是否正确
  def valid_accept_header # :doc:
    (xhr? && (accept.present? || content_mime_type)) ||
      (accept.present? && accept !~ BROWSER_LIKE_ACCEPTS)
      # 此处是问题的关键,如果accept头里包含*/*和其他类型(如:Accept: application/json, text/plain, */*)则此函数返回false,
      # 因此formats函数里不会解析Accept,Accept不起作用,formats函数继续往下走,走到了else分支返回html。
      # 如果只有*/*(Accept: */*),此函数返回true,negotiate_mime函数会选择respond代码块里的第一个格式类型返回(详细分析见下文)
  end

通过以上代码,可以大体看出Rails对返回格式的判断流程:

  1. 判断请求是否带format后缀,如果带,则返回相应格式。如:http://localhost:3000/users.xml
  2. 判断请求是否有Accept头并且是合法头,如果是,则解析Accept,返回Accept中的格式。
  3. 判断请求是否有format_from_path_extension的格式,不好意思,这个暂时没来得及研究是啥,欢迎大神们补充^_^。
  4. 判断请求是否是Ajax调用,如果是,返回javascript格式数据。
  5. 以上都不满足,返回html格式。

对浏览器的特殊处理

从上面的代码里看到,Rails对浏览器发出的请求做了特殊处理,如果是浏览器发出的,则不解析Accept头。为什么要对浏览器的请求做特殊处理?查阅的资料显示是因为早期浏览器设计不规范,大部分浏览器的请求头Accept字段第一个值是application/xml,如果按照Accept解析就会给浏览器用户返回xml格式的数据,而这通常不是浏览器用户想要的。因此判断如果是浏览器就直接忽略Accept头。

最后总结一下Accept的三种情形。

Accept三种情形

1. Accept不包含*/*

假设接口中有如下代码:

respond_to do |format|
  format.html { render html: '<p>this is html</p>'.html_safe }
  format.json { render json: { data: 'this is json' } }
end

Accept的值为:

application/json, text/plain

此时formats函数中valid_accept_header返回true,Rails会解析Accept,formats函数最终返回如下格式顺序的数组:

json
plain

negotiate_mime函数中的order数组中包含的格式是:

html
json

这种情况下,代码遍历formats,如果order中有匹配的格式就返回。format的第一个格式是json,order中有匹配,此时返回json数据。

结论:Accept不包含*/*时,按照Accept的优先级返回。

2. Accept头是*/*

Accept的值为:

*/*

接口代码如下:

respond_to do |format|
  format.html { render html: '<p>this is html</p>'.html_safe }
  format.json { render json: { data: 'this is json' } }
end

此时返回html格式。

respond_to代码块调整一下顺序:

respond_to do |format|
  format.json { render json: { data: 'this is json' } }
  format.html { render html: '<p>this is html</p>'.html_safe }
end

此时返回json格式。

Accept头是*/*时,解析Accept的结果为Mime::ALL类型,从negotiate_mime函数代码中可以明显看出,当请求类型为Mime::ALL时,选择order中的第一个格式返回,此时会返回respond_to代码块中的第一个格式。

如果没有respond_to代码块呢?如果没有respond_to代码块,但是在view目录下有test.html.erbtest.json.jbuilder两个文件,Rails返回什么格式?这种情况下,Rails按顺序遍历所有注册的Mime格式,找对应的匹配文件,一旦找到文件就返回该格式。这种情况下的返回格式依赖Mime格式的注册顺序,下面是Mime格式注册的代码(rails/actionpack/lib/action_dispatch/http/mime_types.rb):

Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml )
Mime::Type.register "text/plain", :text, [], %w(txt)
Mime::Type.register "text/javascript", :js, %w( application/javascript application/x-javascript )
Mime::Type.register "text/css", :css
Mime::Type.register "text/calendar", :ics
Mime::Type.register "text/csv", :csv
Mime::Type.register "text/vcard", :vcf
Mime::Type.register "text/vtt", :vtt, %w(vtt)

Mime::Type.register "image/png", :png, [], %w(png)
Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg)
Mime::Type.register "image/gif", :gif, [], %w(gif)
Mime::Type.register "image/bmp", :bmp, [], %w(bmp)
Mime::Type.register "image/tiff", :tiff, [], %w(tif tiff)
Mime::Type.register "image/svg+xml", :svg

Mime::Type.register "video/mpeg", :mpeg, [], %w(mpg mpeg mpe)

Mime::Type.register "audio/mpeg", :mp3, [], %w(mp1 mp2 mp3)
Mime::Type.register "audio/ogg", :ogg, [], %w(oga ogg spx opus)
Mime::Type.register "audio/aac", :m4a, %w( audio/mp4 ), %w(m4a mpg4 aac)

Mime::Type.register "video/webm", :webm, [], %w(webm)
Mime::Type.register "video/mp4", :mp4, [], %w(mp4 m4v)

Mime::Type.register "font/otf", :otf, [], %w(otf)
Mime::Type.register "font/ttf", :ttf, [], %w(ttf)
Mime::Type.register "font/woff", :woff, [], %w(woff)
Mime::Type.register "font/woff2", :woff2, [], %w(woff2)

Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml )
Mime::Type.register "application/rss+xml", :rss
Mime::Type.register "application/atom+xml", :atom
Mime::Type.register "application/x-yaml", :yaml, %w( text/yaml ), %w(yml yaml)

Mime::Type.register "multipart/form-data", :multipart_form
Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form

# https://www.ietf.org/rfc/rfc4627.txt
# http://www.json.org/JSONRequest.html
Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest )

Mime::Type.register "application/pdf", :pdf, [], %w(pdf)
Mime::Type.register "application/zip", :zip, [], %w(zip)
Mime::Type.register "application/gzip", :gzip, %w(application/x-gzip), %w(gz)

很明显,text/html是第一个格式,因此在本例中返回test.html.erb文件内容。

结论:Accept头是*/*时,返回respond_to代码块中的第一个格式。没有respond_to代码块时,按Mime格式注册顺序寻找对应文件返回。

3. Accept头包含*/*和其他内容

Accept的值为:

application/json, text/plain, */*

接口代码如下:

respond_to do |format|
  format.json { render json: { data: 'this is json' } }
  format.html { render html: '<p>this is html</p>'.html_safe }
end

暂时不考虑流程中的3、4,此时返回html,不会解析Accept头(原因在于valid_accept_header函数返回false,对浏览器的特殊处理),也不会受respond_to代码块顺序影响。

结论:Accept头包含*/*和其他内容,返回html。

文笔不好,内容又多,写的有点乱,大家见谅。

参考资料:

https://blog.bigbinary.com/2010/11/23/mime-type-resolution-in-rails.html

https://github.com/rails/rails/issues/9940

https://tools.ietf.org/html/rfc7231#section-5.3.1

https://tools.ietf.org/html/rfc7231#section-5.3.2

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,911评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,014评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,129评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,283评论 1 264
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,159评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,161评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,565评论 3 382
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,251评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,531评论 1 292
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,619评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,383评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,255评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,624评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,916评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,199评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,553评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,756评论 2 335

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,497评论 18 139
  • 前言 web到目前为止走过了1.0、2.0、移动互联网、本地应用化几个阶段,这使得js变得炙手可热,许多原来在se...
    白昔月阅读 1,747评论 3 6
  • 《我不是基督》 文/白传英 总以为遇见你 就可以和你在一起 来到这个世界上 也许就是为了找到你 只因为在...
    白清风阅读 92评论 0 0
  • 盼望已久的国际大赛今晚圆满成功!经过三次培训,老师的精心指点,总体走的很成功! 总体来说,yoyo水平挺高的,模特...
    静泽福淼阅读 203评论 0 0
  • 在我大二的一个晚上,先后接到了两个高中好友的电话,一个是小西,一个是阿杰。 先是小西的电话,说她不喜欢自己的财会专...
    颜小婧阅读 381评论 1 3