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 guardar los 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, sino las partes que considero más interesantes |
Básicamente, un usuario organizador de un sorteo accederá a la aplicación vía web la cual, a travé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 de autentificación de Google.
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 se le 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 forma independiente pero que se empaquetarán como un artefacto único para ser desplegado.
Como ya se ha mencionado, el proyecto va a ser un multi-module de Gradle, client y backend.
Para la parte cliente (Vue) lo normal es usar gulp, o herramientas similares para construirlo, sin embargo gracias a
los plugins npm
que tenemos en gradle, vamos a usarlo tanto para construir la parte cliente como la parte server y
de 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ágina
oficial 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 el
desarrollo del cliente y evitar la necesidad de tener el backend levantado
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 variable de 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, correspondiendo cada 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é participante le ha tocado qué premio.
Si se desea se puede proporcionar un email por cada participante para notificarle vía correo que 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 se creó en Google Cloud Console. |
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 mismo
usando Google como proveedor de autentificación) como el nombre y el email. Sólo se usa para para fines estadísticos
de 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 para
ese 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 una cuenta 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
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 plugin
org.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
La aplicación va a consistir en unos estados muy simples
class State {
busy = false;
user = {
name:'',
email:''
};
googleForm = {
sheetId:'',
tabId:''
};
participants = [];
prizes = [];
winner = '';
prize = '';
}
con unas actions también simples:
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:
VUE_APP_API_CLIENT = 'mock'
VUE_APP_API_CLIENT = 'server'
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:
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
}
Por último tendremos una serie de componentes aislados entre sí y desacoplados del api mediante el store
anterior
Así un componente como el de capturar la hoja y tab a usar, SheetInput
, simplemente se adjunta al state
y cuando el
usuario rellena el formulario este se actualiza automaticamente.
<b-input
id="inline-form-input-tab"
v-model="$store.state.googleForm.tabId"
class="mb-2 mr-sm-2 mb-sm-0"
required
placeholder="Sheet 1">
</b-input>
Cuando el usuario pulsa el botón de cargar, el componente ejecutará un action
del store
que cargue los participantes
y premios:
...
<b-form @submit="load" v-if="$store.state.user.name" inline>
...
@Component
export default class SheetInput extends Vue {
private busy = false
load() {
this.$store.dispatch('fetchPrizes')
this.$store.dispatch('fetchParticipants')
}
}
Así mismo WinnerModal
es un componente que mostrará un diálogo modal cuando el sistema eliga un ganador de un premio
para que el organizador pueda indicar si lo quiere o no o incluso si no está presente. Para ello se subscribe como
un listener del $store
esperando a que se produzca una mutación del winner
momento en el cual simplemente
mostrará el diálogo:
created(){
this.$store.subscribe( (mutation, state) =>{
if( mutation.type === 'winner'){
this.lastWinner = state.winner
this.$bvModal.show('modal-winner')
}
})
}
Una vez que queramos desplegar una nueva versión simplemente ejecutaremos ./gradlew assembleServerAndClient
la cual
preparará 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 gratuito muy 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)
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)
A continuación se muestran algunas pantallas de cómo sería un sorteo
2019 - 2024 | Mixed with Bootstrap | Baked with JBake v2.6.7