Stawiam dolary przeciw orzechom, że każdy kto para się tworzeniem aplikacji webowych przynajmniej raz w swojej karierze stanął przed koniecznością wykonywania cyklicznych zadań - okresowe czyszczenie pamięci cache, zmiana statusów rekordów w bazie, wysyłanie powiadomień wg harmonogramu, itp. itd.

Nie jest to oczywiście jakieś bardzo wymagające zadanie, szczególnie gdy mamy dostęp do swojego konta shell na serwerze - dopisywanie zadań do cron-a to jednak elementarz. Ale nie zawsze taki dostęp jest, a czasami po prostu jest technicznie utrudniony. Przewidując takie sytuacje większość frameworków i część CMS-ów oferuje własne, wbudowane mechanizmy obsługi zadań planowych. Laravel oczywiście ma też swój własny "Task Scheduling".

W Laravel jest to przygotowane "na bogato", przewidziano większość sytuacji, z których każdą można obsłużyć na kilka sposobów. Proces tworzenia zadań i ich planowanie też jest zrozumiały i przejrzysty. Żeby wszystko działało, na serwerze do cron-a musimy dopsać tylko jedno zadanie:

* * * * * php /śceżka-do-projektu-laravel/artisan schedule:run >> /dev/null 2>&1

Odpalane co minutę uruchamia zadania zdefiniowane w naszym projekcie wg harmonogramu przypisanego indywidualnie do każdego z nich.

Akurat w tej chwili na potrzeby bieżacego projektu musiałem rozwiązać kwestię cyklicznej aktualizacji danych pogodowych, które następnie są wyświetlane na stronie serwisu informacyjnego. Takie zadanie idealnie kwalifikuje się do obsługi według stałego harmonogramu - 30 minutowy interwał wydaje się idealnym do tego celu. No to do dzieła…

Pogoda i jakość powietrza

Dane pogodowe będę pozyskiwał z serwisu OpenWeatherMap.org, który oferuje bezpłatny dostęp do API i dostarcza wszystkie potrzebne informacje o pogodzie aktualnej i prognozowanej. Incydentalnie zdarzają się problemy z komunikacją, ale to przypadłość praktycznie wszystkich dostawców. Usługa wymaga zarejestrowania aplikacji i autoryzacji żadąń kluczem API.

Dla Laravel dostępny jest zgrabny pakiet, kóry co prawda jest nieoficjalny, ale doskonale spełnia swoje zadanie. Oczywiście musimy mieć do niego dostęp w naszym projekcie, więc instalujemy poleceniem:

composer require cmfcmf/openweathermap-php-api

Informacje o stanie powietrza pobiorę z Głównego Inspektoratu Ochrony Środowiska, który także oferuje bezpłatny dostęp do swojego API. W tym przypadku nie będę korzystał z żadnego pakietu, dane będą pobierane w formacie JSON bezpośrednio z serwera.

Pobieramy dane

Do tego celu utworzę komendę artisan (to taki Laravelowy shell), którą w kolejnym kroku przypiszę do harmonogramu. Tradycyjnie zaczynamy od wygenerowania odpowiedniego pliku klasy:

php artisan make:command getWeather

Założenia są takie, że z OpenWeatherMap będę pobierał wszystkie dane łącznie z ikonami ilustrującymi stan pogody. Niestety, zdarza się, że nazwy ikon nie są dostarczane, co oczywiście kończy się niefajnym błędem przy próbie wyświetlenia na stronie. Dlatego będę sprawdzał, czy dostarczona nazwa ikony jest powiązana z fizycznym plikiem na serwerze OpenWeatherMap - jeżeli nie, to będę pomijał bieżącą paczkę, a na stronę trafią dane z poprzedniej transmisji. Podobnie będę postępował z danymi o stanie powietrza.

Wg tych założeń plik app/Console/Commands/getWeather.php musimy teraz wypełnić:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

//API pogodowe
use Cmfcmf\OpenWeatherMap;
use Cmfcmf\OpenWeatherMap\Exception as OWMException;

//dostęp do pamięci cache
use Illuminate\Support\Facades\Cache;

//biblioteka narzędzi date/time
use Illuminate\Support\Carbon;

class getWeather extends Command {

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'getWeather:current'; //tak będzie wywoływana komenda z CLI (artisan command)

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Pobiera bieżące dane pogodowe.'; //opis komendy 

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct() {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle() { 

        $lang = 'pl'; 
        $units = 'metric';

        //tworzymy obiekt OpenWeatherMap z naszym kluczem API
        $owm = new OpenWeatherMap(config('constants.OPENWEATHER_API_KEY'));

        //czy w cache są poprzednie dane? jeżeli tak, to pobieramy
        $pogoda = Cache::has('pogoda') ? Cache::get('pogoda') : array(); 

        // Stan powietrza
        $data = @file_get_contents("http://api.gios.gov.pl/pjp-api/rest/aqindex/getIndex/config('constants.ID_STACJI_POMIAROWEJ')"); //pobieramy dane z serera GIOS
        if ($data) {
            $air = json_decode($data); //poszło OK, dekodujemy do obiektu
        } else { //uwaliło się, poinformujmy o tym w logu
            $air = null;
            echo Carbon::now() . "\t" . 'Bład pobierania danych o stanie powietrza' . PHP_EOL;
        }

        // Dane pogodowe
        try {
            $current = $owm->getWeather(config('constants.OPENWEATHER_CITY_ID'), $units, $lang); //pobieramy aktualne dane dla miasta z id=OPENWEATHER_CITY_ID
            $forecast = $owm->getDailyWeatherForecast(config('constants.OPENWEATHER_CITY_ID'), $units, $lang, '', 2); //pobieramy prognozę na dwa najbliższe dni dla miasta z id=OPENWEATHER_CITY_ID
        } catch (OWMException $e) { //lipa, coś się nie udało
            echo Carbon::now() . "\t" . 'OpenWeatherMap exception: ' . $e->getMessage() . ' (Code ' . $e->getCode() . ').' . PHP_EOL;
        } catch (Exception $e) { //lipa, ale poważniejsza
            echo Carbon::now() . "\t" . 'General exception: ' . $e->getMessage() . ' (Code ' . $e->getCode() . ').' . PHP_EOL;
        }

        if ($current) { //mamy to! sprawdzamy aktualną pogodę
            // Dzisiaj
            if (@getimagesize('http:' . $current->weather->getIconURL())) { //sztuczka magiczna - sprawdzam, czy dostarczona nazwa pliku ikony jest powiązana z fizycznym plikiem
                $pogoda['today'] = $current; //uff, wszytsko jest ok, zapisujemy w tablicy
            }
        }

        if ($forecast) { //mamy to! sprawdzamy prognozę na jutro
            $forecast->rewind(); //ustawiamy na dzisiaj
            $forecast->next(); //ustawiamy na kolejnej pozycji (jutro)
            $tommorow = $forecast->current(); //pobieramy jutrzejsze dane
            // dalej to samo, co powyżej
            if (@getimagesize('http:' . $tommorow->weather->getIconURL())) {
                $pogoda['tommorow'] = $tommorow;
            }
        }

        // Stan powietrza
        if ($air && $air->stIndexLevel->id >= 0) { // ignorujemy dla id<0 - to oznacza brak danych
            $pogoda['air'] = $air;
        }

        // Dane do cache
        Cache::forget('pogoda'); //usuwamy poprzednie
        Cache::forever('pogoda', $pogoda); //nowe zapisujemy na zawsze

    }

}

Odpowiednie wartości ID_STACJI_POMIAROWEJ i OPENWEATHER_CITY_ID należy odnaleźć na stronach dostawców, procedury są tam opisane.

Przy pobieraniu prognozy na jutro można zabezpieczyć się dodatkowo sprawdzaniem daty, ponieważ zdarzają się incydentalne sytuacje, w których dane dla kolejnych dni nie są dostarczane lub niektóre daty są pomijane.

Logika całego procesu sprowadza się do cyklu:

  1. pobieram poprzednią paczkę danych z pamięci cache,
  2. pobieram aktualną paczkę z serwerów dostawców,
  3. jeżeli dane są poprawne, aktualizuję nimi te z poprzedniej paczki,
  4. jeżeli nie są poprawne, pozostawiam poprzednie bez zmian,
  5. usuwam starą paczkę z cache i w jej miejsce zapisuję nową.

Harmonogram

Teraz przyszła pora na zarejestrowanie komendy w naszym projekcie. W tym celu edytujemy plik app/Console/kernel.php:

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * The Artisan commands provided by your application.
     *
     * @var array
     */
    protected $commands = [
        '\App\Console\Commands\getWeather', //to nasza nowa komenda 
    ];

    /**
     * Define the application's command schedule.
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        $schedule->command('getWeather:current') // i od teraz chcemy, żeby
                ->everyThirtyMinutes() // była uruchamina co 30 minut
                ->appendOutputTo('/var/log/scheduled_weather.log') //a komunikaty zapisywała w pliku dziennika;
    }

    /**
     * Register the commands for the application.
     *
     * @return void
     */
    protected function commands()
    {
        $this->load(__DIR__.'/Commands');

        require base_path('routes/console.php');
    }
}

I to wszystko. No, prawie - przydałoby się jeszcze przetestować, zanim ostatecznie odpalimy to zadanie w cronie. Jako, że to, co przed chwilą zrobiliśmy w efekcie dodało do naszego projektu nową komendę, możemy oczywiście prosto ją wywołać tak, jak robimy to z wszystkimi innymi. Najpierw sprawdźmy, czy artisan "widzi" już tę komendę, więc wykonujemy php artisan list i szukamy na liście naszej nazwy:

Lista komend

Oczywiście tam jest, więc możemy spróbować ją uruchomić, ale przedtem dopiszmy w niej na końcu polecenie dd($pogoda), które wyświetli nam elegancko sformatowaną strukturę tablicy z naszą pogodą.

Teraz możemy działać: php artisan getWeather:current - jeżeli wszystko będzie ok, to na ekranie wyświetli się zawrtość tablicy $pogoda. Jeżeli pojawi się jakiś błąd, no to trzeba go usunąć. Oczywiście w poprawnie działającym skrypcie usuwamy dopisane właśnie polecenie dd($pogoda).

I to by było na tyle. Od teraz co 30 minut skrypt będzie wykonywany automatycznie, co będzie wyrazem naszego szacunku dla dostawców informacji pogodowych, bo nie będziemy ich fludować tysiącami żądań, tylko raptem 48 razy w ciągu doby. Zapisane w cache dane są zawsze dostępne do odczytania, w każdej chwili możemy je pobrać $pogoda = \Cache::get('pogoda'); - otrzymamy tablicę z trzema obiektami o indeksach: 'today', 'tommorow' i 'air'. Korzystając z ich metod i właściwości możemy wyświetlać interesujące nas dane.

Na stronie OpenWeatherMap-PHP-Api znajdziecie opis wszystkich metod i właściwości, warto zajrzeć do plików przykładowych (katalog Examples).

Obiekt reprezentujący stan powietrza składa się tylko z właściwości. Ich znaczenie poznacie podglądając jego strukturę lub odwiedzając stronę Interfejs programistyczny aplikacji (API).

Jak widać, definiowanie zadań wykonywanych cyklicznie nie jest szczególnie skomplikowane. Należy pamiętać, że są one integralną częścią naszego projektu i mogą korzystać z jego zasobów. Dzięki temu możemy np. porządkować cache systemu graficznego albo weryfikować statusy publikowanych artykułów, możemy realizować dowolne zadanie, które wymaga okresowego wykonania w ramach naszego projektu i z dostępem do jego zasobów.

W dokumntacji Laravela opisane są wszystkie możliwości, z których tutaj przedstawiłem tylko jedną z najprostszych. W bardziej wymagających i "zasobożernych" sytuacjach rozsądniej będzie wykorzystać kolejkowanie zadań (Queued Jobs), ale i wtedy możemy je uruchamiać wg harmonogramu, który zdefiniujemy podobnie, jak w powyższym pogodowym przykładzie.


blog comments powered by Disqus