DEV Community

Cover image for Pensando em Yii3: como o framework se encaixa
Yuri Neves
Yuri Neves

Posted on

Pensando em Yii3: como o framework se encaixa

Se você vem do Yii2, a primeira coisa que vai procurar no Yii3 é o Yii::$app. Ele não existe. Não tem objeto global da aplicação, não tem Controller base cheio de propriedades mágicas e não tem array de components.

Quando isso encaixa, o resto faz muito mais sentido. Este post é o modelo mental que eu queria ter antes de escrever qualquer código. Se preferir ir direto para a prática, o CRUD passo a passo usa tudo que está aqui.

É um conjunto de pacotes, não um framework monolítico

O Yii3 não é um repositório só. É uma coleção de pacotes yiisoft/* pequenos, e cada um faz uma coisa: yiisoft/router, yiisoft/di, yiisoft/db, yiisoft/validator, yiisoft/view e por aí vai.

Quem junta tudo num app funcionando é o template yiisoft/app. No meu projeto de CRUD, depois de adicionar os pacotes de banco e de formulário, são 49 pacotes yiisoft/* no vendor/. Você não precisa conhecer a maioria, mas ajuda saber que eles existem e que cada um funciona fora do Yii também.

Isso também quer dizer que você adiciona funcionalidades com composer require, e não ativando algo num arquivo de config gigante. Precisa de banco? composer require yiisoft/db-sqlite. Formulários? yiisoft/form-model. E a maioria dos pacotes traz a própria configuração, que é o próximo ponto.

A configuração é array PHP, mesclado de vários lugares

Toda a configuração fica em config/, dividida em grupos. O mapa dos grupos está em config/configuration.php:

'config-plugin' => [
    'params' => 'common/params.php',
    'params-web' => ['$params', 'web/params.php'],
    'params-console' => ['$params', 'console/params.php'],
    'di' => 'common/di/*.php',
    'di-web' => ['$di', 'web/di/*.php'],
    'di-console' => '$di',
    'routes' => 'common/routes.php',
    'bootstrap' => 'common/bootstrap.php',
    // ...
],
Enter fullscreen mode Exit fullscreen mode

O grupo di é todo arquivo em config/common/di/, o di-web é isso mais os arquivos de config/web/di/, e assim por diante. O app web e o console leem grupos diferentes, e os ambientes (dev, prod, test) podem sobrescrever os params.

O detalhe importante é que os pacotes também trazem configuração. O yiisoft/session tem um config/di-web.php que liga FlashInterface a Flash. O yiisoft/db-migration registra os comandos migrate:* no config/params.php dele. Quando você instala um pacote, o yiisoft/config adiciona os arquivos dele ao "merge plan" (config/.merge-plan.php), e em tempo de execução eles são mesclados com os seus.

Então, depois de composer require yiisoft/db-migration, o ./yii migrate:up simplesmente existe, sem nenhuma linha escrita por você. Se você mexer no próprio configuration.php, rode composer yii-config-rebuild para atualizar o plano.

Sua configuração e a dos pacotes mescladas no container

O container constrói tudo

Essa é a maior mudança em relação ao Yii2. No Yii3 você nunca busca um serviço em algum lugar: você pede no construtor, e o container de DI (yiisoft/di, PSR-11) entrega.

final readonly class PostRepository
{
    public function __construct(
        private ConnectionInterface $db,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Para uma classe concreta como PostRepository, você não escreve configuração nenhuma. O container lê o construtor, monta o que precisa e injeta. Isso é autowiring.

Você só precisa de uma definição quando o container não tem como adivinhar. Isso acontece com uma interface (qual implementação?) ou com um valor escalar (qual DSN?). Esta é a conexão de banco do CRUD, em config/common/di/db.php:

return [
    ConnectionInterface::class => static fn (Aliases $aliases, SchemaCache $schemaCache) => new Connection(
        new Driver('sqlite:' . $aliases->get('@runtime/database.sqlite')),
        $schemaCache,
    ),
];
Enter fullscreen mode Exit fullscreen mode

Todas as classes do app dependem de ConnectionInterface, nunca de SQLite. Trocar para MySQL é mudar só esse arquivo.

Na prática, faço três perguntas quando escrevo uma classe:

  1. Do que ela precisa? Isso vai no construtor.
  2. É uma interface ou um valor? Então precisa de uma definição em config/*/di/.
  3. Ela precisa saber de HTTP? Se não, fica fora de src/Web.

Uma requisição, do começo ao fim

Isto é o que acontece quando uma requisição chega no app:

Ciclo da requisição: index.php, camadas de middleware, router, seu código, emitter

O public/index.php cria um HttpApplicationRunner. O runner monta a configuração e o container, e entrega a requisição para a aplicação. A aplicação roda uma pilha de middlewares PSR-15 definida em config/web/di/application.php:

'withMiddlewares()' => [
    [
        ErrorCatcher::class,
        SessionMiddleware::class,
        CsrfTokenMiddleware::class,
        RequestCatcherMiddleware::class,
        Router::class,
    ],
],
Enter fullscreen mode Exit fullscreen mode

Cada middleware recebe a requisição, pode fazer algo antes, chama o próximo e pode mexer na resposta depois. O ErrorCatcher é a camada mais externa, então qualquer exceção lançada lá dentro termina nele. O Router é o último: ele encontra a rota e chama a sua action. O que a action retorna volta pelas mesmas camadas, e o SapiEmitter envia para o navegador.

Middleware é como você se encaixa na requisição

Como tudo é middleware, adicionar comportamento em volta de uma requisição segue sempre a mesma ideia. Este aqui adiciona um header com o tempo de resposta:

final class ResponseTimeMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $start = microtime(true);

        $response = $handler->handle($request);

        $ms = round((microtime(true) - $start) * 1000, 1);

        return $response->withHeader('X-Response-Time', $ms . 'ms');
    }
}
Enter fullscreen mode Exit fullscreen mode

Você pode colocar na lista global ou ligar só em algumas rotas:

Group::create('/posts')
    ->middleware(ResponseTimeMiddleware::class)
    ->routes(/* ... */),
Enter fullscreen mode Exit fullscreen mode

Autenticação, controle de acesso, CORS e rate limit funcionam todos assim. No meu blog, a área admin é um grupo de rotas com um middleware de permissão, e o grupo da API tem um middleware de token.

Actions são só callables

O template não tem controller base. A home é uma classe só, com __invoke():

final readonly class Action
{
    public function __construct(
        private WebViewRenderer $viewRenderer,
    ) {}

    public function __invoke(): ResponseInterface
    {
        return $this->viewRenderer->render(__DIR__ . '/template');
    }
}
Enter fullscreen mode Exit fullscreen mode

A rota aponta para ela com ->action(Action::class). Se você prefere controllers com vários métodos, ->action([PostController::class, 'index']) também funciona, e foi o que usei no CRUD. Nos dois casos, a action recebe os serviços pelo construtor e retorna uma resposta PSR-7. Não precisa de classe base.

As coisas são imutáveis

Você vai ver muito método with*() no Yii3: $response->withHeader(), $viewRenderer->withViewPath(), $viewRenderer->withLayout(). Eles não alteram o objeto, retornam um novo.

// o $viewRenderer é compartilhado pelo app todo, então guardamos uma cópia nossa
$this->viewRenderer = $viewRenderer->withViewPath(__DIR__ . '/views');
Enter fullscreen mode Exit fullscreen mode

O motivo é que os serviços são compartilhados. Se um controller mudasse o caminho das views no renderer compartilhado, todos os outros controllers seriam afetados. Com imutabilidade, isso não acontece. É também o que permite rodar o Yii3 em servidores de longa duração como o RoadRunner (existe o yiisoft/yii-runner-roadrunner), onde os mesmos objetos atendem muitas requisições.

Como eu organizo uma funcionalidade

O template já sugere um formato: src/Web/HomePage/Action.php com o template.php ao lado. Eu sigo essa ideia, agrupando por funcionalidade, e separo o que é web do que não é:

src/
  Post/
    PostRepository.php      # fala com o banco, não sabe nada de HTTP
  Web/
    Post/
      PostController.php    # HTTP: lê a requisição, devolve a resposta
      PostForm.php          # o que o formulário aceita + regras de validação
      views/
        index.php
        _form.php
  Migration/
    M261008033620CreatePostTable.php
Enter fullscreen mode Exit fullscreen mode

Quando adiciono uma funcionalidade, costumo seguir esta ordem:

  1. Uma migration para a tabela.
  2. Um repository (ou service) em src/<Funcionalidade>, sem nada de HTTP.
  3. Um form model, se tiver entrada do usuário.
  4. Uma action ou controller em src/Web/<Funcionalidade>.
  5. As rotas em config/common/routes.php.
  6. As views ao lado do controller.
  7. Uma definição em config/*/di/ só se algo novo for interface ou precisar de valores de config.

O repository não sabe que está sendo chamado de uma página web. A mesma classe pode ser usada depois num comando de console ou num endpoint de API, sem mudança. No meu blog, o mesmo PostRepository atende tanto as páginas do site quanto a API REST.

Vindo do Yii2

Resumindo: o que era implícito agora é explícito. Yii::$app->db vira um ConnectionInterface no construtor. Os components da config viram definições de DI. behaviors() e filtros viram middleware. Controllers não estendem nada, e o $this->render() vira um WebViewRenderer injetado. Active Record deixou de ser o padrão. Ele existe como pacote separado, mas o query builder resolve muita coisa.

No começo se digita mais. Em troca, você abre qualquer classe e vê tudo de que ela depende.

Próximo passo

Com isso em mente, o tutorial de CRUD deve parecer uma sequência de passos pequenos e óbvios: uma migration, um repository, um formulário, um controller, as rotas e as views. O código está em yurineves92/crud-yii3.

Se algo aqui não bate com a sua experiência com Yii3, me conta nos comentários. Também estou aprendendo.

Top comments (0)