Como projetar aplicações com Spring e AngularJS para a nuvem
Aprenda neste artigo como projetar a arquitetura de uma aplicação para a nuvem utilizando Spring Boot e AngularJS.
No desenvolvimento de aplicações, seja para web, desktop ou dispositivos móveis, além de todos os requisitos funcionais, a equipe de desenvolvimento precisa se preocupar também com requisitos não funcionais, como segurança, desempenho e escalabilidade, itens esses que constituem o alicerce da aplicação e que, na maioria dos casos, precisam ser levados em consideração desde o início, sob a pena de ser necessário reconstruir toda a aplicação.
Guia do artigo:
- Frameworks
- Arquitetura
- Proposta
- Back-end
- Modelo
- Repositórios
- Serviços
- Controladores
- Configurações
- Segurança
- Front-End
- Autenticação
- Implantação
- Front-end no S3
- Banco de dados
- JVM
No entanto, a modelagem de uma arquitetura robusta, escalável, que permita à equipe alta produtividade, desenvolvimento ágil e que faça uso de recursos de infraestrutura da nuvem em seu favor demanda tempo e tem impacto no orçamento do projeto. Esses itens (tempo e orçamento) nem sempre estão disponíveis, principalmente para quem está desenvolvendo uma aplicação como hobby e para pequenas startups. Por outro lado, o tempo e o dinheiro investidos nessa etapa reduzem o prazo e simplificam o desenvolvimento e a manutenção. Além disso, criar uma estrutura a partir do zero ou com pouquíssimo material prévio para cada novo projeto é moroso e improdutivo.
Uma das primeiras e mais difíceis tarefas quando se está iniciando um novo projeto é a escolha das tecnologias que serão utilizadas. Isso porque, com raras exceções, essas decisões acompanharão o projeto por toda a sua vida e mudar a tecnologia utilizada normalmente sai caro. E quando o assunto são as tecnologias que servirão de base para o projeto, isso inclui linguagens, plataformas utilizadas, servidor de aplicações, o modo de armazenamento dos dados, entre outros itens funcionais e não funcionais que merecem atenção.
Felizmente, é possível que uma mesma implementação de arquitetura sirva como referência para outras aplicações ou mesmo que seja reutilizada integralmente pela maioria das aplicações web. E é isso que este artigo irá mostrar: uma proposta de arquitetura que pode ser utilizada no modelo mais comum de desenvolvimento web, que se apoia em frameworks e ferramentas consolidadas e que está pronta para ser implantada na nuvem. A intenção aqui não é apresentar a “arquitetura definitiva para aplicações web”, mas sim um conjunto de soluções de problemas comuns a esse tipo de desenvolvimento, além de fornecer conhecimento para que os desenvolvedores possam adaptar as ideias às suas necessidades e aos requisitos dos seus projetos.
Frameworks
Frameworks são conjuntos de abstrações de códigos e estruturas genéricas que devem servir como suporte para uma complementação que possa criar funcionalidades específicas. Os frameworks são responsáveis por comandar o fluxo de execução das aplicações ou atividades. Não os confunda com bibliotecas, que são códigos completos que não ditam o fluxo da aplicação e não precisam de complementação para funcionar. Neste artigo serão utilizados dois frameworks de grande penetração no mercado, vasta documentação e amplo suporte da comunidade: AngularJS e Spring.
O desenvolvimento para web por muito tempo esbarrou em problemas de compatibilidade entre navegadores, quando utilizar JavaScript muitas vezes trazia mais problemas que soluções. No entanto, isso mudou bastante com o surgimento de bibliotecas como o jQuery e frameworks como o Dojo. Foi nessa onda que surgiu o AngularJS, framework JavaScript mantido principalmente pelo Google e usado para o desenvolvimento de aplicações web ricas, seguindo a arquitetura MVW (Model-View-Whatever). Os padrões arquiteturais normalmente utilizados nas aplicações front-end são MVC (Model-View-Controller), MVP (Model-View-Presenter) e MVVM (Model-View-ViewModel). Como o AngularJS funciona bem com todos eles, a equipe cunhou o termo MVW, que significa algo como Model-View-e o que funcionar no seu projeto.
Em resumo, o AngularJS estende as funcionalidades do HTML para simplificar e agilizar o desenvolvimento no lado cliente, permitindo a rápida criação de aplicações com excelente usabilidade, além de abstrair e facilitar o uso de recursos importantes, como chamadas a APIs REST e controle do fluxo de páginas.
Do lado servidor, a grande preocupação da maioria dos frameworks sempre foi fornecer ao desenvolvedor toda a infraestrutura possível para que ele pudesse manter o foco na codificação de funcionalidades importantes para o negócio e simplesmente utilizar recursos como segurança e controle transacional. Esse foi, desde o início, o grande apelo dos EJBs, mas a infraestrutura de servidor de aplicações e o ambiente pesado os mantiveram no mundo das grandes aplicações corporativas. O Spring Framework traz todos esses recursos em um ambiente muito mais leve, simples e acessível, permitindo que possa ser utilizado em praticamente qualquer aplicação.
Arquitetura
A arquitetura de um sistema computacional ou de uma aplicação diz respeito aos elementos que servem de base para a sua construção e como esses interagem entre si para satisfazer as necessidades de funcionamento, desempenho, segurança e usabilidade. É o tópico que trata das decisões de design que guiarão o desenvolvimento e o andamento do projeto.
Entre os requisitos não funcionais que devem ser observados pela arquitetura estão a reusabilidade e a escalabilidade. A primeira existe somente em tempo de desenvolvimento e trata da capacidade dos componentes de serem reutilizados (sejam métodos, classes ou serviços). Esse item impacta diretamente o desenvolvimento e a manutenção uma vez que, quanto maior a reusabilidade, menor a quantidade de código a ser escrito e mantido e mais simples será a aplicação. Já a escalabilidade só faz sentido em tempo de execução e refere-se à capacidade do sistema ou aplicação de suportar o crescimento de trabalho de maneira uniforme, preferencialmente com o menor esforço possível.
O que será mostrado aqui é uma forma de utilizar esses conceitos na prática, com técnicas e padrões de projeto dos quais a equipe poderá se beneficiar, de acordo com os requisitos e necessidades do projeto.
Proposta
A proposta de arquitetura apresentada neste artigo está dividida em duas partes, bastante conhecidas pelos desenvolvedores: front-end e back-end. O front-end é a camada que interage com o usuário e, assim sendo, é nela onde são aplicados os conceitos de usabilidade e estética para que a aplicação seja bonita e simples. O back-end, por sua vez, é a camada responsável por executar as funcionalidades associadas ao negócio da aplicação e, dessa forma, é nela que são implementadas as regras de validação e execução, a persistência dos dados e as integrações com outros sistemas.
A ideia é que as duas camadas sejam desacopladas fisicamente, como se fossem duas aplicações implantadas em estruturas diferentes e utilizando recursos diferentes. A aplicação AngularJS é o cliente, e comporta as páginas HTML, os arquivos JavaScript, as imagens e outros recursos, que serão providos por um servidor de arquivos comum, como o Apache, NGiNX ou outro que esteja disponível. Esses normalmente são mais rápidos e estáveis que os servidores de aplicações para a execução desse tipo de tarefa, onde os arquivos não sofrem modificações no seu conteúdo.
Já a aplicação back-end será implementada em Spring, tendo como base o projeto Spring Boot, que simplifica bastante o desenvolvimento, encapsula o uso de um servidor de aplicações e fornece uma API REST para que a aplicação cliente possa executar suas tarefas. Ao contrário das aplicações que utilizam páginas dinâmicas, como JSP e JSF, os serviços REST não precisam fazer a geração de páginas, o que os tornam significativamente mais leves, exigindo menos recursos do servidor e possibilitando um desenvolvimento mais simples e produtivo.
Os serviços back-end serão fornecidos somente como stateless (veja o BOX1), o que significa que não precisarão ser criados e destruídos para cada cliente ou sessão, reduzindo o processamento e a quantidade de memória necessária no back-end. Além disso, por não terem necessidade de afinidade com o usuário da requisição, isto é, de uma instância do serviço não ser dedicada a um único cliente, a tarefa de balanceamento da carga de trabalho se torna muito mais simples.
Para garantir a segurança da aplicação, a autenticação será feita por meio de um token de segurança. Basicamente, na primeira requisição, a aplicação cliente solicita um token, informando um nome de usuário e uma senha. Se os dados estiverem corretos, o back-end fornece um token, que estará associado ao usuário e deverá ser informado em cada requisição. Enquanto o token for válido, a aplicação cliente terá autorização para executar.
A Figura 1 mostra como poderá ser implantada a aplicação, incluindo, além do front-end e do back-end, elementos de infraestrutura, como balanceadores de carga e banco de dados.
Um balanceador de carga de trabalho (workloader) é o componente responsável por distribuir, de acordo com critérios estabelecidos, a execução do trabalho requisitado entre os recursos disponíveis. Assim, um balanceador pode, por exemplo, entregar uma requisição para cada servidor existente, em um sistema de rodízio.
BOX 1. Serviço stateless
Um serviço stateless é aquele que, como o nome sugere, não tem estado, isto é, tudo que é necessário para sua execução está nos dados da requisição e não é preciso que um outro serviço seja executado antes ou depois dele para que a sua tarefa se complete.
Back-end
Normalmente, ao desenvolver uma aplicação web, assim como para a implantação de aplicações em ambientes de produção, é preciso configurar um servidor de aplicações (Tomcat ou Jetty, por exemplo) e implantar o pacote WAR nesse servidor. Na proposta de arquitetura deste artigo, será mostrado o uso do Spring Boot, solução que facilita a criação de aplicações web standalone de forma realmente muito simples, sem a necessidade de utilização explícita de um servidor de aplicações (o framework cuida disso), sem arquivos de configuração, sem geração de código e sem a necessidade de deploy.
A criação de um novo projeto é sempre uma tarefa chata e repetitiva, quando o desenvolvedor precisa criar estruturas de pastas e pacotes, arquivos de build e outros recursos, normalmente muito parecidos. Uma excelente ferramenta para começar um novo projeto é o Spring Initializr (veja a seção Links), uma aplicação web do projeto Spring que realiza esse trabalho repetitivo rapidamente e com um número mínimo de parâmetros. Ao acessar o Initializr é possível selecionar as preferências do projeto, como o gerenciador de dependências, a versão do framework Spring, os demais módulos que se deseja incluir, como segurança e persistência, e, por fim, ao clicar no botão Generate Project, gerar um esqueleto do projeto com um pom.xml (vide Listagem 1) e uma classe de inicialização da aplicação (vide Listagem 2) com um método main().
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>demo</name>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.3.5.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Além do que é criado por padrão, serão necessárias mais algumas dependências para as funcionalidades desejadas, como a capacidade de conversar no protocolo REST, utilização do formato JSON e acesso a bancos de dados. Isso pode ser feito de duas formas: selecionando as dependências desejadas no próprio Initializr, ou adicionando essas dependências manualmente no pom.xml, como na Listagem 3. A vantagem da primeira opção é que não é necessário conhecer o groupId ou o artifactId do Maven específicos para o módulo desejado, sendo possível fazê-lo somente com o seu nome. A escolha de qual opção utilizar fica a cargo do desenvolvedor.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.9.10</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
Uma das preocupações da arquitetura aqui proposta, como visto anteriormente, é a simplicidade e reusabilidade do código, com o objetivo de aumentar a produtividade e melhorar a manutenibilidade. Sendo assim, a aplicação back-end será dividida nas camadas mostradas na Figura 2. A separação de responsabilidades é um conceito de grande importância e, por isso, deve servir de guia para manter a arquitetura da aplicação nos trilhos.
Uma excelente ideia para o projeto é o padrão chamado Layer SuperType, que orienta a criação de um supertipo para cada camada da aplicação. Segundo Martin Fowler, não é incomum que todas as classes de uma mesma camada tenham métodos e atributos que se repetem com pouca ou nenhuma variação. Logo, o melhor a fazer é colocar todo esse comportamento comum no supertipo da camada, reduzindo o código escrito e replicado.
Modelo
Na camada de modelo estão as entidades mapeadas do banco de dados, que servirão de parâmetro na definição dos supertipos das demais camadas da aplicação. Além dos mapeamentos, é uma boa ideia utilizar os recursos do Hibernate Validator para fazer as validações básicas da entidade — obrigatoriedade, valores mínimos e máximos, tamanho das strings, etc. — colocando anotações na própria classe, pois o seu uso é prático e essas restrições estão intimamente relacionadas à integridade da entidade. A Listagem 4 mostra o supertipo dessa camada.
package com.exemplo.demo.modelo;
@MappedSuperclass
public abstract class BaseModelo implements Serializable {
private static final long serialVersionUID = 1L;
}
Inicialmente, essa classe não tem nenhum código que será herdado pelas outras classes, mas atributos comuns e seus mapeamentos podem ser adicionados. Algumas aplicações podem ter um ID incremental, a data e hora de inserção e da última atualização em todas as entidades, por exemplo. Por ser uma classe abstrata, ela precisa ser anotada com @MappedSuperclass para indicar que não deve ser interpretada como uma entidade concreta e, desse modo, que deverá ser estendida por todas as demais entidades, garantindo o mesmo tipo e comportamento, quando necessário, a todas elas.
Repositórios
Agora que a superclasse que servirá como parâmetro às demais camadas está pronta, pode-se definir a próxima camada na pilha da aplicação: a camada de acesso aos dados, que como o nome indica, é responsável por recuperar, incluir e alterar os dados.
Certamente, muito do código escrito nessa camada para executar as tarefas mais comuns é repetitivo e pode ser parametrizado. No entanto, o Spring é capaz de fazer melhor e reduzir drasticamente a quantidade de código escrito, sendo necessário somente definir a interface do repositório. A partir disso, fica a cargo framework, em tempo de execução, gerar a implementação.
package com.example.demo.repositorios;
@NoRepositoryBean
public interface BaseRepositorio<T extends BaseModelo> extends PagingAndSortingRepository<T, Long> {
}
Para que essa “mágica” aconteça, é claro que algumas regras devem ser seguidas para informar ao Spring qual código deve ser gerado, começando pela hierarquia da interface. Embora não seja obrigatório, é uma boa ideia estender interfaces do framework para herdar suas definições, reduzindo o código e mantendo um padrão de contrato, mesmo em projetos diferentes. Por isso, o supertipo dessa camada, mostrado na Listagem 5, estende PagingAndSortingRepository, que define métodos de listagem com suporte à paginação e ordenação, e também os métodos para salvar, obter e excluir, que podem ser vistos em mais detalhes na Listagem 6. Além disso, o supertipo também é parametrizado com uma entidade que estende de BaseModelo, permitindo que somente as classes que pertencem a essa hierarquia possam ter seus próprios repositórios.
Como a interface BaseRepositorio é o supertipo da camada de repositórios e não irá gerar uma classe concreta, ela deve ser anotada com @NoRepositoryBean para evitar um erro na inicialização da aplicação. Isso ocorre porque o parâmetro T só será definido pelas interfaces que estendem BaseRepositorio e o Spring não saberia como gerar a classe sem ele.
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
<S extends T> S save(S entity);
<S extends T> Iterable<S> save(Iterable<S> entities);
T findOne(ID id);
boolean exists(ID id);
Iterable<T> findAll();
Iterable<T> findAll(Iterable<ID> ids);
long count();
void delete(ID id);
void delete(T entity);
void delete(Iterable<? extends T> entities);
void deleteAll();
Serviços
A próxima camada apresentada é a de serviços. Responsável pela lógica de negócio da aplicação, é nela que estarão as regras de validação mais complexas, como quais as condições pré-existentes para que os dados sejam persistidos e a interação entre entidades, além de orquestrar diferentes serviços para a execução de uma tarefa. Em uma aplicação de venda de passagens, por exemplo, o serviço responsável pela reserva deverá orquestrar os serviços necessários para verificação da disponibilidade de voo, reserva de acentos e pagamentos. Por ser a detentora das regras de negócio e coordenar a interação entre outros serviços, essa classe também é a responsável por interagir com o repositório para acessar ou persistir os dados.
Na superclasse da camada de serviços (veja a Listagem 7) estarão os métodos comuns, públicos e protegidos a todos os serviços da aplicação. Esses devem ser implementados de forma genérica, deixando para as classes concretas fornecer o código específico de cada caso. Por exemplo, o método save pode invocar um método protected que faz a validação dos dados antes de persisti-los. Nesse caso, a implementação padrão pode não fazer validação alguma, deixando essa tarefa a critério das subclasses. As classes concretas, por sua vez, além de complementar o código do supertipo, precisam ser anotadas com @Service e @Transactional. A primeira anotação serve para identificá-la como um service para o framework e a segunda, para indicar que existe controle transacional nesse componente. Isso é necessário porque essas classes realizarão tarefas de alteração no banco de dados.
package com.example.demo.services;
public abstract class BaseServicos<T extends BaseModelo> {
public void delete(Long id) { ... }
@Transactional(propagation=Propagation.NOT_SUPPORTED)
public T get(Long id) { ... }
@Transactional(propagation=Propagation.NOT_SUPPORTED)
public Page<T> list(Integer page) { ... }
public T save(T object) { ... }
}
Nos métodos que realizam consulta ao banco de dados sem realizar qualquer alteração é uma boa ideia utilizar a anotação @Transactional(propagation=Propagation.NOT_SUPPORTED). Isso indica ao framework que não precisa haver controle transacional para essa operação, economizando recursos tanto no servidor de aplicações quanto no banco de dados.
Controladores
A camada de controle deve ser responsável por receber as requisições da aplicação cliente, realizar transformações e repassar as requisições à camada de serviços. Assim como ocorre com outros componentes, criar controllers no Spring é fácil e pode ser feito anotando a classe com @RestController, o que sinaliza que esse controle conversa com as aplicações clientes por meio do protocolo REST. Nesse caso, não é necessária nenhuma anotação ou configuração adicional para informar ao Spring que tanto o response quanto o request devem utilizar o formato JSON. Assim como nas outras camadas, deve haver uma superclasse para essa também (vide Listagem 8).
package com.example.demo.controladores;
public abstract class BaseControlador<T extends BaseModelo> {
@RequestMapping(value = "/", method = RequestMethod.DELETE)
public void delete(@PathVariable Long id, HttpServletRequest request, HttpServletResponse response) { ... }
@RequestMapping(value = "/", method = RequestMethod.GET)
public T get(@PathVariable("id") Long id, HttpServletRequest request, HttpServletResponse response) { ... }
@RequestMapping(value = "", method = RequestMethod.GET)
public Page<T> list(@RequestParam(value = "p", required = false, defaultValue = "0") Integer page, HttpServletRequest request, HttpServletResponse response) { ... }
@RequestMapping(value = "", method = RequestMethod.POST)
public T save(@Validated @RequestBody T object, HttpServletRequest request, HttpServletResponse response) { ... }
}
Como nos demais casos de superclasses de camada, essa é uma classe abstrata e por isso não possui a anotação de implementação (@RestController neste caso) que deve ser colocada nas classes concretas. A anotação @RequestMapping, por sua vez, define a URI de acesso ao controller e deve ser declarada tanto na classe concreta, para especificar o recurso manipulado por ela, quanto nos métodos, para especificar em quais condições cada método será acionado.
Seguindo as convenções de nomenclatura de recursos para APIs REST, a URI de cada controller deve ser o nome do recurso por ele manipulado, no plural. Logo, tomando o exemplo de uma aplicação de venda de passagens, a URI do controller que manipula a entidade Reserva seria /reservas. Ainda de acordo com a convenção REST, os verbos HTTP devem ser utilizados para definir as operações realizadas em um determinado recurso, sendo o método GET para recuperar e os métodos POST e/ou PUT para gravar. Assim, se a URI /reservas for usada com o método GET, o controller deve retornar a lista de reservas, mas se o método utilizado for POST, o controller irá salvar o objeto JSON recebido. Da mesma forma, se a URI /reservas/1234 for utilizada com o método GET, o controller retornará essa reserva no formato JSON, se for utilizado o POST, os dados da reserva serão atualizados, e ser for DELETE, a reserva será excluída.
Configurações
Agora que todas as camadas do back-end estão criadas, é necessário configurar a aplicação para que os componentes sejam carregados pelo Spring. Nas versões anteriores do framework, essa tarefa era feita em arquivos XML nos lugares mais diversos e com uma sintaxe complicada. Por outro lado, as versões mais atuais dão suporte à configuração por meio de anotações, como acontece nos componentes. Essa abordagem facilita o desenvolvimento e a manutenção da aplicação, já que tudo está no mesmo lugar. Porém, alterar essa configuração em tempo de execução não é possível, pois o código precisa ser recompilado e reimplantado.
Para informar ao Spring onde estão os componentes, deve ser utilizada a anotação @ComponentScan na classe de entrada da aplicação. Essa anotação tem o parâmetro basePackages, que deve ser preenchido com a lista de pacotes, separados por vírgula, que serão varridos à procura dos componentes devidamente anotados com @Service, @RestController e @Component. Nesse caso, o trecho que será adicionado à classe deve ser o seguinte:
@ComponentScan(basePackages = "com.example.demo.controladores,com.example.demo.servicos")
A anotação @Component deve ser utilizada para componentes que não são controladores e nem serviços, em classes que fornecem métodos para execução de tarefas genéricas, que não têm relação direta com o negócio da aplicação, como ler os dados de um arquivo ou converter valores. A vantagem de anotar a classe, ao invés de criar uma classe com métodos estáticos, é que, dessa forma, é possível usufruir dos recursos do framework, como injeção de dependência.
Embora permita o uso de anotações, algumas das configurações do comportamento do framework só podem ser feitas por meio de properties, e para isso o Spring conta com um engenhoso recurso. Se houver um arquivo chamado application.properties no classpath da aplicação, esse arquivo será lido e suas propriedades serão carregadas. Acontece que é bastante comum que se utilize um valor no ambiente de desenvolvimento e outro no ambiente de produção, e isso pode causar problemas. O desenvolvedor acaba esquecendo de alterar as configurações e o arquivo de desenvolvimento passa a ser utilizado em produção ou o inverso. Por isso, o mecanismo de configurações tem o conceito de perfil, parecido com o do Maven.
Assim como no arquivo pom.xml, no arquivo de properties é possível definir qual o perfil padrão utilizado em tempo de execução. Isso é feito por meio da propriedade spring.profiles.active. Caso ela tenha um valor, o framework irá carregar as propriedades do arquivo application-<perfil>.properties, se ele existir, e sobrepor os seus valores no arquivo original. Assim, se a propriedade spring.profiles.active tiver o valor dev, por exemplo, e existir um arquivo chamado application-dev.properties, esse será carregado e seus valores irão sobrepor os valores do arquivo application.properties.
O Spring possui um grande número de propriedades para configurar os mais diversos comportamentos da aplicação, como a porta usada para acesso HTTP. Uma propriedade que merece ser mencionada aqui é a security.sessions=NEVER. Com ela, nenhuma sessão será criada no lado servidor, o que economiza recursos e simplifica a escalabilidade da aplicação.
Deve-se evitar o armazenamento de dados relativos ao cliente na sessão do servidor de aplicações. Essa prática, além de onerar o servidor, complica o escalonamento da aplicação, uma vez que exige o compartilhamento de sessões. Uma boa alternativa é utilizar um banco de dados NoSQL, do tipo document base ou key-value, de preferência em memória.
Como uma das premissas da arquitetura proposta é que a aplicação front-end e a back-end fiquem fisicamente em servidores diferentes, é possível que, dependendo do local de hospedadagem, o acesso a esses servidores, ou conjunto de servidores, se dê por meio de domínios diferentes. Isso leva a um problema comum, conhecido por CORS (Cross-Origin Resource Sharing). Para habilitar esse recurso na aplicação, deve-se definir, no arquivo application.properties, as propriedades com prefixo endpoints.cors de acordo com as necessidades.
O mecanismo de CORS permite restringir o acesso a APIs entre sites de domínios diferentes. Isso se dá porque os navegadores modernos impedem que uma chamada via AJAX seja feita para um servidor em um domínio que não seja o do site acessado, a menos que o servidor explicite a autorização por meio de um cabeçalho HTTP.
Segurança
Com a aplicação do lado servidor estruturada, é preciso pensar na segurança. Nesse caso, a aplicação utilizará um mecanismo de autenticação OAuth2 baseado em token para as chamadas à API REST.
Nesse modelo, o fluxo de autenticação funciona da seguinte forma: a aplicação cliente envia as credenciais do usuário, normalmente login e senha, para o servidor. O servidor valida essas credenciais e, em caso positivo, retorna à aplicação cliente um token. A partir daí, a cada requisição enviada pelo cliente, o token deve ser enviado junto, no cabeçalho HTTP, para ser validado no servidor.
Para que isso tudo funcione corretamente, deve-se configurar o módulo de segurança no Spring. Isso pode ser feito adicionando a dependência do módulo às configurações do Maven ao criar o esqueleto do projeto no Initializr, selecionando Security na lista de dependências, ou adicionando manualmente o trecho de código da Listagem 9 ao arquivo pom.xml.
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
Agora é hora de utilizar os recursos de configuração programática para definir como a segurança irá funcionar. Em uma nova classe, que aqui será chamada de OAuth2ServerConfiguration, especificaremos as configurações necessárias. A classe deve ficar no pacote com.example.demo.security e ser anotada com @Configuration. No entanto, para que ela seja corretamente lida pelo Spring, é preciso adicionar esse pacote à lista de pacotes na anotação @ComponentScan da classe de entrada.
Uma boa maneira de organizar as configurações é adicionar duas classes estáticas à OAuth2ServerConfiguration: uma para a configuração da autorização, onde são definidos os recursos protegidos da aplicação; e outra para configurar o gerenciamento dos tokens de autenticação.
Na Listagem 10 está um exemplo de como a configuração pode ser feita. Na documentação do framework pode-se verificar os detalhes e configurações que satisfazem às necessidades de cada projeto. É possível, por exemplo, definir um serviço responsável por recuperar as informações do usuário a partir das credenciais, caso o projeto precise de um modelo mais elaborado para representar o usuário autenticado.
package com.example.demo.security;
@Configuration
public class OAuth2ServerConfiguration {
@Configuration
@EnableResourceServer
protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { ... }
@Configuration
@EnableAuthorizationServer
protected static class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { ... }
}
}
Quando um usuário é autenticado com sucesso e um token é gerado, esse token deve ser armazenado em algum lugar para poder ser validado depois. É no método configure() da classe AuthorizationServerConfigurerAdapter que esse lugar é informado. Para isso, configure() recebe como argumento um objeto do tipo AuthorizationServerEndpointsConfigurer, que pode ser utilizado para alterar diversas configurações de autenticação, entre elas o tokenStore, que é o mecanismo de armazenamento de tokens.
O framework oferece, por padrão, dois tipos de armazenamento de tokens: em memória ou em um banco de dados por meio de acesso JDBC. Obviamente o primeiro é mais rápido e deve ser utilizado em ambiente de desenvolvimento. O segundo, por sua vez, pode ser adotado em ambiente de produção, pois permite que vários servidores compartilhem os mesmos tokens de forma bastante simples. Uma boa opção para esse armazenamento é utilizar um banco de dados em memória, como o H2.
Front-End
Há vários anos, no início do desenvolvimento das aplicações web, o JavaScript era usado somente para realizar algumas validações no lado cliente ou executar alguma animação. Na maioria das vezes, coisas puramente estéticas, sem impacto real no funcionamento da aplicação. Isso porque, além de ter um desempenho ruim, as diferenças entre navegadores tornavam as coisas bastante complexas para fazer algo importante, uma vez que era de responsabilidade do desenvolvedor resolver essas diferenças.
Com o aumento do poder de processamento nos computadores pessoais e a guerra dos navegadores, os fabricantes passaram a investir mais nos mecanismos de execução de JavaScript, melhorando o seu desempenho e seguindo um caminho para a redução das diferenças entre eles. A linguagem passou então a ganhar mais importância e relevância e começaram a surgir bibliotecas e frameworks que aproveitam melhor suas capacidades.
Entre eles, surgiu o AngularJS, que é um framework que segue um novo paradigma de aplicações web, chamado SPA (Single Page Application), oferecendo suporte a interfaces de usuário ricas, sem recarregar a página inteira e preocupadas com a beleza e a usabilidade. As funcionalidades e a modularização do framework o tornam uma excelente opção para esse tipo de aplicação, entregando ao desenvolvedor facilidade de uso e alta produtividade.
Embora não costume ter uma interface web como o Spring Initializr, o AngularJS possui várias ferramentas para criar o esqueleto de um novo projeto, como o Angular Seed, que não é mantido pela equipe do AngularJS, e o Yeoman, que também pode ser usado para outros tipos de projetos, como Node.js. Essas opções são simples de usar e não deve haver dificuldades para o desenvolvedor. Por isso, nenhuma delas será abordada neste artigo.
Como explicado anteriormente, o AngularJS é um framework MVW, o que significa que ele funciona bem com qualquer padrão de projeto MVC, MVP ou MVVM. Assim sendo, aqui será adotado o padrão MVC.
Ao iniciar uma aplicação construída sobre o AngularJS, a página HTML inicial é carregada, assim como o seu script. Esse script contém, entre outras coisas, a definição de states (ou rotas, dependendo de qual recurso for utilizado), que sinaliza qual view deve ser carregada de acordo com a situação (state) da aplicação. Desse modo, pode haver, por exemplo, um state chamado home e outro chamado about, de forma que a navegação entre as páginas se dá por meio das mudanças de state.
Do mesmo modo que ocorre no back-end, a lógica de execução deve ser implementada nos controllers, uma vez que esses componentes são os responsáveis por alterar estados e controlar o uso da interface do usuário. Assim como há, normalmente, uma classe RestController para cada recurso no back-end, um bom nível de granularidade para essa camada, pelo menos inicialmente, é que haja um controller para cada view. À medida que os componentes são reutilizados, é possível perceber que alguns dos controladores podem ser divididos para melhorar a coesão e aumentar o reuso.
Ainda que seja comum executar chamadas às APIs REST nos controladores, vê-se como uma boa prática encapsular essas chamadas em services. Para facilitar isso, o framework oferece uma boa abstração para a execução de chamadas remotas via AJAX, semelhante ao jQuery, o que facilita bastante a vida do desenvolvedor. Ao colocar as chamadas às APIs em serviços, obviamente um para cada resource, torna-se possível que um determinado controller reutilize diferentes serviços na mesma tela, por exemplo.
Além disso, o framework ainda coloca à disposição uma série de funcionalidades que ajudam significativamente no desenvolvimento, livrando o programador da preocupação com as minúcias de baixo nível da linguagem JavaScript.
Autenticação
Da mesma forma que foi necessário incluir um mecanismo para garantir a segurança no back-end, é importante que algo parecido seja feito no front-end também. Contudo, desta vez, isso não implica exatamente em uma questão de segurança, mas sim de usabilidade, dependendo do funcionamento da aplicação. Isso porque, como o back-end irá verificar a autenticação da aplicação cliente, as requisições serão negadas caso o front-end não verifique. Sendo assim, para evitar que erros de autenticação apareçam para o usuário que ainda não efetuou login ou que teve a sessão expirada, é importante que o mecanismo esteja sincronizado.
Para isso, será criado um serviço de autenticação na aplicação cliente. A ideia é que ele seja responsável por tudo relacionado à aplicação, como verificar se a autenticação foi realizada e se está válida, realizar a atualização do token se necessário, recuperar os dados do usuário logado e, é claro, realizar login e logout.
Uma vez que o serviço de autenticação esteja criado, deve ser adicionado um event listener ao mecanismo de manutenção do estado da aplicação, o $stateProvider. Ao receber a notificação de stateChangeStart, ou seja, de que a aplicação está iniciando a mudança para um novo estado, o listener solicita ao serviço de autenticação que verifique se há um usuário autenticado. Em caso negativo, o usuário é redirecionado para a página de login.
Lembre-se que o serviço de autenticação deve utilizar algum recurso de persistência para armazenar o token de autenticação, certificando-se que esse seja removido quando o usuário efetuar logout. Isso pode ser feito por meio de cookies. A vantagem de usar cookies ao invés do armazenamento de dados do navegador é que é possível definir um tempo para que esse expire, fazendo com que tenhamos logout automático. As Listagens 11 e 12 mostram como se dá esse fluxo.
$rootScope.$on('$stateChangeStart',
function (event, toState) {
if (!auth.isAuthorized() && toState.isLogin != true) {
$state.go('access.signin');
event.preventDefault();
}
});
Listagem 12. Serviço de autenticação.
function isAuthorized() {
try {
if (header == null) {
setAuthorization(getAuthorization());
}
return header != null;
} catch (error) {
}
}
function setAuthorization(authorization) {
header = "Bearer " + authorization.access_token;
$cookies.put(CONFIG.AUTH, JSON.stringify(authorization), {expires: moment().add(30, 'minutes').toDate()});
}
function getAuthorization() {
var authorization = null;
try {
authorization = JSON.parse($cookies.get(CONFIG.AUTH));
} catch (Exception) {
}
return authorization;
}
Há um bug relacionado a esse fluxo aberto no GitHub do componente ui-router. Para contorná-lo, deve ser utilizado o seguinte código, logo após a configuração dos estados (assumindo que app.home é o estado padrão da aplicação):
$urlRouterProvider.otherwise(function ($injector, $location) {
var $state = $injector.get("$state");
$state.go("app.home");
});
Ao realizar o login, o controller responsável por essa tela deverá utilizar o serviço de autenticação para executar a tarefa. Esse último, por sua vez, autentica o usuário na aplicação back-end, o qual só então deverá ser direcionado para a página inicial da aplicação.
Quando o serviço de autenticação é invocado para executar, deverá verificar se já existe um token armazenado em um cookie válido e, caso positivo, somente realizar a validação do token no servidor, ao invés de uma nova autenticação. Isso é feito definindo o parâmetro grant_type da requisição para refresh_token e o parâmetro refresh_token com o token de atualização, sendo que esse é retornado pelo servidor no momento da autenticação com login e senha.
No caso de não haver nenhum token disponível, uma nova autenticação deve ser feita, com o nome do usuário e a senha. O serviço de autenticação deve então definir os parâmetros grant_type, scope, username e password com os respectivos valores: "password", "read write", nome do usuário e senha do usuário.
Em ambos os casos, além dos parâmetros passados, o serviço deve informar ainda o client_id e o client_secret. Esses valores são especificados na aplicação back-end, na classe de configuração da segurança. Há ainda o cabeçalho Authorization, que deve ter a string 'Basic ' concatenada com o resultado da função btoa(cliente_id + ':' + client_secret). Essa função transforma a sequência com o id do cliente, o caractere “:” e o código secreto do cliente em um hash base64.
Em seguida, os dados de autenticação devem ser enviados, via HTTP POST, para a URI /oauth/token, e o seu resultado, como dito anteriormente, será armazenado em formato JSON em um cookie no navegador. Em casos mais críticos ou se o desenvolvedor é mais preocupado com a segurança, é possível encriptar os dados da autenticação antes de armazená-los no cookie. É claro que isso fará necessária a decriptação dos mesmos quando forem recuperados.
Uma vez autenticado, é importante lembrar que a cada nova requisição para o back-end, o cabeçalho Authorization: Bearer + access_token deve ser enviado para que a autenticação seja validada, do contrário o servidor negará a requisição. Isso pode ser feito configurando o cabeçalho padrão no $httpProvider para evitar que eventuais esquecimentos se tornem uma dor de cabeça.
Utilize o recurso de definição de constantes do AngularJS para guardar configurações, como o client_id, o client_secret, endereços de servidores remotos ou outros valores que normalmente se repetem no código. Para isso, há o método constant no módulo da aplicação que define uma chave e um valor, que pode ser numérico, string, um array ou um objeto. Assim, quando necessário, basta injetar a constante definida pelo nome. Como exemplo, suponha que foi definida uma constante com o nome CLIENTE. Desse modo, basta que se use a injeção de dependências do framework com o nome CLIENTE e ela estará disponível.
Implantação
Há vários anos esse era um problema muito chato para quem desenvolve aplicações web baseadas em Java, pois não havia hospedagem barata e de qualidade. Os provedores de hospedagem de baixo custo, que forneciam estruturas muito pequenas, embora suficientes para muitos tipos de projetos, não davam suporte a ambientes com servidores de aplicações, porque isso requeria um suporte mais qualificado e mais caro. Esse cenário acabava por inviabilizar e desestimular o desenvolvimento de pequenos projetos e relegava a plataforma Java a ambientes corporativos, que possuíam orçamento para aluguel de servidores dedicados e poderiam configurá-los conforme as necessidades da aplicação.
Com o surgimento e, principalmente, com o crescimento e popularização da computação em nuvem, a demanda aumentou e o preço ficou cada vez mais acessível, tornando a realização de pequenos projetos muito menos impeditiva e com custos de uma pequena fração de outrora. Agora, é possível ter uma máquina de pequeno porte, com um processador, 512 megabytes de memória RAM e alguns gigabytes de disco por US$ 5 por mês. Certamente um hardware capaz de executar, sem dificuldades, uma aplicação implementada sobre a arquitetura vista neste artigo.
Um dos maiores e mais conhecidos players de computação na nuvem do mercado é o Amazon Web Services. A empresa fornece uma enorme gama de serviços e infraestrutura no modelo Pay As You Go, ou seja, pague conforme o uso, além de sistemas operacionais, tamanhos de hardware e orçamentos variados. Particularmente, os serviços oferecidos pela Amazon Web Services necessários para a implantação de uma aplicação seguindo a arquitetura proposta são: S3, EC2 e RDS.
O Amazon S3, ou Amazon Simple Storage Service, provê armazenamento com confiabilidade, segurança, escalabilidade e alto desempenho. É bastante simples de usar, embora tenha recursos mais avançados, como políticas de aposentadoria de dados, e também é muito barato, já que a cobrança é feita pelo volume de dados armazenado. O mais interessante nesse caso é que o S3 permite que os arquivos nele armazenados sejam servidos via HTTP diretamente, sem a necessidade de um outro servidor. Por esse motivo, todo front-end da aplicação deve ser implantado nele.
O EC2, que significa Elastic Compute Cloud, é o tipo de serviço mais comum: um servidor dedicado. Ele permite que o cliente escolha qual sistema operacional e qual versão irá executar e em minutos é possível ter disponível um novo servidor exclusivo, com uma capacidade que pode variar de um processador e meio gigabyte de memória a até 36 processadores com 244 gigabytes de memória.
O último, Relational Database Service (RDS), é o serviço que provê bancos de dados na nuvem, com um custo acessível, recursos confiáveis, seguro e redimensionável. A Amazon dispõe de seis implementações diferentes de bancos de dados: Amazon Aurora, Oracle, MS SQL Server, PostgreSQL, MySQL e MariaDB.
A melhor notícia é que toda essa estrutura está disponível em um modo de degustação por um período de tempo limitado para novos usuários. Ou seja, é possível implantar a aplicação para testes ou piloto gratuitamente e ela não será removida ao final do prazo, mas será preciso começar a pagar pelo uso.
Front-end
Para implantar o front-end no S3 é preciso criar um novo bucket (que é simplesmente uma forma de organizar os arquivos) informando um nome que o identifique unicamente — demoapp, por exemplo — e uma região (geográfica) na qual os arquivos serão hospedados. Em seguida, deve-se habilitar o suporte ao Static Web Hosting, que está disponível nas propriedades do bucket e permite que os arquivos sejam acessados diretamente por qualquer pessoa, funcionando como um servidor web comum — veja na Figura 3 a seta indicando o endereço de acesso. Além disso, é necessário informar o arquivo index do bucket a fim de evitar que a lista de arquivos do site seja exibida ao invés da aplicação (veja a Figura 3). Ainda podemos informar uma página de erro 404, que será exibida quando o arquivo solicitado não existir.
Depois de criado e habilitado o serviço de hospedagem do bucket, basta fazer o upload dos arquivos diretamente para ele. Visto que ele funcionará como o servidor web da aplicação, é importante que as raízes do bucket e da aplicação coincidam ou os arquivos não serão localizados corretamente.
Banco de dados
Como o endereço do servidor de banco de dados normalmente é utilizado no back-end, é uma boa ideia configurar o banco de dados antes do back-end. Portanto, ao acessar o painel do RDS, clique em Launch DB Instance e escolha qual sistema gerenciador de bancos de dados será utilizado (é importante prestar atenção às indicações de quais são elegíveis para o período de gratuidade).
Feito isso, as configurações, como o tipo de hardware, espaço em disco, nome da instância, usuário e senha, devem ser selecionadas. Logo após, informamos o nome do banco de dados e escolhemos a localização do servidor, que deverá ser a mesma do servidor back-end. Além disso, é permitido realizar configurações de rede (que só devem ser alteradas se o desenvolvedor souber o que está fazendo) e políticas de backup.
Depois de clicar para que a instância do servidor de banco de dados seja criada, leva alguns minutos até que ela fique disponível. Uma vez pronta, pode-se conectar usando o cliente apropriado para o fornecedor escolhido e executar os scripts de criação e povoamento dos dados iniciais do banco.
Back-end
Para executar o back-end da aplicação será necessário um servidor que disponha de uma máquina virtual Java. Sendo assim, no painel de controle do EC2, basta clicar em Launch Instance, escolher a opção Amazon Linux e depois o porte do servidor (há uma indicação das máquinas que são elegíveis para o período de gratuidade). Revisadas as configurações, ao clicar no botão Launch o servidor será iniciado logo após selecionarmos o par de chaves SSH que será utilizado para acessá-lo. A Figura 4 mostra onde localizar o nome e IP para acesso ao servidor.
É muito importante selecionar o par de chaves correto, pois sem ele não é possível acessar o servidor e será necessário destruí-lo e criar um novo. Caso ainda não haja um par criado, é possível fazê-lo nesse momento, selecionando a opção Create a new key pair.
A aplicação back-end, construída sobre o framework Spring Boot, agora precisa ser empacotada usando o comando mvn package, o que irá gerar um arquivo JAR no diretório target do projeto. Concluída essa etapa, o JAR deve ser enviado para o servidor, o que pode ser feito via SCP:
scp -i caminho_do_arquivo_chave target/arquivo.jar ec2-user@nome-do-host:/home/ec2-user
ssh -i caminho_do_arquivo_chave ec2-user@nome-do-host
java -jar arquivo.jar &
Caso não seja possível acessar a aplicação back-end depois de iniciada, é preciso verificar, nas configurações da instância do servidor, em grupos de segurança, entrada, se a porta utilizada está disponível.
Pronto! Agora a aplicação está na nuvem.
Há inúmeras outras formas de implementar uma arquitetura que siga os mesmos conceitos, com as mesmas preocupações, ainda que utilizando diferentes tecnologias (ou as mesmas). Esse mesmo conceito pode, por exemplo, ser empregado em aplicativos móveis, com uma abordagem não nativa com o framework Ionic.
O que é importante é a atenção aos requisitos, funcionais e não funcionais, o respeito às diretrizes a serem seguidas no projeto e no desenvolvimento, assim como a aplicação de conceitos simples que facilitam e aceleram o trabalho. Ademais, é preciso ter cuidado para não cair na armadilha da otimização precoce, criando soluções caras e complexas que talvez nunca sejam necessárias de fato. Às vezes é melhor ter um desempenho 15% pior do que um atraso de 10% no prazo. Lembre-se que a diferença entre o remédio e o veneno é a dose.
Confira também
Artigos relacionados
-
Artigo
-
Artigo
-
Artigo
-
Artigo
-
Artigo