Idea
mn-raffle es (otra) implementación de un sistema para sortear premios entre una serie de participantes,como por ejemplo los asistentes a una charla (presencial o virtual) usando GoogleSheet como repositorio para guardarlos datos de los participantes y del sorteo
A diferencia de la implementación google-raffle.html, puramente en javascript y embebida en una hoja GoogleSheet,en esta ocasión vamos a usar una aplicación web, desarrollada en Micronaut para el backend y Vue para el frontend
| | El código del proyecto se encuentra alojado en https://gitlab.com/groogle/mn-raffle |
| | La aplicación desplegada se encuentra en https://mn-raffle-pvidasoftware.cloud.okteto.net/ |
| | En este post NO voy a describir exhaustivamente paso a paso cómo se ha desarollado la aplicación, sinolas partes que considero más interesantes |
Básicamente, un usuario organizador de un sorteo accederá a la aplicación vía web la cual, através de llamadas REST solicitará datos de participantes, premios, etc contra el backend.
El backend a su vez no tendrá nada de persistencia sino que delegará en Google Sheet la misma.El usuario deberá haberse identificado ante el sistema usando así mismo el mecanismo deautentificación de Google.
Arquitectura
Como se puede ver en el diagrama, la aplicación se divide en los siguientes componentes:
Una aplicación Vue (Javascript y HTML) que correrá en el navegador del usuario
Un backend REST en Micronaut que devuelve la lista de participantes, premios y al que sele indica quién ha ganado qué.
Google: GoogleSheet como persistencia y GoogleAuth como autentificación de usuarios (para futuras funcionalidades que lo requieran)
La aplicación constará de 2 módulos (client y server) que podrán ser desarrollados de formaindependiente pero que se empaquetarán como un artefacto único para ser desplegado.
Proyecto
Como ya se ha mencionado, el proyecto va a ser un multi-module de Gradle, client y backend.
Client
Para la parte cliente (Vue) lo normal es usar gulp, o herramientas similares para construirlo, sin embargo gracias alos plugins npm
que tenemos en gradle, vamos a usarlo tanto para construir la parte cliente como la parte server yde esta forma tener una única tarea capaz de construir ambos y juntarlos en un único artefacto a desplegar.
Por lo demás el proyecto semilla lo crearemos mediante vue-cli
siguiendo los tutoriales disponibles en la páginaoficial de Vue siendo relevante que usaremos:
bootstrap-vue para la parte visual
route para enrutar las diferentes partes de la aplicación
vuex para gestionar el estado de la misma
Así mismo crearemos un interface service
que sirva para dialogar con el backend con una implementación fake
a usar en eldesarrollo del cliente y evitar la necesidad de tener el backend levantado
Backend
El server será una aplicación micronaut típica con los siguientes componentes:
micronaut security, en concreto usaremos Google para la autentificación
groogle-sheet, un DSL que nos permite acceder a una hoja Google de forma fácil
Debido a que el backend accederá a servicios remotos en Google,deberemos de crear un proyecto en Google Cloud Platform para obtener unas credenciales de servicio.Descargaremos el fichero JSON que las contiene pero nos aseguraremos que NO se versiona junto al código
Para ejecutar el server y poder acceder a la hoja de cálculo que nos indique el usuario deberemos tener una variablede entorno GOOGLE_APPLICATION_CREDENTIALS apuntando a la ruta completa del fichero JSON anterior.
La persistencia se va a realizar utilizando GoogleSheet.
El organizador del sorteo podrá crear tantos documentos y hojas como desee, correspondiendocada una de ellas a un sorteo.
En la hoja el organizador dispondrá de los nombres de los participantes en una columna,los premios a sortear en otra junto con la cantidad de cada uno de ellos y el sistema anotará a qué participantele ha tocado qué premio.
Si se desea se puede proporcionar un email por cada participante para notificarle vía correoque ha sido agraciado con un premio.
La imagen siguiente es un ejemplo con las filas y columnas a utilizar en cada hoja:
| | Para que el backend pueda acceder al documento este deberá ser compartido con la cuenta de servicio que secreó en Google Cloud Console. |
Backend
Como se ha comentado el backend será una aplicación Micronaut ofreciendo un API:
Mediante UserController
el front podrá obtener detalles del organizador del sorteo (previa autentificación del mismousando Google como proveedor de autentificación) como el nombre y el email. Sólo se usa para para fines estadísticosde uso.
RaffleController
es el encargado de ofrecer la lista de participantes así como de precios disponibles en cada momento.Requiere para ello que se le indique el id
de la hoja de Google así como el nombre del tab que se quiere usar paraese sorteo.
Así mismo acepta que tras un sorteo se le indique quién ha salido ganador y aceptado el premio o bien si no estápresente para no volver a ofrecerlo en la lista de participantes.
Para que el backend pueda acceder a la hoja indicada el propietario de esta deberá haberla compartido con unacuenta de servicio creada para ello a través de las opciones de compartir disponible en la propia hoja de GoogleSheet.
Raffle usa el DSL 'Groogle' para leer y escribir en la hoja. Por ejemplo, para leer la lista de participantes:
List<Participant> loadParticipants(String sheetId, String tabId){
List people = []
sheetService.withSpreadSheet sheetId, {
withSheet tabId, {
writeRange"A3", "C99", {
get().eachWithIndex{ def entry, int i -> (1)
if( entry[0] && !entry[1])
people.add new Participant(name:entry[0], email: entry[2])
}
}
}
}
people.sort { Math.random() }
}
| 1 | Leemos un rango de filas-columnas y rellenamos un array con aquellas que tienen datos |
Para escribir en la hoja, por ejemplo si un participante no ha acudido y no volver a ofrecerlo, el código sería:
Boolean notPressent(String sheetId, String tabId, String name){
sheetService.withSpreadSheet sheetId, {
withSheet tabId, {
List<List<String>> range = writeRange("A3", "C99").get() (1)
List<String> rwinner = range.find{ it[0] && it[0] == name}
rwinner[1] = "NOT PRESSENT"
writeRange("A3","C99").set(range) (2)
}
}
true
}
| 1 | Leemos un rango determinado |
| 2 | escribimos un array en la hoja |
Como se ha mencionado, el backend contendrá a su vez el build del client como recursos incluidos en el propio jar.De esta forma evitamos requerir otro componente para servir la parte html+javascript
Client
El client va a consistir en una aplicación Vue (con route+state y bootstrap-vue para la parte visual)
Al ser un submodulo del proyecto Gradle, podemos usar el mismo IDE a la vez que mantenemos alineados los dos proyectos.
Lo único que neceitamos para ello es establecer "un puente" entre Gradle y npm para lo que usaremos el pluginorg.ysb33r.nodejs.npm
de Gradle y crearemos unas task especificas para construir y ejecutar la parte cliente
plugins {
id 'org.ysb33r.nodejs.base' version '0.6.2'
id 'org.ysb33r.nodejs.npm' version '0.6.2'
}
task npmInstall( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
group 'build'
description = 'Install dependencies'
command 'install'
}
task start( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
group 'build'
description = 'Run the client app'
command 'run'
cmdArgs 'serve'
}
//others task
State
La aplicación va a consistir en unos estados muy simples
src/store/index.ts
class State {
busy = false;
user = {
name:'',
email:''
};
googleForm = {
sheetId:'',
tabId:''
};
participants = [];
prizes = [];
winner = '';
prize = '';
}
con unas actions también simples:
src/store/index.ts
actions: {
//....
fetchParticipants( context ){
return api
.loadParticipants(context.state.googleForm)
.then((participants: any) => context.commit('participants', participants))
},
//....
raffle( context, prize ){
const arr = context.state.participants
const winner = arr[Math.floor(Math.random() * arr.length)]
context.commit('prize', prize)
context.commit('winner', winner['name'])
},
acceptWinner( context){
return api
.winner( context.state.googleForm, context.state.prize, context.state.winner)
.then( () => context.dispatch('fetchPrizes') )
.then( () => context.dispatch('fetchParticipants') )
},
notPressent(context){
return api
.notPressent( context.state.googleForm, context.state.winner)
.then( () => context.dispatch('fetchParticipants') )
}
},
Usando la configuración por entorno de Vue podremos indicar al store qué implementación de API usar:
env.development
VUE_APP_API_CLIENT = 'mock'
env.production
VUE_APP_API_CLIENT = 'server'
Service
Para poder desarrollar el front sin depender de tener corriendo el backend, vamos a usar un mock
que devolverádatos de pruebas contenidos en un JSON con un cierto retraso, simulando que se está realizando una petición http:
src/services/mock/index.ts
const user = mock.user
const prizes = mock.prizes
const vparticipants = mock.participants
const mockFetchData = (mockData: any, time = 0) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(mockData)
}, time)
})
}
export default new class {
loadParticipants(groogleData: any){
return mockFetchData(vparticipants, 1000) // wait 1s before returning posts
}
// others methods
}
Build and deploy (Okteto)
Una vez que queramos desplegar una nueva versión simplemente ejecutaremos ./gradlew assembleServerAndClient
la cualpreparará un jar con los dos componentes.
Así mismo mediante el plugin jib
de Gradle podremos generar y subir una imagen Docker con la aplicación a DockerHub(o a tu repositorio).
Como plataforma donde desplegar la aplicación he elegido Okteto (https://okteto.com) el cual cuenta con un plan gratuitomuy generoso donde ejecutar este tipo de aplicaciones usando Kubernetes.
Para kubernetizar la aplicación simplemente usamos el fichero que nos creó micronaut (con algunos ajustes)
k8s.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: "mn-raffle"
spec:
selector:
matchLabels:
app: "mn-raffle"
template:
metadata:
labels:
app: "mn-raffle"
spec:
volumes:
- name: google-cloud-key
secret:
secretName: mn-raffle-key
containers:
- name: "mn-raffle"
image: "jagedn/mn-raffle"
imagePullPolicy: "Always"
volumeMounts:
- name: google-cloud-key
mountPath: /var/secrets/google
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /var/secrets/google/client_secret.json
- name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_SECRET
valueFrom:
configMapKeyRef:
name: mn-raffle
key: google_client_secret
- name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_ID
valueFrom:
configMapKeyRef:
name: mn-raffle
key: google_client_id
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
name: "mn-raffle"
annotations:
dev.okteto.com/auto-ingress: "true"
spec:
selector:
app: "mn-raffle"
type: ClusterIP
ports:
- port: 8080
protocol: TCP
targetPort: 8080
mediante el comando `kubectl apply -f k8s.yml
(como se puede observar las credenciales se encuentran guardadas en un configMap dentro del kubernetes)
Top comments (0)