Back-End

5 out, 2017

Aplicação PHP em SAP Cloud Platform com PostgreSQL, Redis e Cloud Foundry

Publicidade

Continuando com meu estudo da SAP Cloud Platform (SCP) e do Cloud Foundry, hoje vou construir uma aplicação PHP simples. Essa aplicação serve uma landing page simples do Bootstrap. Ela usa uma autenticação básica HTTP, onde as credenciais são validadas contra um banco de dados PostgreSQL. Ela também tem uma API para recuperar o localtimestamp do servidor de banco de dados (apenas para brincar com um servidor de banco de dados). Eu também quero brincar com Redis na nuvem, então a solicitação da API terá um Time To Live (ttl) de cinco segundos.

Vou usar um serviço Redis para fazê-lo.

Primeiro, criamos nossos serviços no Cloud Foundry. Estou usando a camada livre do Cloud Foundry do SAP para esse exemplo. Não vou explicar aqui como fazer isso, é bastante simples no Cockpit do SAP.

Há algum tempo, brinquei com o Cloud Foundry da IBM também. Lembro-me de que foi muito simples.

Então, criamos nossa aplicação (.bp-config/options.json).

{
"WEBDIR": "www",
"LIBDIR": "lib",
"PHP_VERSION": "{PHP_70_LATEST}",
"PHP_MODULES": ["cli"],
"WEB_SERVER": "nginx"
}

Se quisermos usar nossos serviços PostgreSQL e Redis com nossa aplicação PHP, precisamos conectar esses serviços à nossa aplicação. Essa operação também pode ser feita com o Cockpit do SAP.

Agora é a vez da aplicação PHP. Eu costumo usar o framework Silex dentro dos meus backends, mas agora há um problema: o Silex está morto. Eu fico um pouco triste, mas não vou chorar. É apenas uma ferramenta e há outras. Eu tenho o meu exemplo com o Silex, mas, como um exercício, também o farei com Lumen.

Vamos começar com o Silex. Se você está familiarizado com o microframework Silex (ou outro microframework, na verdade), você pode ver que não há nada especial.

use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpFoundation\Request;
use Silex\Provider\TwigServiceProvider;
use Silex\Application;
use Predis\Client;
 
if (php_sapi_name() == "cli-server") {
    // when I start the server my local machine vendors are in a different path
    require __DIR__ . '/../vendor/autoload.php';
    // and also I mock VCAP_SERVICES env
    $env   = file_get_contents(__DIR__ . "/../conf/vcap_services.json");
    $debug = true;
} else {
    require 'vendor/autoload.php';
    $env   = $_ENV["VCAP_SERVICES"];
    $debug = false;
}
 
$vcapServices = json_decode($env, true);
 
$app = new Application(['debug' => $debug, 'ttl' => 5]);
 
$app->register(new TwigServiceProvider(), [
    'twig.path' => __DIR__ . '/../views',
]);
 
$app['db'] = function () use ($vcapServices) {
    $dbConf = $vcapServices['postgresql'][0]['credentials'];
    $dsn    = "pgsql:dbname={$dbConf['dbname']};host={$dbConf['hostname']};port={$dbConf['port']}";
    $dbh    = new PDO($dsn, $dbConf['username'], $dbConf['password']);
    $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $dbh->setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER);
    $dbh->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
 
    return $dbh;
};
 
$app['redis'] = function () use ($vcapServices) {
    $redisConf = $vcapServices['redis'][0]['credentials'];
 
    return new Client([
        'scheme'   => 'tcp',
        'host'     => $redisConf['hostname'],
        'port'     => $redisConf['port'],
        'password' => $redisConf['password'],
    ]);
};
 
$app->get("/", function (Application $app) {
    return $app['twig']->render('index.html.twig', [
        'user' => $app['user'],
        'ttl'  => $app['ttl'],
    ]);
});
 
$app->get("/timestamp", function (Application $app) {
    if (!$app['redis']->exists('timestamp')) {
        $stmt = $app['db']->prepare('SELECT localtimestamp');
        $stmt->execute();
        $app['redis']->set('timestamp', $stmt->fetch()['TIMESTAMP'], 'EX', $app['ttl']);
    }
 
    return $app->json($app['redis']->get('timestamp'));
});
 
$app->before(function (Request $request) use ($app) {
    $username = $request->server->get('PHP_AUTH_USER', false);
    $password = $request->server->get('PHP_AUTH_PW');
 
    $stmt = $app['db']->prepare('SELECT name, surname FROM public.user WHERE username=:USER AND pass=:PASS');
    $stmt->execute(['USER' => $username, 'PASS' => md5($password)]);
    $row = $stmt->fetch();
    if ($row !== false) {
        $app['user'] = $row;
    } else {
        header("WWW-Authenticate: Basic realm='RIS'");
        throw new HttpException(401, 'Please sign in.');
    }
}, 0);
 
$app->run();

Talvez a única coisa especial seja a maneira como o autoloader é feito. Estamos inicializando o autoloader de duas formas diferentes. Uma delas quando a aplicação é executada na nuvem, e outra quando a aplicação é executada localmente com o servidor interno do PHP. Isso ocorre porque os vendedores estão localizados em caminhos diferentes, dependendo de qual ambiente o aplicativo vive. Quando o Cloud Foundry conecta serviços às aplicações, ele injeta variáveis de ambiente com a configuração do serviço (credenciais, host, etc). Ele usa VCAP_SERVICES env var.

Eu uso o servidor embutido para executar a aplicação localmente. Quando estou fazendo isso, não tenho a variável VCAP_SERVICES. E também, as informações dos meus serviços são diferentes de quando estou executando a aplicação na nuvem. Talvez seja melhor com uma variável de ambiente, mas estou usando esse truque:

if (php_sapi_name() == "cli-server") {
    // I'm runing the application locally
} else {
    // I'm in the cloud
}

Então, localmente, faço um mock de VCAP_SERVICES com os meus valores locais e também, por exemplo, configuro a aplicação Silex no modo debug.

As vezes eu quero rodar minha aplicação localmente, mas quero usar os serviços da nuvem. Não consigo me conectar diretamente a esses serviços, mas podemos fazê-lo via SSH através da nossa aplicação conectada. Por exemplo, se nossa aplicação PostgreSQL estiver sendo executada em 10.11.241.0:48825, podemos mapear essa porta remota (em uma rede privada) para nossa porta local com esse comando:

cf ssh -N -T -L 48825:10.11.241.0:48825 silex


Você pode ver mais informações sobre esse comando aqui.

Agora, podemos usar o pgAdmin, por exemplo, em nossa máquina local para nos conectarmos ao servidor em nuvem.

Podemos fazer o mesmo com Redis:

cf ssh -N -T -L 54266:10.11.241.9:54266 silex

E, basicamente, isso é tudo. Agora, vamos fazer o mesmo com Lumen. A ideia é criar a mesma aplicação com Lumen ao invés de Silex. É uma aplicação boba, mas funciona para a tarefa em que normalmente a uso. Também reutilizarei os serviços Redis e PostgreSQL do projeto anterior.

use App\Http\Middleware;
use Laravel\Lumen\Application;
use Laravel\Lumen\Routing\Router;
use Predis\Client;
 
if (php_sapi_name() == "cli-server") {
    require __DIR__ . '/../vendor/autoload.php';
    $env = 'dev';
} else {
    require 'vendor/autoload.php';
    $env = 'prod';
}
 
(new Dotenv\Dotenv(__DIR__ . "/../env/{$env}"))->load();
 
$app = new Application();
 
$app->routeMiddleware([
    'auth' => Middleware\AuthMiddleware::class,
]);
 
$app->register(App\Providers\VcapServiceProvider::class);
$app->register(App\Providers\StdoutLogServiceProvider::class);
$app->register(App\Providers\DbServiceProvider::class);
$app->register(App\Providers\RedisServiceProvider::class);
 
$app->router->group(['middleware' => 'auth'], function (Router $router) {
    $router->get("/", function () {
        return view("index", [
            'user' => config("user"),
            'ttl'  => getenv('TTL'),
        ]);
    });
 
    $router->get("/timestamp", function (Client $redis, PDO $conn) {
        if (!$redis->exists('timestamp')) {
            $stmt = $conn->prepare('SELECT localtimestamp');
            $stmt->execute();
            $redis->set('timestamp', $stmt->fetch()['TIMESTAMP'], 'EX', getenv('TTL'));
        }
 
        return response()->json($redis->get('timestamp'));
    });
});
 
$app->run();

Eu criei quatro provedores de serviços. Um para manusear conexões de banco de dados (eu não gosto de ORMs).

namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use PDO;
 
class DbServiceProvider extends ServiceProvider
{
    public function register()
    {
    }
 
    public function boot()
    {
        $vcapServices = app('vcap_services');
 
        $dbConf = $vcapServices['postgresql'][0]['credentials'];
        $dsn    = "pgsql:dbname={$dbConf['dbname']};host={$dbConf['hostname']};port={$dbConf['port']}";
        $dbh    = new PDO($dsn, $dbConf['username'], $dbConf['password']);
        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $dbh->setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER);
        $dbh->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
 
        $this->app->bind(PDO::class, function ($app) use ($dbh) {
            return $dbh;
        });
    }
}

Outro para Redis. Preciso estudar um pouco mais Lumen. Eu sei que o Lumen possui uma ferramenta embutida para trabalhar com Redis.

namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use Predis\Client;
 
class RedisServiceProvider extends ServiceProvider
{
    public function register()
    {
    }
 
    public function boot()
    {
        $vcapServices = app('vcap_services');
        $redisConf    = $vcapServices['redis'][0]['credentials'];
 
        $redis = new Client([
            'scheme'   => 'tcp',
            'host'     => $redisConf['hostname'],
            'port'     => $redisConf['port'],
            'password' => $redisConf['password'],
        ]);
 
        $this->app->bind(Client::class, function ($app) use ($redis) {
            return $redis;
        });
    }
}

Outro para dizer ao monolog para enviar logs para Stdout.

namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use Monolog;
 
class StdoutLogServiceProvider extends ServiceProvider
{
    public function register()
    {
        app()->configureMonologUsing(function (Monolog\Logger $monolog) {
            return $monolog->pushHandler(new \Monolog\Handler\ErrorLogHandler());
        });
    }
}

E um último para trabalhar com variáveis de ambiente Vcap. Provavelmente eu preciso integrá-lo com arquivos dotenv.

namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
 
class VcapServiceProvider extends ServiceProvider
{
    public function register()
    {
        if (php_sapi_name() == "cli-server") {
            $env = file_get_contents(__DIR__ . "/../../conf/vcap_services.json");
        } else {
            $env = $_ENV["VCAP_SERVICES"];
        }
 
        $vcapServices = json_decode($env, true);
 
        $this->app->bind('vcap_services', function ($app) use ($vcapServices) {
            return $vcapServices;
        });
    }
}

Nós também precisamos lidar com a autenticação (http basic auth, neste caso), então vamos criar um middleware simples.

namespace App\Http\Middleware;
 
use Closure;
use Illuminate\Http\Request;
use PDO;
 
class AuthMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $user = $request->getUser();
        $pass = $request->getPassword();
 
        $db = app(PDO::class);
        $stmt = $db->prepare('SELECT name, surname FROM public.user WHERE username=:USER AND pass=:PASS');
        $stmt->execute(['USER' => $user, 'PASS' => md5($pass)]);
        $row = $stmt->fetch();
        if ($row !== false) {
            config(['user' => $row]);
        } else {
            $headers = ['WWW-Authenticate' => 'Basic'];
            return response('Admin Login', 401, $headers);
        }
 
        return $next($request);
    }
}

Em resumo: Lumen é legal. A interface é muito semelhante à do Silex. Eu tenho que fazer a minha mente deixar de pensar em Silex para pensar em Lumen de forma mais fácil. Blade em vez de Twig: não há problema. Os provedores de serviços são muito semelhantes. O roteamento é quase o mesmo, e middlewares são muito melhores. Atualmente, o backend é uma mercadoria para mim, então eu não quero gastar muito tempo trabalhando nela. Quero algo que funcione. E Lumen parece ser a solução.

Ambos os projetos Silex e Lumen, estão disponíveis no meu GitHub.