建立一个基于Ractor的记录器,可以与非Ractor兼容的代码一起工作

79 阅读3分钟

如果这段代码不工作,Rails怎么可能工作?Ractor似乎从根本上与许多大量使用的Rails API不兼容。

require 'logger'

class Rails
  def self.logger
    @logger ||= Logger.new(STDOUT)
  end
end

Ractor.new do
  Rails.logger.info "Hello"
end.take

周末,我在Diffend.io中增加了对Ractors的支持,这是一个为Ruby和Rails提供的OSS供应链安全和管理的免费平台,所以我对这个话题比较新鲜。Mike的代码说明了开发者在使其代码与Ractors兼容时将面临的一个问题。

当你试图运行它时,你将会遇到一个异常。

terminated with exception (report_on_exception is true):
`take': thrown by remote Ractor. (Ractor::RemoteError)
`logger': can not access instance variables of classes/modules
  from non-main Ractors (RuntimeError)

有没有什么方法可以保留Rails#logger API,并允许它从我们想要的任何Ractor中使用?

有!有!有

所以,让我们先解释一下为什么这段代码不能工作。

  def self.logger
    @logger ||= Logger.new(STDOUT)
  end

这段代码实际上有两个问题,尽管只有一个是立即可见的。

  1. 你不能从主处理器以外的Ractors访问可共享对象的实例变量。
  2. 你不能从非主Ractor访问STDOUT(至少不是这样)。

好消息是在字里行间说的:虽然我们不能使用可共享对象,也不能引用实例变量,但我们可以保留Rails.logger API!

class Rails
  def self.logger
    rand
  end
end

Ractor.new do
  Rails.logger
end.take.then { p _1 }

#=> 0.06450369439220172

但是我们想共享一个记录器,对吗?嗯,不完全是。我们想要的是能够使用相同的API来记录一些信息。而这正是这里的关键点。

我们可以迅速绕过所有的问题。我们只需要一个单独的Ractor,它将为我们的应用程序运行所有的日志记录,并与标准的记录器兼容的API。

我们需要什么来实现这一点呢?不多。我们需要。

  1. 创建一个Ractor,该Ractor将拥有唯一的应用范围内的记录器。
  2. 创建用于记录的API。
  3. 将Ractor连接到Rails#logger 接口。

这一切都可以通过几行代码实现。

class Rogger < Ractor
  def self.new
    super do
      # STDOUT cannot be referenced but $stdout can
      logger = ::Logger.new($stdout)

      # Run the requested operations on our logger instance
      while data = recv
        logger.public_send(data[0], *data[1])
      end
    end
  end
 
  # Really cheap logger API :)
  def method_missing(m, *args, &_block)
    self << [m, *args]
  end
end

class Rails
  LOGGER = Rogger.new

  def self.logger
    LOGGER
  end
end

Ractor.new do
  Rails.logger.info "Hello"
end

而当我们运行它时,我们最终会遇到不同的挑战。

terminated with exception (report_on_exception is true):
ruby/3.0.0/logger/formatter.rb:15:in `call': can not access global variables $$ from non-main Ractors (RuntimeError)
  from ruby/3.0.0/logger.rb:586:in `format_message'
  from ruby/3.0.0/logger.rb:476:in `add'
  from ruby/3.0.0/logger.rb:529:in `info'
  from test.rb:23:in `public_send'
  from test.rb:23:in `block in new'

更新:我下面说的那个拉动请求已经被合并了,所以这个猴子补丁不再需要了。

事实证明,Ruby的defaulf日志格式并不适合Ractor。我已经开启了修复这个问题的拉动请求,所以一旦合并,基本的Ruby日志格式就可以正常工作了。目前,我们将给它打上猴子补丁。

class Logger::Formatter
  def call(severity, time, progname, msg)
    Format % [
      severity[0..0],
      format_datetime(time),
      Process.pid,
      severity,
      progname,
      msg2str(msg)
    ]
  end
end

有了这个,我们可以从任何我们想要的ractor中运行我们的日志。

require 'logger'

class Logger::Formatter
  def call(severity, time, progname, msg)
    Format % [
      severity[0..0],
      format_datetime(time),
      Process.pid,
      severity,
      progname,
      msg2str(msg)
    ]
  end
end

class Rogger < Ractor
  def self.new
    super do
      logger = ::Logger.new($stdout)

      while data = recv
        logger.public_send(data[0], *data[1])
      end
    end
  end

  def method_missing(m, *args, &_block)
    self << [m, *args]
  end
end

class Rails
  LOGGER = Rogger.new

  def self.logger
    LOGGER
  end
end

Ractor.new do
  Rails.logger.info "Hello"
end

sleep(1)

ruby test.rb

I, [2020-09-28T18:23:56.181512 #11519]  INFO -- : Hello

总结

在Rails等事物中提供Ractor支持并不容易。有许多挑战需要解决,但同时,我认为这是一个利用Ruby新功能的绝佳机会。这也是一个摆脱Ruby和Rails中的反模式的好机会,从我有记忆以来,这些反模式就一直存在。由于Ractors的存在,有一个全新的工程世界将更容易实现。

今年,我还想探索用Ruby VM运行同质化的Docker容器的可能性,在其中我可以对运行在特定公会中的服务进行负载平衡。理论上说,这可以允许在亚秒级缓解突然的流量高峰,而不需要有许多过度配置的实例。