Mike Logaciuk

Rails'y to nie wszystko

12 Dec 2023

Header

Ruby

Ruby! - język, który po dziś dzień ma tyle samo fanów - co wrogów.

Kochany za ekspresywność, z przypominającą język angielski składnią, bogatym ekosystemem bibliotek i framework’ów (gem’y), ale do niedawna dosyć mocno atakowany ze względu na jego wydajność i przypadłość w postaci Global Interpreter Lock’a (tak samo jak i Python zresztą).

Zapoczątkowany przez Yukihiro ‘Matza’ Matsumoto w drugiej połowie lat 90’tych język, przeszedł długą drogę.

Ostatnie wydania z serii 3.x wprowadziły zmiany, które rzucają pozytywne światło na przyszłość tego języka. Począwszy od fiber'ów, które można nazwać samurajską wersją gouritines z Go po ractory.

Fiber’y są podobne do wątków z tą różnicą, że nie są kontrolowane przez system operacyjny, a przez interpreter - dzięki czemu w teorii zyskujemy na wydajności ze względu na mniejsze przełączanie kontekstu. Minusem jest fakt, że musimy użyć schedulera… Natomiast ractor’y, tak jak fibery, są podobne do wątków - z tym, że ractory mają własny GIL.

Wraz z tymi zmianami doszedł jeszcze jeden element, od wersji 3.2: YJIT tj Yet Another Ruby JIT, który znacząco zwiększył wydajność aplikacji pisanych w Ruby (Shopify przepisany z C99 do Rust’a #1 #2).

W wersji 3.3.0 dostał jeszcze większego kopa w wydajności, która w moim przypadku zachęciła mnie do zrezygnowania z TruffleRuby w maszynie wirtualnej GraalVM (community) na rzecz właśnie YJIT w jednym moich produkcyjnych API.

No dobra, a co z tymi Railsami?

Ruby on Rails to najbardziej znany framework napisany w języku Ruby używany do web development’u zarówno przez fullstack’ów jak i back-end’owców. Framework zapoczątkowany przez David’a Heinemeier’a Hansson’a, do dziś uznawany wzór w kwestii tworzenia framework’ów. Historię Railsów możecie poznać dzięki świetnemu dokumentowi od Honeypot.

Wykorzystywany min. przez Shopify, Airbnb, Dribble czy Twitch‘a a także Github‘a i niezliczoną ilość MVP/PoC’ów, które tylko początkowo miały być pisane w Ruby…

troll

Niemniej jednak z Railsami jest jeden problem: przyćmiewa wszystko w ekosystemie Ruby. W tym sensie, że jeżeli jest jakaś wzmianka o nauce Ruby to za każdym razem pojawia się w niej słowo Rails.

Ba! Czasami pojawiają się artykuły, które sugerują, że Rails’y to jakiś tajemniczy superset Ruby (a nim oczywiście nie jest). No, ale takie artykuły z reguły są pisane przez bot’y z Medium’a w celu nabicia postów (sorry chłopaki).

To co po za Railsami?

I tu zaczyna się jazda bez trzymanki, bo o ile uznamy, że obchodzi Nas tylko back-end to w sumie lista takich framework’ów i microframework’ów kończy się na 15 pozycjach (zależnie od faktu jak bardzo batteries included ma ów framework być).

Oczywiście do takich naj-najbardziej znanych zaliczyć możemy takie framework’i jak:

  • Sinatra
  • Padrino (Sinatra na sterydach)
  • Hanami
  • Cuba
  • Roda
  • czy Grape.

Z pomocą ActiveRecord’u lub Sequel’a oraz bcrypt’u jesteśmy w stanie naprawdę małym nakładem sił, napisać w pełni działający back-end.

W tym celu możemy użyć kodu Sinatry i go uruchomić:

require 'sinatra'
require 'sequel'
require 'json'
require 'sqlite3'

begin
  LOCAL_DB = Sequel.connect('sqlite://db/database.db')
rescue Exception => e
  puts "Database not found"
ensure
  %x[mkdir -p db]
  LOCAL_DB = Sequel.connect('sqlite://db/database.db')
end

LOCAL_DB.create_table? :users do
  primary_key :id
  String :name
  String :email
  String :password
  String :salt
  String :token
  String :role
  DateTime :created_at
  DateTime :updated_at
end

get '/' do
  'Hello world!'
end

get '/users' do
  users = LOCAL_DB[:users]
  users.all.to_json
end

post '/users' do
  users = LOCAL_DB[:users]
  user = users.insert(:name => params[:name],
                      :email => params[:email],
                      :password => params[:password],
                      :salt => params[:salt],
                      :token => params[:token],
                      :role => params[:role],
                      :created_at => Time.now,
                      :updated_at => Time.now)
  user.to_json
end

get '/users/:id' do
  users = LOCAL_DB[:users]
  user = users.where(:id => params[:id])
  user.all.to_json
end

error 401 do
  status 401
  content_type :json
  { message: "You need to login in with a proper authentication key in order to use the API." }.to_json
end

error 403 do
  status 403
  content_type :json
  { message: "Access forbidden" }.to_json
end

error 404 do
  status 404
  content_type :json
  { message: "Endpoint not found" }.to_json
end

Nie, to nie żart. To API naprawdę będzie działać, mimo że wygląda jak pseudokod - to wcale nim nie jest. W połączeniu z szeroką gamą gem'ów, świat lekkiego developmentu staje przed Nami otworem.

Może maila?

Potrzebujesz wysłać maila? Nic bardziej trudnego:

title = "Some dummy title"
message = """
    <html>
    <head></head>
      <body>
        <p>
        Lorem ipsum....
        </p>
      </body>
    </html>
"""

Pony.mail(:to => 'john.doe@gmail.com',
          :from => 'author@domain.xd',
          :cc => 'cc@domain.xd',
          :bcc => 'bcc@domain.xd',
          :subject => title,
          :html_body => message,
          :body => raw,
          :via => :smtp,
          :via_options => {
            :address              => config[:mail_host],
            :port                 => config[:mail_port],
            :enable_starttls_auto => true,
            :user_name            => config[:mail_user],
            :password             => config[:mail_password],
            :authentication       => :login,
            :domain               => "domain.xd"
          })

Potrzebujesz scheduler’a?

Użyj whenever albo sidekiq’a:

every 3.hours do
  rake "database:indexes:rebuild"
end

every 1.day, at: ['3:00 am', '8:00 pm'] do
  command "echo $DOMAIN_PWD | kinit $DOMAIN_USR@COMPANY.LOCAL"
end

Testy

Pisanie testów w Ruby może być równie przyjemne co kodowanie:

RSpec.describe Receipt do
  it "sums the prices of receipt line items" do
    transaction = Receipt.new
    transaction.add_position(LineItem.new(:item => Item.new(
      :product => Product.new("FTX998213"),
      :price => Payment.new(99.99, :PLN)
    )))
    transaction.add_position(LineItem.new(:item => Item.new(
      :product => Product.new("XF92131P"),
      :price => Payment.new(666.00, :PLN),
      :quantity => 2
    )))
    expect(transaction.total).to eq(Money.new(1431.99, :PLN))
  end
end

Maszyny wirtualne w formie infrastruktury jako kodu

W przypadku chęci uruchomienia lokalnej maszyny wirtualnej, np. z Ubuntu na potrzeby developerskie, nie musimy spędzać roku na setupie i przygotowaniu maszyn. Wystarczy Vagrant i kilka chwil nad kodem:

# -*- mode: ruby -*-
# vi: set ft=ruby :
# you're doing.
Vagrant.configure("2") do |config|
  config.vm.define "ubuntu" do |machine|
    machine.vm.box = "bento/ubuntu-22.04"
    machine.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1"
    machine.vm.network "forwarded_port", guest: 443, host: 8443, host_ip: "127.0.0.1"

    machine.vm.synced_folder ".", "/vagrant", type: "nfs", nfs_version: 4, nfs_udp: false

    machine.vm.provider :libvirt do |libvirt|
      libvirt.driver = "kvm"
      libvirt.cpu_model = "EPYC-Rome"
      libvirt.cpus = 2
      libvirt.memory = 2048
      libvirt.storage_pool_name = "kvm"
      libvirt.storage :file, :size => '20G'
    end

    machine.vm.provision "shell", inline: <<-SHELL
    apt-get update
    apt-get install apt-transport-https ca-certificates btop htop git unzip wget curl build-essential gcc -y
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
    export PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}"
  SHELL
  end
end
vagrant up && vagrant ssh

CLI

A co w przypadku gdy chcemy napisać CLI np. takie do wykonywania prostych zapytań na bazie SQL?

Możemy do tego użyć kombinacji w postaci dry/cli oraz sequel’a i table_print’u:

#!/usr/bin/env ruby
require 'bundler/setup'
require 'dry/cli'
require 'sequel'
require 'json'
require 'yaml'
require 'tiny_tds'

module Valctl
  module CLI
    module Commands
      extend Dry::CLI::Registry

      class Version < Dry::CLI::Command
        desc 'Print version'

        def call(*)
          puts '0.6.0'
        end
      end

      class Get < Dry::CLI::Command
        desc 'Query the database'

        argument :server, required: true, desc: 'The server to connect to'
        argument :database, required: true, desc: 'The database to connect to'
        argument :query, required: true, desc: 'The query to run'
        option :format, default: 'table', values: %w[json table], desc: 'The output format'

        def call(server:, database:, query:, format: 'table', **)
          db = Sequel.connect(
            adapter: 'tinytds',
            host: server,
            database:,
            user: '',
            password: ''
          )

          forbidden = %w[delete drop truncate update]

          if forbidden.any? { |word| query.downcase.include?(word) }
            (puts 'Error: Forbidden query')
          else
            (
              begin
                result = []

                db[query].all do |row|
                  row.each do |key, value|
                    row[key] = value.to_f if value.is_a?(BigDecimal)
                  end

                  result << row
                end

                db.disconnect

                case format
                when 'json'
                  puts JSON.pretty_generate(result)
                when 'table'
                  require 'table_print'
                  tp result
                end
              rescue StandardError => e
                puts "Error: #{e.message}"

                db.disconnect
              ensure
                db.disconnect
              end)

          end
        end
      end

      register 'version', Version, aliases: %w[v -v --version]
      register 'get', Get, aliases: %w[q -g --get]
    end
  end
end

Dry::CLI.new(Valctl::CLI::Commands).call
$ ./valctl get 'dwh.company.local' 'staging' 'SELECT TOP 5 store, sale_id, sale_date FROM dbo.Sales'

STORE  | SALE_DATE  | SALE_ID
-------|------------|-------------
91094  | 2013-10-14 | 709391160738
188343 | 2014-11-14 | 775295660268
188343 | 2014-11-14 | 775295991936
188343 | 2014-11-14 | 775296592335
188343 | 2014-11-14 | 775296601326

Podsumowanie

Jak widać świat Ruby, ma więcej do zaproponowania niż tylko Rails’y, a to tylko delikatny zalążek jego ekosystemu.

Pomijając fakt, że składnia Ruby jest sama w sobie dość prosta, to wraz z jego możliwościami meta-programowania i pisania DSL’i, sprawia, że kodowanie w tym języku jest nadzwyczaj przyjemne i niebywale efektywne, zwłaszcza w obszarze automatyzacji czy back-end development’u.

Owszem, może nie napiszemy w Nim własnych kontenerów czy systemu operacyjnego, ale od czego mamy C, C++ czy Rust’a.