sexta-feira, 16 de dezembro de 2016

O tempo passa, o tempo vue: uma alternativa para desenvolver sua SPA e seu backend sem tanto drama

Olá!

Hoje é dia de usarmos umas tecnologias que nos ajudem em vez de ficar no nosso caminho, e de preferência sem aquela curva de aprendizado. Não quer dizer que seja morto de simples, mas significa em verdade que temos muitas receitas para o bolo de laranja. Agora se aprume nessa cadeira, todo torto, e anote os ingredientes:


O resto vem na maré.

Como de costume, vamos começar criando um projeto. Esse vai se chamar evento-cliente:

mkdir evento-cliente
cd evento-cliente
npm init -y
mkdir src
touch src/main.js

Criado o projeto, hora de pedir ao npm todas as coisas bacanas que iremos usar:

npm install vue vue-router axios --save

npm install browserify vueify budo --save-dev

Para quem não conhece, o vue é mais um framework view-model baseado em componentes. ele se diferencia dos outros, sendo extremamente técnico e imparcial, por não ser feio.

Brincadeiras à parte, é bom ver que o mercado possui várias boas opções pra escolher. Mas sim, adiante. O seu package.json deve estar mais ou menos assim:

{
  "name": "evento-cliente",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.15.3",
    "vue": "^2.1.6",
    "vue-router": "^2.1.1"
  },
  "devDependencies": {
    "browserify": "^13.1.1",
    "budo": "^9.3.0",
    "vueify": "^9.4.0"
  }
}

Como o nosso projeto vai ter uma etapa de compilação, devemos montar os scripts para isso. Mude a seção de scripts do package.json pra ficar assim:

{
  "name": "evento-cliente",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "budo src/main.js:build.js --live --open",
    "build": "browserify src/main.js -o build.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.15.3",
    "vue": "^2.1.6",
    "vue-router": "^2.1.1"
  },
  "devDependencies": {
    "browserify": "^13.1.1",
    "budo": "^9.3.0",
    "vueify": "^9.4.0"
  }
}

Ainda nesse caminho, é preciso indicar o vueify como um transform do browserify:

{
  "name": "evento-cliente",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "budo src/main.js:build.js --live --open",
    "build": "browserify src/main.js -o build.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.15.3",
    "vue": "^2.1.6",
    "vue-router": "^2.1.1"
  },
  "devDependencies": {
    "browserify": "^13.1.1",
    "budo": "^9.3.0",
    "vueify": "^9.4.0"
  },
  "browserify": {
    "transform": [
      "vueify"
    ]
  }
}

Pronto, isso nos dará uma configuração de trabalho mínima. vamos de volta ao terminal e criemos mais alguns arquivos vazios:

touch src/eventos.vue
touch src/pessoas.vue
touch src/participantes.vue
touch src/baserouter.vue
touch src/serviceapi.js

Agora aos códigos. abra o src/main.js, lá vamos declarar nossa instância do view-model:

"use strict"

const div = document.createElement("div");
div.id="mountpoint";
document.body.appendChild(div);

const Vue = require('vue');
const VueRouter = require('vue-router');

Vue.use(VueRouter);

const router = new VueRouter({
  routes: [
    { path: '/', redirect: '/eventos' },
    { path: '/eventos', component: require("./eventos.vue") },
    { path: '/pessoas', component: require("./pessoas.vue") },
    { path: '/participantes', component: require("./participantes.vue") }
  ]
});

const vm = new Vue({
  router,
  render: (createElement) => {
    return createElement(require("./baserouter.vue"));
  }
}).$mount("#mountpoint");

No trecho de código acima, tem um bocado de coisas legais acontecendo:
  • Na criação do viewmodel  (new Vue) indicamos o router e uma render funcion. A sintaxe do es6 deixa a declaração do router mais limpa, evitando aquele router:router que éramos habituados a fazer.
  • Esses require são velhos conhecidos nossos e, em resumo, nos permitem modularizar o código client-side feito gente grande.
  • A fat arrow (traduz-se cê tá gorda) substitui nosso saudoso e ainda presente function e traz o this semântico de brinde. 
  • No vue, tudo é componente. nada de services, factories, controllers... nada.
  • SPA com vue usa a especificação de single-file-components dele. É por causa desses componentes com extensão .vue, inclusive, que precisamos do vueify no browserify.
  • A parte em que criamos uma div e adicionamos ela ao documento é um cosmético. Você deve ter notado que não criamos um arquivo HTML, e para este exemplo nem precisamos criar um. O budo inventa um pra você.
Agora abra o arquivo src/baserouter.vue:

<!-- baserouter.vue -->
<template>
  <div class="well">
    <a href="#/eventos">Eventos</a> |
    <a href="#/pessoas">Pessoas</a> |
    <a href="#/participantes">Participantes</a>
    <br/>
    <router-view></router-view>
  </div>
</template>

<script>
const baserouter = {
  name:"BaseRouter"
};
module.exports = baserouter;
</script>

<style>
  .well{
    position:absolute;
    top:5px;
    left:5px;
    bottom:5px;
    right:5px;
  }
</style>

Conforme falamos, tudo no vue é componente. E isso inclui o ponto de montagem da SPA, mais especificamente aquela tag <router-view></router-view> vista ali em cima.

Outra coisa interessante aqui é nossa primeira visão de um arquivo .vue de componente. eles sempre terão esse jeitão, um template, um script e um style.

Essa organização é útil para projetos realmente grandes, mas no pequenos não chega a ser um terrível incômodo. Pra parar os elogios, é a sintaxe mais honesta do mercado até o momento.

Os arquivos de componentes são pensados para usar com estruturas de montagem de projeto. Aqui estamos de browserify/vueify, mas o suporte a webpack existe e é muito bom.

O componente seguinte deve listar eventos:

<!-- eventos.vue -->
<template>
  <div>
    <h1>Eventos</h1>
    <label>Nome do evento</label>
    <input v-model="detalheevento.nomeevento" />
    <button @click="save()">Salvar</button>
    <button @click="del()">Excluir</button>
    <hr/>
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Nome</th>
        </tr>
      </thead>
      <tbody>
        <tr v-if="listaeventos.length == 0">
          <td colspan="2">Nenum evento encontrado</td>
        </tr>
        <tr v-for="evt in listaeventos" @click="sel(evt)">
          <td>{{evt.idevento}}</td>
          <td>{{evt.nomeevento}}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
  const Vue = require("vue");
  const api = require("./serviceapi");
  const eventos = {
    name: "Eventos",
    created() {
      this.list();
    },
    data() {
      return {
        detalheevento: {
          idevento: null,
          nomeevento: ""
        },
        listaeventos: []
      }
    },
    methods: {
      list() {
        api.eventos.list().then(ret => {
          this.listaeventos = ret.data;
        });
      },
      save() {
        api.eventos.save(this.detalheevento).then(ret => {
          this.detalheevento = { idevento: 0, nomeevento: "" }
          this.list();
        });
      },
      del() {
        api.eventos.del(this.detalheevento.idevento).then(ret => {
          this.detalheevento = { idevento: 0, nomeevento: "" }
          this.list();
        });
      },
      sel(ev) {
        this.detalheevento = ev;
      }
    }
  };
  module.exports = eventos;
</script>

<style scoped>
  table{
    width:100%;
  }
  th{
    text-align: left;
  }
</style>

Coisas começam a ficar interessantes:
  • os componentes ganham uma função chamada created que serve de PostConmstruct para os iniciados e para os leigos nos mistérios da Fé é um eventos disparado quando "entrarmos" neste componente.
  • v-model, click, v-if e v-for, eventos e bindings que se não lhe são familiares são pelo menos amigáveis de se tratar e se entender.
  • temos ainda um require para um javascript normal, o serviceapi.js, que veremos lá adiante. o bloco script dos módulos vue contam como scripts 100% legais para se trabalhar, logo temos conversa não apenas com outras camadas, mas outros paradigmas também.
  • se eu jogo um scoped na tag style, eu garanto que o css que eu colar ali não "vazará" para outros componentes. isso me deixa usar a priori definições css super genéricas mas estas não afetarão ninguém fora do meu componente. o componente vira de fato uma caixinha autossuficiente que você pode carregar para onde bem quiser, uma alegria.
O pessoas.vue é bem similar ao eventos.vue, portanto vamos olhar para o apiservice.js, que é mais legal:

// serviceapi.js
"use sctrict"
const axios = require("axios");

const api = axios.create({
  baseURL: 'http://localhost:3000',
});

const eventos = {
  list: () =>
    api.get("/evento/list"),
  save: (ev) =>
    api[ev.idevento ? "put" : "post"]("/evento/save", ev),
  del: (idevento) =>
    api.delete(`/evento/${idevento}`),
  participar: (idevento, idpessoa) =>
    api.post(`/participante/${idevento}/${idpessoa}`),
  desistir: (idevento, idpessoa) =>
    api.delete(`/participante/${idevento}/${idpessoa}`)
};

const pessoas = {
  list: () =>
    api.get("/pessoa/list"),
  save: (ev) =>
    api[ev.idevento ? "put" : "post"]("/pessoa/save", ev),
  del: (idpessoa) =>
    api.delete("/pessoas/" + idpessoa),
  participantes: (idevento) =>
    api.get("/participante/" + idevento),
};

exports.eventos = eventos;
exports.pessoas = pessoas;

Quem já fez um cliente de serviço? pois bem, temos aí um cliente de serviço usando axios, mas o vue é flexível e não te amarra a nenhum específico.

Destaque para a chamada ao create, onde podemos indicar onde nosso serviço se encontra.

usando as features do es6, já disponível para o povo sem a necessidade de babel, economizamos um outro quilo de código e assim deixamos as coisas mais simples de manter.

Nosso cliente rodamos com npm run dev na linha de comando, mas antes disso precisamos falar sobre o backend:

Crie uma segunda pasta e nesta pasta nosse segundo projeto. Crie ainda o esqueleto de alguns arquivos vazios, iremos preenchê-los e debatê-los ali adiante.

mkdir evento-servico
cd evento-servico
npm init -y 
knex init 
mkdir lib
touch index.js
touch lib/main.js
touch lib/dbconfig.js
touch lib/evento.js
touch lib/pessoa.js
npm install nodemon --save-dev
npm install express body-parser knex bookshelf sqlite3 pg --save

Temos aí um bom começo para o nosso backend.

Aproveite que já temos suporte a migrações (um oferecimento de knex init) e crie o esqueleto da migração inicial:

knex migrate:make esquema_inicial

Este comando criará um arquivo chamado migrations/20161216143607_esquema_inicial.js ou coisa parecida.

Esse arquivo de migração você usa pra criar as tabelas do sistema, usando a api do knex para isso:

exports.up = (knex, Promise) => {
  return knex.schema.createTable("evento", (tb) => {
    tb.increments("idevento");
    tb.string("nomeevento").notNullable();
  }).createTable("pessoa", (tb) => {
    tb.increments("idpessoa");
    tb.string("nomepessoa").notNullable();
  }).createTable("evento_pessoa", (tb) => {
    tb.integer("idevento").notNullable();
    tb.integer("idpessoa").notNullable();
    tb.primary(["idevento", "idpessoa"]);
  });
};

exports.down = (knex, Promise) => {
  return knex.schema
    .dropTable("evento_pessoa")
    .dropTable("pessoa")
    .dropTable("evento");
};

Nosso package.json do projeto de serviço deve ficar mais ou menos assim:

{
  "name": "evento-servico",
  "version": "1.0.0",
  "description": "",
  "main": "lib/main.js",
  "scripts": {
    "dev": "nodemon index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^1.11.0"
  },
  "dependencies": {
    "body-parser": "^1.15.2",
    "bookshelf": "^0.10.2",
    "express": "^4.14.0",
    "knex": "^0.12.6",
    "pg": "^6.1.2",
    "sqlite3": "^3.1.8"
  }
}

Na seção de scripts devemos indicar o nodemon como ferramenta de auxílio ao desenvolvimento.

O index.js segue:

const main = require("./lib/main");
main.startup();

Usamos ele só como ponto de entrada, deixando a amarração dos serviços dentro lib/main.js:

"use strict"
const express = require("express");
const bodyParser = require("body-parser");

const knex = require("./dbconfig").knex;

const app = express();

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header("Access-Control-Allow-Headers",
    "Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With");
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  next();
})

app.use(bodyParser.json({
  limit: 1024 * 1024
}));

app.use(bodyParser.raw({
  type: ['application/octet-stream', 'image/*', 'application/pdf'],
  limit: 10240 * 1024 // 10MB
}));

// TODO wire routes

app.use("/evento", require("./evento").router);
app.use("/pessoa", require("./pessoa").router);
app.use("/
participante", require("./participante").router);
exports.startup = () => {
  console.log("starting migration subsystem");
  knex.migrate.latest().then(() => {
    console.log("migration done!");
    let port = 3000;
    console.log("listening at port %s", port);
    app.listen(port);
  });
}

// expose app instance
exports.app = app;

Este script tem uns aspectos notáveis, a saber:

  • Após os require's comuns, express, body-parser e knex (oriundo do lib/dbconfig.js, que trataremos adiante) e da criação do app, temos a configuração necessária para o SOP não barrar o acesso das chamadas via script. Devemos indicar os endereços de origem que permitimos, os cabeçalhos e os verbos HTTP aceitos.
  • A configuração do body-parser para json indica o tamanho máximo de json que trabalharemos aqui: 1MB. porque? bom, porque eu quis. A outra configuração, o modo raw, é para uploads HTTP sem formulário que, acreditem, é a melhor coisa que a evolução do javascript já trouxe até hoje. Nosso exemplo não cobre upload, mas há outros artigos que tratam do assunto, então você pode se apoiar um pouco por lá caso precise.
  • A seção onde colamos as rotas no app chamam os módulos dedicados e colocam os routers deles publicados sob o nome de router. Está abstrato, mas vai fazer sentido logo ali.
  • Em vez de dispararmos o listen() do app imediatamente, criamos uma função que, além de fazer isso, garante que o banco de dados estará na versão mais recente. é essa função que usamos lá no index.js, mostrado anteriormente. A própria referência ao app é exportada também. o s exports, aliás, sempre vão aparecer o mais baixo possível no arquivo, posto que se um segundo módulo precisar deste, é bom que a importação relevante já esteja definida, assim escapamos de depender de um broker ou de simplesmente cair no "bug" do módulo vazio.

Agora, ao lib/dbconfig.js:

"use strict"

const env = process.env.NODE_ENV || "development";
console.log("we are on [%s] environment",env);

const knex = require("knex")(require("../knexfile")[env]);
exports.knex = knex;

const bookshelf = require("bookshelf")(knex);
exports.bookshelf = bookshelf;

Neste script o mais diferente é checarmos o NODE_ENV pra decidir que esquema de banco vamos usar. Por padrão, o knex vai lhe dar no knexfile.js três perfis:

  • development: o knex irá criar um banco sqlite3 pra você prototipar sem dor, sem culpa.
  • staging: este perfil deverá ser igual ao de produção, excetuando-se que é pra ser um outro servidor, um só pra testes in the real thing. vem pronto um perfil pra rodar com postgresql.
  • production: o perfil que será usado em produção.
Fora decidir qual perfil de ambiente usaremos, não temos mais nada de emocionante aqui. criamos o knex, o bookshelf, mas foi isso.

Vamos ao lib/evento.js:


"use strict"

const router = require("express").Router();

const Bookshelf = require("./dbconfig").bookshelf;

const Evento = Bookshelf.Model.extend({
  idAttribute: "idevento",
  tableName: "evento"
});

router.get("/list", (req, res) =>
  Evento.fetchAll().then((ret) => res.send(ret)));

router.post("/save", (req, res) =>
  Evento.forge(req.body).save().then((ret) => res.send(ret)));

router.put("/save", (req, res) =>
  Evento.forge({ idevento: req.body.idevento })
    .save(req.body).then((ret) => res.send(ret)));

router.delete("/:idevento", (req, res) => 
  Evento.forge({ idevento: req.params.idevento })
    .destroy().then(() => res.send("OK")));

exports.Evento = Evento;
exports.router = router;

Aqui temos duas coisas importantes acontecendo: a primeira é a definição do model de Evento usando os poderes do bookshelf. lá no final do arquivo, inclusive, temos o exports disso, caso um outro módulo precise dele. A segunda é a definição das rotas em um router igualmente publicado ao final do módulo.

No bookshelf, quando você chama sua coluna de "id" de qualquer coisa coisa diferente de "id", essa coluna deve ser indicada na definição do model. E isso de indicar a coluna de id vai se estender a várias outras ocasiões onde o id da tabela se fizer necessário, chamadas para o belongsTo ou ao hasMany também terão essa necessidade.

Mais valioso ainda, você pode usar o bookshelf para mapear visões de banco, embora operações de salvamento e deleção não prosperem. veja o lib/participante.js para melhor esclarecimento:

"use strict"

const router = require("express").Router();

const Bookshelf = require("./dbconfig").bookshelf;
const knex = require("./dbconfig").knex;

const Participante = Bookshelf.Model.extend({
  tableName: "vw_participante"
});

router.get("/:idevento", (req, res) =>
  Participante.fetchAll().then((ret) => res.send(ret)));

router.post("/:idevento/:idpessoa", (req, res) =>
  knex("evento_participante").insert(req.params).then((ret) => res.send("OK")));

router.delete("/:idevento/:idpessoa", (req, res) => 
  knex("evento_participante").del().where(req.params).then(() => res.send("OK")));

exports.Evento = Participante;
exports.router = router;

e, vw_participante, indicado como tabela na definição de model é definido da num migrate assim:

// 20161216214026_cria_view_participantes.js
exports.up = (knex, Promise) => {
  return knex.raw("create view vw_participante as select * from pessoa natural join evento_pessoa");
};

exports.down = (knex, Promise) => {
  return knex.raw("drop view vw_participante");
};


Note, adicionalmente, que no participante.js, na hora de adicionar ou remover participantes, não apelamos para o model, mas sim para o knex, que está ali, amigo pra toda hora.

Bookshelf+Knex, em resumo, não vão ficar no seu caminho, mas sim lhe ajudar no que puderem.

Para rodar o projeto, dois terminais são necessários. um para o backend e outro para o frontend. É o caminho mais sensato e difundido, posto que vai se parecer muito com a realidade da publicação para a web em 2016.

O código deste artigo pode ser encontrado aqui, espero que lhe seja útil assim como foi pra mim.

Sem mais.