为 Discourse 开发一个 Onebox 插件(一)理解 Onebox gem

我们 openSUSE 中文论坛用的是 discourse,有一天给用户贴了一个 OBS 的链接,突然想到是不是可以让它也能像 github 一样有一个漂亮的预览小窗口 🤓 于是说干就干:discourse-openbuildservice-onebox 插件。

以下是教程,由于涉及到目前最大的 Ruby on Rails 程序 discourse,会分成几部分来讲解。第一部分我们来试着理解一下 discourse 出品的 onebox gem。

Ruby 作为一门脚本语言,所有对象的方法都可以被重写。学名叫做 Meta Programming。这是理解 onebox gem 的基础。

我们下面来看 discourse 是怎么使用 onebox gem 的,下面是 app/models/post_analyzer.rb 的 cook 函数,这个函数负责把你输入的文字转为 html 保存在 postgresql 数据库,是最基础的函数之一:

  def cook(raw, opts = {})
    [...]

    result = Oneboxer.apply(cooked) do |url|
      @onebox_urls << url
      if opts[:invalidate_oneboxes]
        Oneboxer.invalidate(url)
        InlineOneboxer.invalidate(url)
      end
      onebox = Oneboxer.cached_onebox(url)
      @found_oneboxes = true if onebox.present?
      onebox
    end

    cooked = result.to_html if result.changed?
    cooked
  end

可以看到 discourse 自己还有一个 Oneboxer 的 wrapper,这里使用了 Oneboxer.apply 和 Oneboxer.cache_onebox 函数。接下来我们接着看 lib/oneboxer.rb

  def self.apply(string_or_doc, extra_paths: nil)
    doc = string_or_doc
    doc = Nokogiri::HTML5::fragment(doc) if doc.is_a?(String)
    changed = false

    each_onebox_link(doc, extra_paths: extra_paths) do |url, element|
      onebox, _ = yield(url, element)

      if onebox
        parsed_onebox = Nokogiri::HTML5::fragment(onebox)
        next unless parsed_onebox.children.count > 0

        if element&.parent&.node_name&.downcase == "p" &&
           element.parent.children.count == 1 &&
           HTML5_BLOCK_ELEMENTS.include?(parsed_onebox.children[0].node_name.downcase)
          element = element.parent
        end

        changed = true
        element.swap parsed_onebox.to_html
      end
    end

    # strip empty <p> elements
    doc.css("p").each do |p|
      if p.children.empty? && doc.children.count > 1
        p.remove
      end
    end

    Result.new(doc, changed)
  end

这段其实没啥用,简单解释下就是取到 cooked 这个 html 里需要 onebox 化的 URL node。然后 invalidate 之后取 cached_onebox。cached_onebox 与实际进行 onebox 化里面有太多的判断代码(onebox_raw),我们只需要关注 Oneboxer 的这个函数就够了:

  def self.external_onebox(url)
    Discourse.cache.fetch(onebox_cache_key(url), expires_in: 1.day) do
      fd = FinalDestination.new(url,
                                ignore_redirects: ignore_redirects,
                                ignore_hostnames: blocked_domains,
                                force_get_hosts: force_get_hosts,
                                force_custom_user_agent_hosts: force_custom_user_agent_hosts,
                                preserve_fragment_url_hosts: preserve_fragment_url_hosts)
      uri = fd.resolve

      if fd.status != :resolved
        args = { link: url }
        if fd.status == :invalid_address
          args[:error_message] = I18n.t("errors.onebox.invalid_address", hostname: fd.hostname)
        elsif fd.status_code
          args[:error_message] = I18n.t("errors.onebox.error_response", status_code: fd.status_code)
        end

        error_box = blank_onebox
        error_box[:preview] = preview_error_onebox(args)
        return error_box
      end

      return blank_onebox if uri.blank? || blocked_domains.map { |hostname| uri.hostname.match?(hostname) }.any?

      options = {
        max_width: 695,
        sanitize_config: Onebox::DiscourseOneboxSanitizeConfig::Config::DISCOURSE_ONEBOX,
        allowed_iframe_origins: allowed_iframe_origins,
        hostname: GlobalSetting.hostname,
        facebook_app_access_token: SiteSetting.facebook_app_access_token,
      }

      options[:cookie] = fd.cookie if fd.cookie

      r = Onebox.preview(uri.to_s, options)
      result = { onebox: r.to_s, preview: r&.placeholder_html.to_s }

      # NOTE: Call r.errors after calling placeholder_html
      if r.errors.any?
        missing_attributes = r.errors.keys.map(&:to_s).sort.join(I18n.t("word_connector.comma"))
        error_message = I18n.t("errors.onebox.missing_data", missing_attributes: missing_attributes, count: r.errors.keys.size)
        args = r.data.merge(error_message: error_message)

        if result[:preview].blank?
          result[:preview] = preview_error_onebox(args)
        else
          doc = Nokogiri::HTML5::fragment(result[:preview])
          aside = doc.at('aside')

          if aside
            # Add an error message to the preview that was returned
            error_fragment = preview_error_onebox_fragment(args)
            aside.add_child(error_fragment)
            result[:preview] = doc.to_html
          end
        end
      end

      result
    end
  end

这里可以看到:

    r = Onebox.preview(uri.to_s, options)
    result = { onebox: r.to_s, preview: r&.placeholder_html.to_s }

这段是最主要的,也就是说 Onebox 这个 gem 是通过它的 preview 函数来与 discourse 进行交互的。discourse 代码看到这里就够了,下面我们来看 onebox 代码。

一个 rubygem 最重要的是它的 lib 文件夹。我们先从 lib/onebox.rb 看起:

  def self.preview(url, options = Onebox.options)
    # onebox does not have native caching
    unless Onebox::Helpers.blank?(options[:cache])
      warn "Onebox no longer has inbuilt caching so `cache` option will be ignored."
    end

    Preview.new(url, options)
  end

这就是上面的 Onebox.preview 的来源。最重要的是 Preview 这个 class。下面我们接着看 lib/onebox/preview.rb

module Onebox
  class Preview

    # see https://bugs.ruby-lang.org/issues/14688
    client_exception = defined?(Net::HTTPClientException) ? Net::HTTPClientException : Net::HTTPServerException
    WEB_EXCEPTIONS ||= [client_exception, OpenURI::HTTPError, Timeout::Error, Net::HTTPError, Errno::ECONNREFUSED]

    def initialize(link, options = Onebox.options)
      @url = link
      @options = options.dup

      allowed_origins = @options[:allowed_iframe_origins] || Onebox::Engine.all_iframe_origins
      @options[:allowed_iframe_regexes] = Engine.origins_to_regexes(allowed_origins)

      @engine_class = Matcher.new(@url, @options).oneboxed
    end
  end
end

可以看到,这段代码的意思是说根据 Matcher 来匹配 URL 从而取 oneboxed 的结果。接着看 lib/onebox/matcher.rb

module Onebox
  class Matcher
    def initialize(link, options = {})
      @url = link
      @options = options
    end

    def ordered_engines
      @ordered_engines ||= Engine.engines.sort_by do |e|
        e.respond_to?(:priority) ? e.priority : 100
      end
    end

    def oneboxed
      uri = URI(@url)
      return unless uri.port.nil? || Onebox.options.allowed_ports.include?(uri.port)
      return unless uri.scheme.nil? || Onebox.options.allowed_schemes.include?(uri.scheme)
      ordered_engines.find { |engine| engine === uri && has_allowed_iframe_origins?(engine) }
    rescue URI::InvalidURIError
      nil
    end
  end
end

这段代码的意思是从 ordered_engines 里找到匹配 uri 的 engine。返回这个 engine。而 ordered_engines 来自 Engine.engines。我们来看最重要的 lib/onebox/engine.rb

module Onebox
  module Engine
    def self.included(object)
      object.extend(ClassMethods)
    end

    def self.engines
      constants.select do |constant|
        constant.to_s =~ /Onebox$/
      end.map(&method(:const_get))
    end
  end
end

require_relative "helpers"
require_relative "layout_support"
require_relative "file_type_finder"
require_relative "engine/standard_embed"
require_relative "engine/html"
require_relative "engine/json"
require_relative "engine/amazon_onebox"
require_relative "engine/github_issue_onebox"
require_relative "engine/github_blob_onebox"
[...]

这里的 included 函数其实是一个被重写的内置函数。它是 ruby 的 module 函数,意思是这个 module 被 included 到别的 Object 里的时候会做什么。也就是说 module Engine 这个模块被别的 Class/Module 这样 include 的时候会发生什么:

module A
    include Engine
end

这里所有 include 了 module Engine 的 Class 都会自动获得 ClassMethods 的类方法,ClassMethods 模块在后面一点的地方:

    module ClassMethods
      def ===(other)
        if other.kind_of?(URI)
          !!(other.to_s =~ class_variable_get(:@@matcher))
        else
          super
        end
      end

      def priority
        100
      end

      def matches_regexp(r)
        class_variable_set :@@matcher, r
      end

      def requires_iframe_origins(*origins)
        class_variable_set :@@iframe_origins, origins
      end

      def iframe_origins
        class_variable_defined?(:@@iframe_origins) ? class_variable_get(:@@iframe_origins) : []
      end

      # calculates a name for onebox using the class name of engine
      def onebox_name
        name.split("::").last.downcase.gsub(/onebox/, "")
      end

      def always_https
        @https = true
      end

      def always_https?
        @https
      end
    end

举个例子:

module Onebox
  module Engine
    class YoukuOnebox
      include Engine
      include HTML

      matches_regexp(/^(https?:\/\/)?([\da-z\.-]+)(youku.com\/)(.)+\/?$/)
      requires_iframe_origins "https://player.youku.com"

这是 lib/onebox/engine/youku_onebox.rb。因为 include Engine,所以自动获得 matches_regexp 这个类方法,所以可以调用:

matches_regexp(/^(https?:\/\/)?([\da-z\.-]+)(youku.com\/)(.)+\/?$/)

来将 YoukuOnebox 这个 Class 的类全局变量 @@matcher 设置为上面的 regexp。通过这个例子我们应该大概可以明白为什么能够刚定义一个新类就拥有那么多的类方法了。同样功效的还有 include HTMLinclude JSON等等。下一篇用的的时候再给大家看代码。

下面来看第二个方法:

    def self.engines
      constants.select do |constant|
        constant.to_s =~ /Onebox$/
      end.map(&method(:const_get))
    end

很多新手这时候就无法理解了。constants 是什么?它其实 ruby 的一个 Module 方法。返回这个 module 的所有常量,定义在 Module 内部的 Class 也是常量,给段测试代码:

module A
  class B
  end
end

module C
 include A
 def self.test
   p constants
 end
end

C.test

上面运行 C.test 的结果是 [:B]。类名 B 是 C 的常量。那么 self.engines 这个函数其实就好理解了。从 Engine module 的全部类中找到以 Onebox 结尾的,并且返回一个字符串数组。上面 Youku 的那个例子我们已经看到了,所有的自定义 Engine 都是这个结构:

module Onebox
  module Engine
    class YoukuOnebox

目的就是让 self.engines 函数能够知道它的存在。

我们接着往下看:

    def initialize(link, timeout = nil)
      @errors = {}
      @options = DEFAULT
      class_name = self.class.name.split("::").last.to_s

      # Set the engine options extracted from global options.
      self.options = Onebox.options[class_name] || {}

      @url = link
      @uri = URI(link)
      if always_https?
        @uri.scheme = 'https'
        @url = @uri.to_s
      end
      @timeout = timeout || Onebox.options.timeout
    end

是不是很有意思,一个 Module 居然有 initialize 函数!他的作用是,include 它的 class 的 new instance 生成的时候,如果这个 class 本身没有写 initialize 函数,那么运行 module 带的。要是写了,在 class 的 initialize 函数里可以运行 super 来运行 module 带的。我们看 engine 文件夹里所有的自定义引擎都没有写自己的 initialize 函数,就是因为这里写了。

这里定义了一些实例变量,这些变量是写自定义引擎的时候可以直接用的。

接着往下看:

    # raises error if not defined in onebox engine.
    # This is the output method for an engine.
    def to_html
      fail NoMethodError, "Engines need to implement this method"
    end

这种函数看起来很奇怪对不对?直接抛错误。因为 Ruby 是可以重写方法的。上面 YoukuOnebox 的例子里:

      def to_html
        <<~HTML
          <iframe src="https://player.youku.com/embed/#{video_id}"
                  width="640"
                  height="430"
                  frameborder='0'
                  allowfullscreen>
          </iframe>
        HTML
      end

就重写了这个方法。这样 Engine module 调用方法的时候实际上是不空的。下面我们来捋一下 Onebox.preview(url, options) 的流程:

Onebox.preview 调用Preview.new(url,options),后者调用 Matcher.new(@url, @options).oneboxed, Matcher 是获取全部自定义引擎后通过判断 engine == uri来找出对应引擎。

上面的理解了我们就可以接着理解 engine == uri 了。我们前面已经说了,所有的自定义引擎都 include Engine了,得到了ClassMethods的类方法,有一个类方法就是这个:

      def ===(other)
        if other.kind_of?(URI)
          !!(other.to_s =~ class_variable_get(:@@matcher))
        else
          super
        end
      end

如果是拿 URI 来判断的话,先把它转成字符串,然后与自定义引擎的类变量 @@matcher 做正则匹配,就能够确保引擎的唯一性。

上面也说了,Onebox.preview 最终得到的结果是 Engine module 里面的一个自定义 Class。那么 discourse 是怎么使用这个 engine 的呢?

    r = Onebox.preview(uri.to_s, options)
    result = { onebox: r.to_s, preview: r&.placeholder_html.to_s }

我们再把 discourse 的这段代码拿回来理解。它定义了一个 hash。其中 onebox 是这个自定义 class 的 to_s,引擎怎么转字符串呢?在 lib/onebox/preview.rb 里面:

    def to_s
      return "" unless engine
      sanitize process_html engine_html
    rescue *WEB_EXCEPTIONS
      ""
    end

对我们用处不大,略。那么 preview 是什么呢?是调用了引擎的 placeholder_html 方法,这个方法在 engine.rb 里面。

    def placeholder_html
      to_html
    end

它调用了默认抛错误的那个 to_html 方法,而当有了确定的引擎后,to_html 方法是自定义引擎实现的。也就是说,想要写一个自定义引擎,最最重要的方法是 matches_regexpto_html

对于 Onebox gem 的理解到这里就可以了。下一篇我们将要真正开始写我们的插件。