Pull to refresh

Интеграция Ruby в Nginx

Reading time 6 min
Views 14K


Уже достаточно давно существует всем известная связка Nginx + Lua, в том числе здесь был ряд статей. Но время не стоит на месте. Примерно год назад появилась первая версия модуля, интегрирующего Ruby в Nginx.

MRuby


Для интеграции был выбран не полноценный Ruby, а его подмножество, которое предназначено для встраивания в другие приложения, устройства и тд. Имеет некоторые ограничения, но в остальном полноценный Ruby. Проект называется MRuby. На текущий момент имеет уже версию 1.0.0, т.е. считается стабильным.
MRuby не позволяет подключать другие файлы во время выполнения, поэтому вся программа должна быть в одном файле. При этом есть возможность преобразовать программу в байткод и выполнять уже его, что положительно сказывается на производительности.
Т.к. нет возможности подгружать другие файлы, то и существующие gem-ы не подходят для него. Для расширения функционала используется свой формат, который представляет из себя как C код, так и Ruby местами. Данные модули собираются вместе с самой библиотекой во время компиляции и являются ее неотъемлемой частью. Имеются биндинги к различным базам данных, для работы с файлами, сетью и так далее. Полный список доступен на сайте.
Также там имеется модуль, позволяющий интегрировать данный движок в Nginx, который особенно заинтересовал.

ngx_mruby


Итак, знакомьтесь: ngx_mruby. Модуль для подключения ruby скриптов к nginx. Имеет схожий функционал с Lua версией. Позволяет выполнять операции на различных этапах обработки запроса.

Модуль собирается довольно просто, на сайте есть подробная инструкция. Кто не хочет заморачиваться со сборкой, могут скачать готовый пакет:
http://mruby.ajieks.ru/st/nginx_1.4.4-1~mruby~precise_amd64.deb
MRuby в данной сборке содержит следующие дополнительные модули:

Как видите, есть почти все необходимое для работы. Единственное, что не обнаружил в API данного модуля, это возможности делать запрос наружу. Скорее всего, его нужно будет реализовать как расширение и сделать обвязку вокруг nginx API.

Автор показывает красивый график с тестами, но конфигурации окружения так и не нашел. Поэтому просто приложу его для красоты:
image

Попробуем использовать


Итак, сервер у нас уже установлен. Все функционирует, статика отдается. Добавим немного к этому динамики.
В качестве примера я выбрал задачу по парсингу Markdown разметки и отдачи ее в HTML без дополнительного серверного приложения. А также нумерации строк в исходниках на Ruby.
Для этого сделан клон репозитория sinatra и настроен nginx для решения поставленной задачи.

Markdown

Для обработки разметки воспользуемся подключенным в сборку модулем mruby-discount. Он предоставляет простой класс для работы с разметкой. В основе лежит одноименная библиотека на C, потому вопрос производительности, думаю, особо стоять не будет.
Для начала напишем программу, которая будет считывать запрошенный файл с диска, обрабатывать его и отдавать пользователю.
r = Nginx::Request.new

m = Discount.new("/st/style.css", "README")

filename = r.filename
filename = File.join(filename, 'README.md') if filename.end_with?('/')

markdown = File.exists?(filename) ? File.read(filename) : ''
Nginx.rputs m.header
Nginx.rputs m.md2html(markdown)
Nginx.rputs m.footer

Первой строкой получаем экземпляр объекта запроса, содержащий всю необходимую информацию, включая запрошенный файл, заголовки, URL, URI и т.д.
Следующей строкой создаем экземпляр класса Discount, указывая файл стиля и заголовк страницы.
Данный код не делает обработку 404 ошибки, поэтому даже если файла нету, всегда будет 200 код возврата.
Подключаем теперь все это
    location ~ \.md$ {
        add_header Content-Type text/html;
        mruby_content_handler "/opt/app/parse_md.rb" cache;
    }

Результат:
mruby.ajieks.ru/sinatra
mruby.ajieks.ru/sinatra/README.ru.md

Файлы Ruby

Первоначально планировал сделать не просто нумерацию, а так же раскраску кода, используя когда-то написанный код https://github.com/fuCtor/chalks. Однако после всех произведенных адаптаций в его работе возникли проблемы. Код, вроде, работал, но на определенном этапе падал с Segmentation fault. Первоначальное подозрение было на нехватку памяти выделяемой, но даже после уменьшения ее потребление проблема не пропала. После удаления кода, связанного с раскраской, все заработало, но не так красиво, как хотелось.
Результат изменений
module CGI
TABLE_FOR_ESCAPE_HTML__ = {"&"=>"&", '"'=>""", "<"=>"<", ">"=>">"}
def self.escapeHTML(string)
  string.gsub(/[&\"<>]/) do |ch|
  TABLE_FOR_ESCAPE_HTML__[ch]
  end
end
end

class String
  def ord
    self.bytes[0]
  end
end

class Chalk

  COMMENT_START_CHARS = {
      ruby: /#./,
      cpp: /\/\*|\/\//,
      c: /\/\//
  }
  COMMENT_END_CHARS = {
      cpp: /\*\/|.\n/,
      ruby: /.\n/,
      c: /.\n/,
  }

  STRING_SEP = %w(' ")
  SEPARATORS = " @(){}[],.:;\"\'`<>=+-*/\t\n\\?|&#"
  SEPARATORS_RX = /[@\(\)\{\}\[\],\.\:;"'`\<\>=\+\-\*\/\t\n\\\?\|\&#]/

  def initialize(file)
    @filename = file
    @file = File.new(file)
    @rnd = Random.new(file.hash)
    @tokens = {}
    reset
  end

  def parse &block
    reset()

    @file.read.each_char do |char|
      @last_couple = ((@last_couple.size < 2) ? @last_couple : @last_couple[1]) + char

      case(@state)
        when :source
          if start_comment?(@last_couple)
            @state = :comment
          elsif STRING_SEP.include?(char)
              @string_started_with = char
              @state = :string
          else
            process_entity(&block) if (@entity.length == 1 && SEPARATORS.index(@entity))  || SEPARATORS.index(char)
          end

        when :comment
          process_entity(:source, &block) if end_comment?(@last_couple)

        when :string
          if (STRING_SEP.include?(char) && @string_started_with == char)
            @entity += char
            process_entity(:source, &block)
            char = ''
          elsif char == '\\'
            @state = :escaped_char
          else
          end
        when :escaped_char
          @state = :string
      end
      @entity += char
    end
  end

  def to_html(&block)
    html = ''
    if block
      block.call( '<table><tr><td><pre>' )
    else
      html = '<table><tr><td><pre>'
    end
    line_n = 1
    @file.readlines.each do
      if block
        block.call( "<a href='#'><b>#{line_n}</b></a>\n" )
      else
        html += "<a href='#'><b>#{line_n}</b></a>\n"
      end
      line_n += 1
    end

    @file = File.open(@filename)
    if block
      block.call( '</pre></td><td><pre>' )
    else
      html += '</pre></td><td><pre>'
    end
    parse do |entity, type|
      entity = entity.gsub("\t", '  ')
      if block
        block.call( entity )
        #block.call(highlight( entity , type))
      else
        html += entity
        #html += highlight( entity , type)
      end
    end

    if block
      block.call( '</pre><td></tr></table>' )
    else
      html + '</pre><td></tr></table>'
    end
  end

  def language
    @language ||= case(@file.path.to_s.split('.').last.to_sym)
      when :rb
        :ruby
      when :cpp, :hpp
        :cpp
      when  :c, :h
        :c
      when :py
        :python
      else
        @file.path.to_s.split('.').last.to_s
    end
  end

  private


  def process_entity(new_state = nil, &block)
    block.call @entity, @state if block
    @entity = ''
    @state = new_state if new_state
  end

  def reset
    @file = File.open(@filename) if @file
    @state = :source
    @string_started_with = ''
    @entity = ''
    @last_couple = ''
  end

  def color(entity)
    entity = entity.strip

    entity.gsub! SEPARATORS_RX, ''

    token = ''
    return token if entity.empty?
    #return token if token = @tokens[entity]

    return '' if entity[0].ord >= 128

    rgb = [ @rnd.rand(150) + 100, @rnd.rand(150) + 100, @rnd.rand(150) + 100 ]

    token = String.sprintf("#%02X%02X%02X", rgb[0], rgb[1], rgb[2])
    #token = "#%02X%02X%02X" % rgb
    #@tokens[entity] = token
    return token
  end

  def highlight(entity, type)
    esc_entity = CGI.escapeHTML( entity )
    case type
      when :string, :comment
        "<span class='#{type}'>#{esc_entity}</span>"
      else

        rgb = color(entity)
        if rgb.empty?
          esc_entity
        else
          "<span rel='t#{rgb.hash}' style='color: #{rgb}' >#{esc_entity}</span>"
        end

    end
  end

  def start_comment?(char)
    rx = COMMENT_START_CHARS[language]
    char.match rx if rx
  end

  def end_comment?(char)
    rx = COMMENT_END_CHARS[language]
    char.match rx if rx
  end
end


И собственно код, который выполняет чтение файла и нумерацию:
r = Nginx::Request.new

Nginx.rputs '<html><link rel="stylesheet" href="/st/code.css" type="text/css" /><body>'
begin
    ch = Chalk.new(r.filename)
    data = ch.to_html
    Nginx.rputs data

rescue => e
  Nginx.rputs e.message
end

Nginx.rputs '</body></html>'

Подключаем все. Т.к. класс Chalk используется постоянно, подгрузим его заранее:
mruby_init '/opt/app/init.rb';
Данная строка добавляется перед server секцией в настройках. Далее уже указываем наш обработчик:
  location ~ \.rb$ {
        add_header Content-Type text/html;
        mruby_content_handler "/opt/app/parse_code.rb" cache;
   }

Все, теперь можно посмотреть на результат: mruby.ajieks.ru/sinatra/lib/sinatra/main.rb

Заключение


Таким образом, возможно реализовать расширенную обработку запросов, фильтрацию, кэширование, используя еще один из языков. Готов ли данный модуль для использования в боевых условиях, не знаю. Пока тестировал, бывали зависания всего сервера, но есть вероятность кривизны рук, либо все же не все до конца доработано. Буду следить за развитием проекта.

Желающие могут погонять на производительность указанные в статье скрипты по ссылкам выше.
Сервер развернут на DigitalOcean на самой простой машине, Ubuntu 12.04 x64. Количество процессов 2, подключений 1024. Никаких дополнительных настроек не делалось. На случай зависания сервера поставил перезагрузку nginx каждые 10 минут.
Tags:
Hubs:
+11
Comments 28
Comments Comments 28

Articles