Połączenia HTTP z PHP za serwerem proxy

Dzisiaj zmierzyłem się z problemem wywoływania usługi SOAP z poziomu PHP działającego na serwerze schowanym za proxy. Ostatecznie okazało się to prostsze niż myślałem, wystarczy przekazać dodatkowe parametry do konstruktora obiektu SoapClient.

$soap = new SoapClient('http://domena.pl/usluga?wsdl', array(
  'proxy_host' => '10.107.10.69',
  'proxy_port' => 8080,
  'proxy_login' => 'nazwa.uzytkownika',
  'proxy_password' => 'haslo'
  ));

Zabrało mi to jednak trochę czasu bo zacząłem od szukania rozwiązania na niższym poziomie czyli modyfikacji parametrów używanych przez PHP do wykonywania połączeń HTTP. Metodą guglania, prób i błędów dotarłem do takiego rozwiązania, które również działa.

$default = stream_context_get_default(array(
  'http' => array(
    'proxy' => 'tcp://10.107.10.69:8080',
    'request_fulluri' => true,
    'header' => 'Proxy-Authorization: Basic ' . base64_encode('nazwa.uzytkownika:haslo'),
    )
  ));

Dzięki tej metodzie z proxy zaczynają współpracować wszystkie funkcje wykorzystujące protokół HTTP, np. readfile() z plikiem na zdalnym serwerze.

readfile('http://www.google.com/');

Wyłączanie akcji w generatorze Symfony

Generator modułów administracyjnych w Symfony nie umożliwia wybrania akcji, które dany moduł ma wykonywać, zawsze dostajemy pełen zestaw CRUD. Czasami zachodzi potrzeba zablokowania niektórych akcji, np. w wypadku logów bezpieczeństwa sens ma jedynie index (przeglądanie listy), a jakakolwiek modyfikacja powinna być zabroniona.

Odnośniki widoczne na liście można łatwo ukryć edytując plik generator.yml, wystarczy domyślne akcje zastąpić pustymi listami.

generator:
  param:
    config:
      list:
        title:             Permission changes log
        actions:           {}
        object_actions:    {}
        batch_actions:     {}
      filter:
        display:           [agent_user_name, subject_user_name, permission_name, created_at]

Akcje można jednak dalej wykonywać ręcznie wpisując URL, dlatego należy je zablokować w kontrolerze.

class permission_logActions extends autoPermission_logActions
{
  public function preExecute()
  {
    $this->forward404If($this->getActionName() !== 'index', 'Action disabled');
    parent::preExecute();
  }
}

Zapisywanie osadzonych formularzy w Symfony

Zapisywanie osadzonych formularzy w Symfony działa inaczej niż intuicyjnie zakładałem. Co ciekawsze, już raz się z tym kiedyś zetknąłem, ale było to na tyle dawno, że zapomniałem rozwiązania.

Załóżmy, że w zwykłym formularzu dziedziczącym z klasy sfForm osadzam formularz oparty na modelu z bazy danych czyli dziedziczący z sfFormDoctrine.

class OnePageCheckoutForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['same_address'] = new sfWidgetFormInputCheckbox();
    $this->validatorSchema['same_address'] = new sfValidatorBoolean();

    $this->embedForm('customer', new CustomerForm());
    $this->embedForm('shipment_address', new AddressForm());
    $this->getWidgetSchema()->setNameFormat('checkout_form[%s]');
  }
}

W kontrolerze chcę zapisać dane z osadzonych formularzy, ale oczywiście klasa OnePageCheckoutForm nie ma metody save(). W pierwszym odruchu napisałem mniej więcej coś takiego:

public function executeCheckout(sfWebRequest $request)
{
  if (!$this->getUser()->hasCart())
  {
    return $this->redirect('@cart');
  }
  $this->cart = $this->getUser()->getCart();

  $this->form = new OnePageCheckoutForm();
  if ($request->getMethod() == 'POST')
  {
    $this->form->bind($request->getParameter($this->form->getName()), $request->getFiles($this->form->getName()));
    if ($this->form->isValid())
    {
      $values = $this->form->getValues();
      $this->form->getEmbeddedForm('customer')->save();
      $this->form->getEmbeddedForm('shipment_address')->save();

      $this->cart->setCustomerId($this->form->getEmbeddedForm('customer')->getObject()->getId());
      $this->cart->save();
      $this->redirect('@cart_payment_process');
    }
  }
}

Ku memu zaskoczeniu zobaczyłem wyjątek, z którego wynikało, że zapis formularza customer się nie powiódł, bo formularz nie zawiera danych. Dla pewności wrzuciłem w kod kilka var_dumpów:

// wynik: dane dla całego formularza łącznie z osadzonymi
var_dump($this->form->getValues());
// wynik: false
var_dump($this->form->getEmbeddedForm('customer')->isBound());
// wynik: pusta tablica
var_dump($this->form->getEmbeddedForm('customer')->getObject()->toArray());

Jak się okazało, formularz klasy sfForm nie aktualizuje obiektów w osadzonych formularzach, trzeba to wykonać samemu:

$this->form->getEmbeddedForm('customer')
  ->updateObject($this->form->getValue('customer'));

Można to zrobić w kontrolerze, ja ze względów konwencjonalno-estetycznych dopisałem do klasy OnePageCheckoutForm metody updateEmbeddedForms() i saveEmbeddedForms(), które są z grubsza kopią tak samo nazwanych metod z klasy sfFormObject.

Sortowanie wyników z użyciem funkcji w Solarium

To będzie zwięzły wpis dokumentujący małe zwycięstwo w pojedynku z Solarium. Zadanie polegało na dodaniu nowego sposobu sortowania produktów: w pierwszej kolejności według dostępności produktu (bez względu na ilość, tylko binarne tak/nie), w drugiej według ceny. W bazie Solr mamy tylko ilość sztuk danego produktu, należało ją więc jakoś zrzutować na wartość binarną. Na szczęście Solr umożliwia użycie funkcji do porządkowania znalezionych dokumentów, Solarium od drugiej wersji także to obsługuje. Końcowy efekt moich starań jest następujący:

$query->addSort($query->getHelper()->functionCall('min', array('stock', 1)), Solarium_Query_Select::SORT_DESC);

Dla celów dydaktycznych dodam, że najpierw próbowałem zrobić to w ten sposób:

$query->addSort($query->getHelper()->functionCall('if', array('stock', 1, 0)), Solarium_Query_Select::SORT_DESC);

jednak kończyło się to błędem:

Solr HTTP error: sort param could not be parsed as a query, and is not a field that exists in the index: if(stock,1,0) (400)

Przyczyna okazała się prozaiczna, ale odkrycie jej zajęło mi dłuższą chwilę. Otóż funkcja if dostępna jest dopiero od wersji 4.0 Solr, a my używamy 3.6.

Generowanie losowych kodów rabatowych

Wczoraj pisałem moduł obsługujący kody rabatowe w sklepie internetowym. Wedle specyfikacji kody mogą być jedno- lub wielokrotnego użytku. Te pierwsze mogą być hurtowo generowane w panelu administracyjnym. Na chłopski rozum taki jednorazowy kod powinien być:

  • losowy, żeby pomysłowi klienci nie mogli zgadywać kodów
  • w miarę krótki, żeby wpisywanie go nie było kłopotliwe
  • pozbawiony podobnych do siebie znaków takich jak O i 0, ze względów wspomnianych w poprzednim punkcie

Mój pomysł na generowanie takich kodów wygląda następująco:

public static function generateRandom()
{
  return str_pad(strtr(strtoupper(base_convert(rand(1000, 2176782335), 10, 36)), '0O', '1A'), 6, 'X');
}

Powyższa metoda generuje losowe kody złożone z 6 alfanumerycznych znaków (wielkie litery i cyfry) z pominięciem zera i litery O. Przykładowy kod: 3LFPHP. Tytułem wyjaśnienia: 2176782335 to ZZZZZZ w systemie trzydziestoszóstkowym.

Nie robiłem żadnych testów, ale przeczucie mówi mi, że takie rozwiązanie jest szybsze niż losowanie po jednym znaku z tablicy dozwolonych znaków.

Kody powinny być także unikalne, a powyższa metoda tego nie zapewnia. Gdyby tych kodów trzeba było generować tysiące na minutę pewnie pokusiłbym się o jakiś bardziej wyszukany algorytm, np. w jednej transakcji wygenerować tablicę z liczbą kodów ciut większą niż zadana, jednym zapytaniem sprawdzić które z nich już występują w bazie, odfiltrować je z tablicy, a resztę wstawić drugim zapytaniem. W tym konkretnym sklepie nie ma takiej potrzeby, więc wybrałem prostsze rozwiązanie.

public static function generateCodes(Voucher $voucher, $number)
{
  for ($i = 1; $i <= $number; $i++)
  {
    do
    {
      try
      {
        $code = new VoucherCode();
        $code->setVoucher($voucher)
          ->setCode(VoucherCode::generateRandom())
          ->save();
      }
      catch (Doctrine_Connection_Mysql_Exception $e)
      {
      }
    } while ($code->isNew())
  }
}

Kolumna code w tabeli ma założony unikalny indeks (przydaje się do wyszukiwania), więc próba wstawienia takiej samej wartości powoduje wyjątek, który łapię i generuję nowe wartości dopóki wstawianie rekordu do tablicy nie zakończy się powodzeniem.

Doctrine Query Cache w Symfony

Dzisiaj o odkrywaniu Ameryki w konserwie. Właśnie takie miałem wrażenie, gdy dłubiąc w projekcie trafiłem na rozdział na temat cache w dokumentacji Doctrine. Wcześniej tam nie trafiłem, bo całe keszowanie robiłem w Symfony – wszak do pamięci podręcznej najlepiej wrzucać efekt końcowy. Dodatkowo w dokumentacji Symfony nie rzuciło mi się w oczy nic na temat cacheowania w Doctrine.

Nie zamierzam streszczać dokumentacji Doctrine. W kontekście tej notki ważne jest, że Doctrine może zapamiętywać w cache dwa rodzaje danych:

  • skompilowane zapytania
  • wyniki zapytań

Dzięki zapamiętywaniu zapytań czasochłonny proces parsowania zapytania i budowania docelowej SQL-ki jest wykonywany tylko raz, kolejne wywołania będą korzystać z danych zapamiętanych w pamięci podręcznej. Problem aktualności danych w cache nie istnieje ponieważ zmiana zapytania spowoduje automatyczne wygenerowanie nowych danych. Korzyści są niebagatelne, użycie proste jak konstrukcja cepa, a problemów nie ma. Dlatego zgodnie z dokumentacją i zdrowym rozsądkiem, pamięć podręczna zapytań powinna być zawsze włączona, nawet w środowisku testowym.

Włączenie cache dla Doctrine w projekcie Symfony jest banalne i sprowadza się do dodania króciutkiej metody w klasie config/ProjectConfiguration.class.php.

public function configureDoctrine(Doctrine_Manager $manager)
{
    $manager->setAttribute(Doctrine_Core::ATTR_QUERY_CACHE, new Doctrine_Cache_Apc());
}

Powyższy kod spowoduje, że skompilowane zapytania będą zapamiętywane w pamięci APC. Oprócz APC Doctrine może współpracować z serwerem memcached lub inną bazą danych (np. SQLite).

Cache można włączyć nie tylko na poziomie managera, równie dobrze można zrobić to w samym zapytaniu:

$q = Doctrine_Query::create()
    ->useQueryCache(new Doctrine_Cache_Apc());

Jak wspomniałem wcześniej, pamięć podręczna zapytań powinna być włączona zawsze. Na poziomie konkretnego zapytania więcej sensu ma włączanie zapamiętywania wyników:

$q = Doctrine_Query::create()
    ->useResultCache(new Doctrine_Cache_Apc());

Dane binarne w PHP

Przy pisaniu poprzedniej notki o base64_encode() przypomniało mi się inne zastosowanie base64, a mianowicie osadzanie danych binarnych w kodzie PHP. Poniższy kawałek kodu wypluwa przezroczysty obrazek w formacie GIF o rozmiarach 1 x 1 px. Jest to fragment skryptu, który zlicza otwarcia mailingu.

header('Content-Type: image/gif'); 
header('Expires: Wed, 11 Nov 1998 12:00:00 GMT'); 
header('Cache-Control: no-cache'); 
header('Cache-Control: must-revalidate'); 
die(base64_decode('R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOwA='));

Oczywiście równie dobrze plik można zapisać na dysku i wypluwać go przez file_get_contents() lub przekierować do niego przez wysłanie nagłówka Location, ale powyższe rozwiązanie ma tę zaletę, że plik jest osadzony w skrypcie. To może być istotne jeśli sprzedajemy skrypt zakodowany np. ionCubem i nie chcemy, żeby klient miał możliwość podmiany obrazka. Dodatkowo base64_decode() ma szanse być szybsze niż otwarcie i wczytanie zewnętrznego pliku, a od przekierowania przez nagłówek Location jest szybsze na bank.

Z drugiej strony osadzanie plików w skrypcie ma sens tylko dla niedużych plików, tak π razy oko do 2-3 KiB. Przy większych plikach wybrałbym file_get_contents(), ze względu na mniejszy rozmiar skryptu PHP i prawdopodobnie większą szybkość działania.

Z trzeciej strony, jeśli podstawowym celem jest szybkość działania, można pokusić się o jeszcze inne rozwiązanie.

define(EMPTY_GIF_IMAGE, "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\xff\xff\xff\x21\xf9\x04\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b\x00");
header('Content-Type: image/gif'); 
header('Expires: Wed, 11 Nov 1998 12:00:00 GMT'); 
header('Cache-Control: no-cache'); 
header('Cache-Control: must-revalidate'); 
die(EMPTY_GIF_IMAGE);

Jeśli korzystamy z APC lub podobnego rozwiązania to po kompilacji skryptu stała EMPTY_GIF_IMAGE będzie zawierała nasz pliczek GIF w postaci gotowej do użycia, odpada konieczność konwersji z base64. Zastrzegam, że nie sprawdzałem tego w praktyce, ale zdziwiłbym się, gdyby nie było to najszybsze z opisywanych rozwiązań.

Krótsze skróty dzięki base64

Skróty wiadomości (ang. message digest, hash) to chleb powszedni w programowaniu. Przez funkcje md5(), sha1() itp. generowane są identyfikatory sesji, ciasteczka identyfikujące użytkownika, nazwy plików i tuzin innych rzeczy.

Skrót, jak sama nazwa wskazuje, powinien być krótki. Tymczasem w większości wypadków, z którymi się zetknąłem, skróty używane są w zapisie szesnastkowym, co oznacza, że 128 bitowy skrót MD5 jest zapisywany w 32 bajtach (czyli 256 bitach), a 160 bitowy SHA1 potrzebuje 40 znaków (256 bitów). To, delikatnie mówiąc, trąci rozrzutnością. Oszczędzić bajty można wykorzystując zapis base64, co pokazuje poniższy przykład:

var_dump(sha1('Ala ma kota')); 
var_dump(strtr(rtrim(base64_encode(sha1('Ala ma kota', true)), '='), '+/', '-_'));

Wynik:

string(40) "43fd70009a97a7d311c5644047ccc700f8d08a9d" 
string(27) "Q_1wAJqXp9MRxWRAR8zHAPjQip0"

Czytaj dalej Krótsze skróty dzięki base64