Page 114 of 165

Paperclip, interpolations (interpolacje) i zmiany nazwy plików

Wstęp

Są sytuacje w których chcemy aby nasze pliki z Paperclipa miały konkretne nazwy. Np. takie odpowiadające nazwom obiektów do których są dopięte (np po atrybucie name). Dla leniwych - gotowy kod na githubie.

Interpolacje

Paperclip daje nam taką możliwość dzięki interpolacjom. Możemy w bardzo prosty sposób tworzyć własne reguły nazewnictwa. Aby tego dokonać, modyfikujemy (lub tworzymy jeśli go nie mamy) plik config/initializers/paperclip_extensions.rb wpisując w nim (przykładowo):

Paperclip.interpolates :instance_name do |attachment, style|
  attachment.instance.name.to_url
end

gdzie metoda to_url (opisana tutaj) zamienia tekst na jego odpowiednik w formie nadającej się do linków (a także na nazwy plików, katalogów, itp). Przykładowo z nazwy "Przykładowa nazwa" powstanie "przykladowa-nazwa". Dzięki temu, możemy sobie poskładać nazwę dla pliku w modelu w taki oto sposób:

  has_attached_file :photo,
    :url => "/images/photos/:instance_name_:style.:extension"

Pozwala nam to tworzyć ładniejsze linki do obrazków.

Interpolacja po parametrach == problem

Interpolując z pomocą parametrów, nazwijmy to "zmiennych", pojawia się nam pewien problem. Paperclip nie zmienia samoczynnie nazw plików już utworzonych, tak więc zmieni ścieżki do plików w bazie, nie zmieniając położenia (bądź nazw) samych plików. W skutek tego jeśli utworzymy sobie model z polem :name, po którym interpolujemy, a następnie zaktualizujemy wartość tego pola, utracimy dostęp do plików będących już na serwerze. Przykładowo:

  1. Mamy model z polem :name oraz plikiem :image
  2. Interpolujemy nazwę pliku po atrybucie :name: 'images/:instance_name.:extension'
  3. Tworzymy obiekt, przypisując name => "Susanoo", dołączamy zdjęcie
  4. Otrzymujemy obiekt ze ścieżką do pliku => "images/susanoo.jpg"
  5. Aktualizujemy nazwę w naszym obiekcie, zmieniając ją na "Nokogiri". Paperclip zmieni scieżkę w bazie, na "images/nokogiri.jpg", jednak nie zmieni nazw plików, przez co w systemie plików dalej będzie plik "images/susanoo.jpg", zamiast "images/nokogiri.jpg"

Rozwiązanie - has_interpolated_attached_file

Rozwiązaniem takiej sytuacji jest wykorzystanie callbacków z ActiveRecord. Musimy "przechwycić'' stary (poprawny) adres pliku na serwerze a następnie przenieść go pod nowy adres. Musimy także zachować transakcyjność. Aby tego dokonać wykorzystamy trzy callbacki:

  1. before_update - zapamiętamy w nim nasz "stary", poprawny adres pliku - dla każdego ze stylów.
  2. after_update - mając stare nazwy oraz nowe (bo już po aktualizacji), w tym callbacku przeniesiemy pliki pod nowe adresy
  3. after_rollback - gdyby coś poszło nie tak, wycofamy zmiany wprowadzone w plikach tak aby odzyskały swoje pierwotne położenie

Całość zamkniemy w metodzie która będzie rozszerzeniem do Paperclipa, dzięki czemu będziemy mogli z niej korzystać zamiast standardowego has_attached_file.

before_update

Najpierw before_update. Tutaj sprawa jest prosta. Tworzymy sobie zmienną instancyjną która będzie hashem przechowującym. oryginalne nazwy plików (dla wszystkich styli jeśli są). Cała sztuczka polega na tym, że aby uzyskać dostęp do pierwotnych nazw. musimy odpytać bazę danych o samych siebie ale z bazy - tak żeby mieć te "poprzednie" ścieżki z nazwami. Reszta to już tylko iteracja po wartościach i przypisanie do hasha:

      before_update do |record|
        @interpolated_names = {} unless @interpolated_names
        @interpolated_names[name] = {} unless @interpolated_names[name]
        old_record = self.class.find(record.id)
        (record.send(name).styles.keys+[:original]).each do |style|
          @interpolated_names[name][style] = old_record.send(name).path(style)
        end
      end

after_update

Mamy już potrzebne nam stare i nowe ścieżki. Nie pozostaje nam nic innego, jak je pozamieniać:

      after_update do |record|
        (record.send(name).styles.keys+[:original]).each do |style|
          orig_path = @interpolated_names[name][style]
          dest_path = record.send(name).path(style)
          if orig_path && dest_path && File.exist?(orig_path) && orig_path != dest_path
            FileUtils.move(orig_path, dest_path)
          end
        end
      end

after_rollback

Dla pewności, musimy zapewnić sobie także możliwość odwrócenia zmian. Jak to zrobić? Po prostu pozamieniamy nazwy plików z powrotem, wracając do punktu wyjścia:

      after_rollback do |record|
        return unless @interpolated_names
        (record.send(name).styles.keys+[:original]).each do |style|
          dest_path = @interpolated_names[name][style]
          orig_path = record.send(name).path(style)
          if orig_path && dest_path && File.exist?(orig_path) && orig_path != dest_path
            FileUtils.move(orig_path, dest_path)
          end
        end
      end

Całość

Całość zamkniemy w metodzie has_interpolated_attached_file. Dodając do niej przy okazji oryginalny has_attached_file, co pozwoli nam korzystać z naszej metody zamiast has_attached_file:

require 'fileutils'
# Some usefull paperclip extensions
module Paperclip
  module ClassMethods

    def has_interpolated_attached_file name, options = {}

      # Get old pathes to all files from file and save in instance variable
      before_update do |record|
        @interpolated_names = {} unless @interpolated_names
        @interpolated_names[name] = {} unless @interpolated_names[name]
        old_record = self.class.find(record.id)
        (record.send(name).styles.keys+[:original]).each do |style|
          @interpolated_names[name][style] = old_record.send(name).path(style)
        end
      end

      # If validation has been passed - move files to a new location
      after_update do |record|
        (record.send(name).styles.keys+[:original]).each do |style|
          orig_path = @interpolated_names[name][style]
          dest_path = record.send(name).path(style)
          if orig_path && dest_path && File.exist?(orig_path) && orig_path != dest_path
            FileUtils.move(orig_path, dest_path)
          end
        end
      end

      # If renaming (or other callbacks) went wrong - restore old names to files
      after_rollback do |record|
        return unless @interpolated_names
        (record.send(name).styles.keys+[:original]).each do |style|
          dest_path = @interpolated_names[name][style]
          orig_path = record.send(name).path(style)
          if orig_path && dest_path && File.exist?(orig_path) && orig_path != dest_path
            FileUtils.move(orig_path, dest_path)
          end
        end
      end

      has_attached_file name, options
    end

  end
end

Źródełka dostępne na moim Githubie.

Facebook i problem w JW Player – część II – cachowanie

Wstęp

Tutorial składa się z dwóch cześci:

  1. FacebookBot z wykorzystaniem Mechanize
  2. Cachowanie odpytań

Kod (nowszy i lepszy ;) ) dostępny na githubie.

Ze względu na dużą popularność wpisu dot. "naprawy" linków z Facebooka (tutaj), postanowiłem dołączyć wam drugą część poradnika. Ta część dotyczyć będzie cachowania wyników - cobyśmy za często nie odpytywali Facebooka o linki. Dlaczego lepiej jest cachować wyniki, niż każdorazowo odpytywać FB? Z dwóch powodów:

  • czas - przede wszystkim - czas potrzebny na odpytanie serwera Facebooka o potrzebne dane, a następnie zwrócenie tego klientowi, zajmuje zbyt wiele czasu. Dużo szybciej jest wyciągnąć i zwrócić pojedynczy rekord z bazy,
  • logi - Facebook na pewno nie będzie zadowolony (i nie przepuści po pewnym czasie) z tego, że codziennie nasze konto odpytuje np. 30 - 40 tys razy o strony z filmami. Uznają to za dziwne zachowanie i po prostu zbanują konto lub co gorsza IP.

Czas "życia" adresu z Facebooka to od 24 do 36 godzin. Aby ciągłość usług była stabilna, załóżmy że cache będzie działał przez 30 minut. Da nam to odpytywanie Facebooka - maksymalnie co 30 minut na filmik (a nie przy każdym żądaniu). Dodatkowo, kiedy nasz cache się przedawni, nie odpytamy od razu o nowy URL - najpierw sprawdzimy czy stary już umarł, wysyłając zapytanie o nagłówek pod URL filmu. Jeśli dostaniemy odpowiedź 200 - tzn, że link wciąż jest aktywny i nie musimy go zmieniać.

Takie podejście zapewni nam uptime na poziomie (w najgorszym przypadku): 100%-(30/1440)*100%= 97.9% czasu. A to i tak zakładając wariant, że url z FB zmieni się dokładnie po naszym odpytaniu, przez co cache będzie serwował przez całe 30 minut zły URL. Myślę, że spokojnie można przyjąć uptime na poziomie 98.5%-99%.

Ustawienia

Aby było nam łatwiej, wszystkie ustawienia będziemy przechowywać w jednym miejscu. Stwórzmy sobie plik lib/settings.rb, w którym w stałej SETTINGS będziemy przechowywać co nam trzeba:

SETTINGS = {
  :db_user    => 'user_bazy',
  :db_adapter  => "mysql",
  :db_host     => "mysql5",
  :db_database => "baza",
  :db_pass     => 'haslo',
  :db_socket   => '/tmp/mysql.sock',

  :fb_login    => 'mail@do.facebooka.pl',
  :fb_pass     => 'haslo-do-fb',
}

Baza danych

Do przechowywania danych wykorzystamy bazę MySQLa. Stworzymy sobie plik db_connector.rb, który tak jak poprzedni (i wszystkie następne oprócz main.rb) będzie przechowywany w katalogu lib/. W pliku tym będziemy trzymać kod odpowiedzialny za łączenie się z bazą danych oraz naszą "pseudo" migrację - generującą  tabelę z odpowiednimi kolumnami. Warto zwrócić uwagę na to, że mimo wykorzystania ActiveRecord - ORMa z Railsów - nie wykorzystujemy całego frameworka. Sam ORM nam w zupełności wystarczy.

ActiveRecord::Base.establish_connection(
  :adapter  => SETTINGS[:db_adapter],
  :host     => SETTINGS[:db_host],
  :database => SETTINGS[:db_database],
  :username => SETTINGS[:db_user],
  :password => SETTINGS[:db_pass],
  :socket   => SETTINGS[:db_socket]
)

unless Video.table_exists?
  ActiveRecord::Base.connection.create_table(:videos) do |t|
    t.column :video_id, :string
    t.column :name, :string
    t.column :url, :string, :default => nil
    t.column :views, :integer, :default => 1
    t.column :cached_at, :datetime
  end
end

Warto zwrócić uwagę na ten warunek:

unless Video.table_exists?

dzięki któremu migracja zostanie wykonana tylko jeśli tabela z danymi nie istnieje.

Model filmu wideo

Mamy już gdzie trzymać nasze dane. Pora stworzyć model pojedyńczego filmiku z FB. Model nasz zawierać będzie cztery metody:

  1. url=(new_url) - przypisanie nowego adresu filmiku z Facebooka,
  2. working? - metoda zwracająca true/false zależnie od tego czy filmik działa czy też nie,
  3. url_working? - metoda sprawdzająca nagłówek odpowiedzi z żądania pod adresem URL filmu,
  4. (private) set_cached_at_time - ustawiająca podczas tworzenia instancji czas cachowania na teraz.

Zacznijmy od szkieletu modelu:

require 'net/http'
require 'uri'

class Video < ActiveRecord::Base
  # 30 minut
  CACHE_TIME = 60*30

  before_create :set_cached_at_time
  # Gwarancja, ze jeden filmik bedzie mial jeden wpis w bazie
  validates_uniqueness_of :video_id
  validate :working?
end

Metody

1. url=

def url=(new_url)
  self.errors.clear
  self.cached_at = Time.now
  super new_url
end

Część z was może zapytać, dlaczego czyszczę błędy (self.errors.clear)? Czyszczę je, ponieważ przypisanie nowego URLa następuje wtedy i tylko wtedy, gdy stary był niepoprawny i nie przechodził walidacji. Nowy z założenia przechodzi, tak więc błąd starego URLa musi zostać "zapomniany".

2. working?

def working?
  v = false
  if self.cached_at >= Time.now - CACHE_TIME && self.url.length > 10
    v = true
  else
    v = url_working?
    self.cached_at = Time.now
  end
  self.errors.add(:cached_at, 'Cache ulegl przedawnieniu') unless v
  v
end

W tej metodzie sprawdzamy czy URL jest poprawny. Jeśli jest "młody" (czyli nowszy niż 30 minut) i jego długość jest większa niż 10 (to tak dla pewności ;) ), tzn. że jest prawdziwy (w 99% procentach przypadków - patrz wstęp). W przeciwnym razie - sprawdzamy nagłówek odpowiedzi z żądania wysłanego pod link, i jeśli jest poprawny - to ok, jeśli nie - to wrzucamy błąd do tablicy błędów naszej instancji modelu. Uważniejsze osoby mogą zapytać, dlaczego ustawiamy cache na nowy - mimo, że URL się nie zmienił. Ustawiamy tak, ponieważ zmiana URLa nastąpi na pewno jeśli ten link był niepoprawny. Nastąpi ona jednak w pliku main.rb.

3. url_working?

def url_working?
  begin
    host = URI.parse(self.url).host
    http = Net::HTTP.new(host)
    headers = http.head(self.url)
  rescue
    return true
  end
  if headers.code == "200"
    true
  else
    false
  end
end

Zasadniczo sposób działania tej metody opisałem już wyżej, ale tak dla pewności:

  1. Wysyłamy żądanie nagłówka pod adres naszego filmiku
  2. Jeśli coś poszło nie tak - zakładamy, że URL nie jest poprawny
  3. Jeśli dostaliśmy nagłówek to go sprawdzamy
  4. Jeśli 200 - to znaczy że URL jest poprawny (true)
  5. Jeśli cokolwiek innego (404, 401, 403, 500, itd) - tzn. że już "umarł" (false)

4. set_cached_at_time

  def set_cached_at_time
    self.cached_at = Time.now
  end

Tutaj nie ma co tłumaczyć.

To by było na tyle jeśli chodzi o nasz model Video. Pozostaje nam jeszcze tylko plik main.rb.

Połączenie wszystkiego w całość

Na początek zaincludujmy pliki z katalogu lib/ oraz inne niezbędne nam biblioteki:

# coding: utf-8

def require_local(file)
  require File.join(File.dirname(__FILE__), "/lib/#{file}")
end

require 'rubygems'
require_local 'settings'
require_local 'facebook_bot'
require 'active_record'
require_local 'video'
require_local 'db_connector'

Zwróćcie uwagę, że dołączamy naszego FacebookBota z pierwszej części tego poradnika. Jednak jest to wersja lekko zmodyfikowana w stos. do wersji z części pierwszej. Różnica jest zasadniczo taka, że ta "nowa" wersja bota, oprócz URLa filmu pobiera także jego nazwę, dlatego radzę pobrać sobie paczkę ze źródełkami lub plik z botem z mojego githuba.

Dalej pobieramy z parametrów ID filmu oraz opcję "force" której użycie wymusi regenerację linku wprost z Facebooka, niezależnie od wartości cache'a:

video_id = ARGV[0]
force = false || ARGV[1] == 'true'

Dalej cała główna logika tej aplikacji:

video = Video.find_by_video_id(video_id)

# Jesli taki plik istnieje i jest poprawny i żądanie nie jest wymuszone
# to zwróć link
if video && video.valid? && !force
  puts 'Cache'
  puts video.url
else
  # Jesli link był niepoprawny lub jest to nowy filmik to zainicjuj FBbota
  # i pobierz z FB URL oraz nazwę filmiku
  fb = FacebookBot.new(SETTINGS[:fb_login], SETTINGS[:fb_pass])
  url = fb.video_url(video_id)
  name = fb.video_name(video_id)
  # Jesli video istniało tylko URL wygasł to przypisz nowy URL i nazwę
  # (nazwe na wypadek gdyby ulegla zmianie)
  if video
    video.url = url
    video.name = name
  else
    # Jesli filmiku nie bylo to go utworz
    video = Video.new(
      :video_id => video_id,
      :url => url,
      :name => name
    )
  end
  puts 'Nowe wywolanie'
  puts video.url
end
# Podbij ilosc wyswietlen
video.views+=1
# I Zapisz zmiany w modelu
video.save

Tyle. Od teraz mamy w pełni działający program umożliwiający "regenerację" linków do plików video z Facebooka. Dzięki cachowaniu w bazie danych ilość odpytań dla popularnych filmików znacznie spada.

Tutorial składa się z dwóch cześci:

  1. FacebookBot z wykorzystaniem Mechanize
  2. Cachowanie odpytań

Kod (nowszy i lepszy ;) ) dostępny na githubie.

Copyright © 2024 Closer to Code

Theme by Anders NorenUp ↑