Skip to content

Gap1512/lisp-for-the-web

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

1 Lisp for the Web

1.1 The Brothers are History

O trabalho a seguir mostra o processo de implementação de um website, o qual lista alguns jogos e permite que o usuário vote em suas preferências.

1.2 Definindo pacotes e sistemas

Ao invés de entrar com comandos no toplevel, define-se pacotes. Como o projeto a seguir se utilizará de pacotes externos, os mesmos precisam ser carregados antes do arquivo em questão. Para isso, cria-se três arquivos: Um “package.lisp”, outro “filename.lisp”, e por fim “filenam.asd”, sendo filename o nome desejado para o pacote.

Em “filename.asd”, que no caso será chamado de “retro-games.asd”, coloca-se:

(asdf:defsystem #:retro-games
    :description "Website desenvolvido em Lisp"
    :author "Gustavo Alves Pacheco <gap1512@gmail.com>"
    :serial t
    :depends-on (#:cl-who #:hunchentoot #:parenscript #:cl-mongo)
    :components ((:file "package")
		   (:file "retro-games" :depends-on ("package"))))

Em “package.lisp”, coloca-se:

(defpackage :retro-games
  (:use :cl :cl-who :hunchentoot :parenscript :cl-mongo))

Finalmente, o código em questão será escrito no arquivo “retro-games.lisp”, que deve se iniciar com:

(in-package :retro-games)

Para que as definições sejam armazenadas no pacote “retro-games”.

Após feita a definição destes três arquivos, o pacote pode ser carregado para o toplevel utilizando o Quicklisp, da seguinte forma:

(ql:quickload 'retro-games)

O Quicklisp irá carregar e, se necessário, instalar, as dependências do pacote, desde que o diretório onde o projeto se encontra esteja no escopo de busca do ASDF. Por garantia, é recomendado criar projetos dentro da pasta local-projects, do quicklisp. Caso queira que a busca seja feita em outro diretório, verificar este tutorial.

Depois do carregamento, deve-se mudar o toplevel para acessar o pacote em questão. Para isso, entra-se com o seguinte comando no REPL.

(in-package :retro-games)

1.3 Representando jogos como objetos

Para representar um jogo, o qual será avaliado pelo usuário do site, optou-se por utilizar o sistema de objetos do Lisp.

(defclass game ()
    ((name :reader name
	     :initarg :name)
     (votes :accessor votes
	      :initform 0)))

Desta forma, um jogo é composto por dois atributos: Name e Votes. Para instanciar um objeto dessa classe, utiliza-se a função:

(defvar many-lost-hours (make-instance 'game :name "Tetris"))

Os acessores dos atributos são definidos intrinsicamente pela linguagem, ao utilizar reader para leitura e accessor para leitura e escrita.

(votes many-lost-hours)

Definindo um método que acessa o atributo de determinado objeto e incrementa o número de votos é simples:

(defmethod vote-for (user-selected-game)
  (incf (votes user-selected-game)))

1.4 Um protótipo de backend

Inicialmente, o backend será definido como uma lista armazenada em memória. Optou-se por esta representação visto que a linguagem permite facilmente modificações em suas aplicações.

(defvar *games* nil)

Defvar inicializa uma variável especial, no caso games. O * serve para padronizar os nomes de variáveis globais. O acesso a games é, em seguida, encapsulado a algumas funções.

(defun game-from-name (name)
  (find name *games* :test #'string-equal
	  :key #'name))

Tal função, escrita em torno de find, procura um item em uma sequência. Test representa a função de comparação entre o elemento em cheque e o item, enquanto key indica qual função será aplicada aos elementos, antes da comparação. Como find retorna nil (falso) quando o item não é encontrado, pode-se abstrair uma nova função, para verificar se um item já está armazenado.

(defun game-stored? (game-name)
  (game-from-name game-name))

Para exibir os itens em ordem de popularidade, lisp fornece sort, uma função que ordena uma sequência. Entretanto, sort é destrutiva. Portanto, modifica seus argumentos e, consequentemente, games. Para que a variável seja preservada, a mesma é copiada antes de ser avaliada.

(defun games ()
  (sort (copy-seq *games*) #'> :key #'votes))

Finalmente, implementa-se uma função para adicionar novos jogos à lista.

(defun add-game (name)
  (unless (game-stored? name)
    (push (make-instance 'game :name name) *games*)))

1.5 Customizando a representação de impressão de objetos

É possível definir uma forma de impressão para os objetos da classe, especializando a função genérica print-object da classe game, da forma:

 (defmethod print-object ((object game) stream)
   (print-unreadable-object (object stream :type t)
     (with-slots (name votes) object
	(format stream "name: ~s with ~d votes" name votes))))

Print-unreadable-object recebe um objeto, uma stream e alguns parâmetros adicionais, além de um corpo. Tal função imprime o corpo dentro de “#<” e “>”. Caso type seja true, adiciona a classe do objeto ao começo da frase. With-slots evita que o objeto seja acessado duas vezes, para buscar cada atributo.

2 Entering the Web

2.1 Generating HTML dynamically

Primeiramente, quando deseja-se criar uma linguagem embutida a um domínio específico, procura-se uma representação de tal linguagem em Lisp. Para HTML, pode-se utilizar o pacote CL-WHO.

(setf (html-mode) :html5)

(with-html-output (*standard-output* nil :indent t :prologue t)
  (:html
   (:head
    (:title "Test page"))
   (:body
    (:p "CL-WHO é fácil de usar"))))

O primeiro comando especifica a utilização do HTML5. Além disso, para que o Doctype apareça no documento, requisita-se o prologue. Outra vantagem do CL-WHO é que o mesmo permite que expressões Lisp sejam avaliadas no corpo, possibilitando a criação de páginas dinâmicas.

2.2 Macros: Evitando duplicidade de código

Mesmo que o CL-WHO apresente uma forma mais sucinta que o HTML puro, repetições se fazem presentes. Estas repetições começam a ficar mais evidentes quando a complexidade do sistema aumenta. Uma forma de representar abstrações em Lisp é por meio de macros. Esta funcionalidade permite que código seja gerado em tempo de compilação. O funcionamento é de certa forma semelhante às funções, com a diferença que as macros utilizam como estruturas de dados, o próprio código. Desta forma, as macros atenuam a linha entre tempo de compilação e execução, visto que durante a expansão do código da macro, toda a linguagem está à disposição.

Aplicando o conceito na página a ser feita, deseja-se que elementos em comum sejam preenchidos automaticamente pela linguagem, na expansão do código. Um exemplo é o cabeçalho da página HTML, com o DOCTYPE.

Uma página padrão seria, portanto, definida da seguinte forma:

(standard-page (:title "Retro Games")
		 (:h1 "Top Retro Games")
		 (:p "We'll write the code later..."))

Logo, percebe-se que a macro de geração do código envolve basicamente o CL-WHO, da forma

(defmacro standard-page ((&key title) &body body)
  `(with-html-output-to-string
	 (*standard-output* nil :prologue t :indent t)
     (:html :lang "en"
	      (:head
	       (:meta :charset "utf-8")
	       (:title ,title)
	       (:link :type "text/css"
		      :rel "stylesheet"
		      :href "./retro.css"))
	      (:body
	       (:div :id "header"
		     (:img :src "./logo.jpg"
			   :alt "Commodore 64"
			   :class "logo")
		     (:span :class "strapline"
			    "Vote on your favourite Retro Game"))
	       ,@body))))

O backquote representa uma lista no qual seus termos não são avaliados, com exceção daqueles precedidos por vírgula. O ,@ não só avalia o termo, como também desembrulha um nível de lista.

É possível verificar a expansão gerada pela macro utilizando o recurso macro-expansion1, da forma:

(macroexpand-1 '(standard-page (:title "Retro Games")
		   (:h1 "Top Retro Games")
		   (:p "We'll write the code later...")))
(WITH-HTML-OUTPUT-TO-STRING (*STANDARD-OUTPUT* NIL :PROLOGUE T :INDENT T)
  (:HTML :LANG "en"
   (:HEAD (:META :CHARSET "utf-8") (:TITLE "Retro Games")
    (:LINK :TYPE "text/css" :REL "stylesheet" :HREF "/retro.css"))
   (:BODY
    (:DIV :ID "header"
     (:IMG :SRC "/logo.jpg" :ALT "Commodore 64" :CLASS "logo")
     (:SPAN :CLASS "strapline" "Vote on your favourite Retro Game"))
    (:H1 "Top Retro Games") (:P "We'll write the code later..."))))

2.3 Hunchentoot

Hunchentoot é um web-server completo, escrito em Common Lisp. Para lançá-lo, deve-se instanciar um dos receptores providenciados. Tal objeto é responsável por aceitar novas conexões. Nativamente, Hunchentoot já possui alguns acceptors definidos. Será utilizado o easy-acceptor. É chamado de fácil porque já vem com um mecanismo de lançamento implementado. Para iniciar, deve-se:

(start (make-instance 'easy-acceptor :port 8080))

Colocando dentro de uma função, para regular a porta:

(defun start-server (port)
  (start (make-instance 'easy-acceptor :port 8080)))

Ao executar, já é possível testá-lo.

Para publicar alguma página, é necessário providenciar um handler para o Hunchentoot. Existem várias formas de definir um dispatcher. Abaixo um exemplo:

 (push (create-prefix-dispatcher "/retro-games.htm"
				  'retro-games)
	*dispatch-table*)

Desta forma, quando retro-games.htm for acessado, o dispatcher executará a função chamada retro-games, que deve retornar a página em si. Logo, tal função deve ser implementada.

(defun retro-games ()
  (standard-page (:title "Retro Games")
    (:h1 "Top Retro Games")
    (:p "We'll write the code later...")))

E assim, a página já está no ar. Entretanto, percebe-se que este processo é repetitivo, podendo ser simplificado por uma macro. O pacote já possui uma forma fácil de definir os passos acima. Para isso, utiliza-se:

(define-easy-handler (retro-games :uri "/retro-games") ()
  (standard-page (:title "Retro Games")
		   (:h1 "Top Retro Games")
		   (:p "We'll write the code later...")))

Portanto, a página final ficará da seguinte forma:

(define-easy-handler (retro-games :uri "/retro-games") ()
  (standard-page (:title "Top Retro Games")
    (:h1 "Vote on your all time favourite retro games!")
    (:p "Missing a game? Make it available for votes "
	  (:a :href "new-game" "here"))
    (:h2 "Current stand")
    (:div :id "chart"
	    (:ol
	     (dolist (game (games))
	       (htm
		(:li (:a :href (format nil "vote?name=~a"
				       (url-encode (name game))) "Vote!")
		     (fmt "~A with ~d votes" (escape-string (name game))
			  (votes game)))))))))

Para adicionar um arquivo estático, como uma imagem, ou uma folha de estilos, deve-se

 (push (create-static-file-dispatcher-and-handler "/retro.css"
						   "C:/home/lisp-for-the-web/retro.css")
	*dispatch-table*)

E para a imagem:

 (push (create-static-file-dispatcher-and-handler "/logo.jpg"
						   "C:/home/lisp-for-the-web/logo.jpg")
	*dispatch-table*)

O dolist pega todos os jogos da lista, e os transforma em uma lista ordenada. Ao pressionar o Vote!, o usuário é redirecionado para a página que contém como parâmetro, o nome do jogo selecionado. Utilizando o define-easy-handler é trivial o tratamento deste link.

(define-easy-handler (vote :uri "/vote") (name)
  (when (game-stored? name)
    (vote-for (game-from-name name)))
  (redirect "/retro-games"))

Na página criada, há um link para uma outra, de cadastro de novos jogos. Tal página é criada a seguir:

(define-easy-handler (new-game :uri "/new-game") ()
  (standard-page (:title "Add a new game")
    (:h1 "Add a new game to the chart")
    (:form :action "/game-added" :method "post" :id "addform"
	     (:p "What is the name of the game?" (:br)
		 (:input :type "text" :name "name" :class "txt"))
	     (:p (:input :type "submit" :value "Add" :class "btn")))))

Tal página envia as informações para game-added, escrita de forma semelhante:

(define-easy-handler (game-added :uri "/game-added") (name)
  (unless (or (null name) (zerop (length name)))
    (add-game name))
  (redirect "/retro-games"))

Com tal função, o jogo é adicionado ao banco de dados, caso seja válido. Em sequência uma validação do lado do usuário será feita.

3 Expressing JavaScript in Lisp

3.1 Lisp para o navegador

Em game-added, algumas funções validavam a entrada do usuário, mas é recomendado que tal validação seja feita antes do envio. É possível que isto seja feito em Common Lisp, através do pacote Parenscript. Este compila código Lisp em JavaScript. Desta forma, uma função de validação seria da forma:

(defvar add-form nil)

(defun validate-game-name (evt)
  (when (= (@ add-form name value) "")
    (chain evt (prevent-default))
    (alert "Please enter a name.")))

Devido às diferenças de escrita entre Lisp e JavaScript, algumas macros devem ser utilizadas. A primeira, @, faz com que o código seja escrito como addForm.name.value. A segunda, chain, compila para evt.preventDefault(). Além disso, convenções de nome e comentários são também convertidas para o padrão JS. Infelizmente, devido à natureza do JS, vários efeitos colaterais são encontrados.

Tal código será suficiente para a funcionalidade desejada. Portanto, é necessário apenas a adição do tratador de eventos do JavaScript.

3.2 On event handlers

(defun init ()
  (setf add-form (chain document (get-element-by-id "addform")))
  (chain add-form (add-event-listener "submit" validate-game-name false)))

(setf (chain window onload) init)

3.3 Integrando a DSL ao sistema

O recomendado é que a macro standard-page possua a funcionalidade de criar páginas com script. Então, a mesma deve ser redefinida para abordar tal recurso.

(defmacro standard-page ((&key title script) &body body)
  `(with-html-output-to-string
	 (*standard-output* nil :prologue t :indent t)
     (:html :lang "en"
	      (:head
	       (:meta :charset "utf-8")
	       (:title ,title)
	       (:link :type "text/css"
		      :rel "stylesheet"
		      :href "./retro.css")
	      ,(when script
		 `(:script :type "text/javascript"
			   (str ,script))))
	      (:body
	       (:div :id "header"
		     (:img :src "./logo.jpg"
			   :alt "Commodore 64"
			   :class "logo")
		     (:span :class "strapline"
			    "Vote on your favourite Retro Game"))
	       ,@body))))

Assim, esta macro aceita um parâmetro chave adicional, script. A página new-game, refeita adicionando o script é da seguinte forma:

   (define-easy-handler (new-game :uri "/new-game") ()
     (standard-page (:title "Add a new game"
			     :script (ps
				       (defvar add-form nil)
				       (defun validate-game-name (evt)
					 (when (= (@ add-form name value) "")
					   (chain evt (prevent-default))
					   (alert "Please enter a name.")))
				       (defun init ()
					 (setf add-form (chain document (get-element-by-id "addform")))
					 (chain add-form (add-event-listener "submit" validate-game-name false)))
				       (setf (chain window onload) init)))
	(:h1 "Add a new game to the chart")
	(:form :action "/game-added" :method "post" :id "addform"
	       (:p "What is the name of the game?" (:br)
		   (:input :type "text" :name "name" :class "txt"))
	       (:p (:input :type "submit" :value "Add" :class "btn")))))

3.4 Em busca de robustez

Embora seja uma boa prática separar o código JavaScript do Html, em Lisp esta técnica se torna um problema menor, já que toda a página é escrita em Lisp.

Uma problemática do desenvolvimento web são as especificações de cada navegador. Uma forma de solucionar tal problema é utilizar alguma biblioteca de terceiros, que faça tal distinção (exemplo jQuery). Bibliotecas do tipo adicionam robustez ao sistema, visto que passam a ser compatíveis com virtualmente todos os browsers disponíveis.

Com Parenscript, esta integração é feita de forma trivial. Não apenas isto, mas ainda é possível desenvolver as próprias macros e funções em cima deste recurso, expandindo ainda mais a linguagem.

3.5 A vantagem do Lisp

Sendo simples (como nesse caso), ou não, o código em JavaScript, ainda é preferível que tudo seja escrito em Lisp. Alguns fatores influenciam essa decisão. São eles:

  • Regularidade no código, mantendo uma representação uniforme.
  • Possibilidade de abstração, ganhando acesso a macros e todos os recursos da linguagem,

tanto no lado do servidor, quanto do cliente.

  • O código em Parenscript roda tão bem no REPL quanto no browser
  • Mantém o fluxo de trabalho, visto que não há trocas de contexto

Além disso, caso o usuário não deseje colocar o código do script junto com a definição da página, é possível abstrair normalmente, colocando inclusive em outro arquivo, as funções do lado do usuário, por exemplo.

4 Persistent Objects

4.1 Introdução à persistência de dados

Inicialmente, o problema de persistência foi ignorado. Entretanto, é altamente recomendado que alguma forma de armazenamento dos dados seja considerada. Sabendo que o Hunchentoot é multi-threaded e os requisitos de páginas podem vir de qualquer núcleo, um banco de dados é a solução mais adequada.

4.2 MongoDB como backend

Quando se trata de informações persistentes, alternativas não faltam. Quando não se sabe exatamente o que se irá construir, o recomendado é começar com uma estratégia que pode ser facilmente modificada futuramente. Uma boa solução é um banco de dados não relacional, exemplo Mongo. Este é um BD NoSQL construído sobre a ideia de representar conteúdo como documento. É a escolha deste trabalho pois é fácil de configurar e possui um ambiente de desenvolvimento bem amigável. MongoDB pode ser instalado por aqui.

4.3 Do Lisp para o Mongo

Após a instalação, é necessário iniciar o processo do MongoDB, executando o mongod. mongod será o ponto principal de interação. Todas as operações serão enviadas para ele. A integração com o Lisp será feita através do cl-mongo, carregado junto com o sistema, ao ser especificado no arquivo asd.

Para conectar a um banco, utiliza-se:

(cl-mongo:db.use "games")

MongoDB armazena todos os documentos em uma coleção. No caso deste trabalho, a coleção será chamada game, e será definida da seguinte forma:

(defparameter *game-collection* "game")

Desta forma, é possível se referir à coleção utilizando o símbolo game-collection no código.

4.4 Migrando para a persistência

Já que inicialmente um esforço adicional foi destinado a encapsular o acesso ao backend, as recompensas desta estratégia serão colhidas aqui. As únicas alterações necessárias serão modificar o acesso de games usando a API do mongo. Desta forma:

 (defun game-from-name (name)
   (let ((found-games (docs (db.find *game-collection* ($ "name" name)))))
     (when found-games
	(doc->game (first found-games)))))

 (defun game-stored? (name)
   (game-from-name name))

A nova implementação de game-from-name procura em game-collection utilizando db.find, que se traduz em um comando findOne, do Mongo. O resultado é garantido de ser unitário, caso encontrado. Portanto, resta apenas converter o documento para um jogo. Para isso, é recomendado alterar a classe game, da seguinte forma:

(defclass game ()
  ((name :reader name
	   :initarg :name)
   (votes :accessor votes
	    :initarg :votes ;necessário quando leitura feita por BD
	    :initform 0)))

(defun doc->game (game-doc)
  (make-instance 'game
		   :name (get-element "name" game-doc)
		   :votes (get-element "votes" game-doc)))

O get-element, em doc->game, permite que os valores dos campos do documento recuperado sejam acessados. Como a função game-from-name continua retornando nil caso não seja encontrado, game-stored? pode continuar da mesma maneira. Portanto, restam as alterações em add-game. Já não é mais necessário manter as instâncias dos objetos na memória, já que o banco de dados faz isso.

(defun add-game (name)
  (let ((game (make-instance 'game :name name)))
    (db.insert *game-collection* (game->doc game))))

O passo extra de instanciação não é estritamente necessário, mas é recomendado para que seja mantido o nível de abstração entre banco e funcionalidade. Logo, uma função de conversão game->doc é necessária.

(defun game->doc (game)
  ($ ($ "name" (name game))
     ($ "votes" (votes game))))

O símbolo $ é uma macro fornecida pelo cl-mongo. A macro permite a criação de um documento, e adiciona os campos name e votes a ele. Sem a macro, tal código poderia ser escrito da seguinte forma:

(defun game->doc (game)
  (let ((game-doc (make-document)))
    (add-element "name" (name game) game-doc)
    (add-element "votes" (votes game) game-doc)
    game-doc))

Tal estratégia é maior, utiliza um estilo imperativo, e não aproveita os recursos do Lisp.

4.4.1 Notas em concorrência

A variável criada inicialmente, games, encontraria problemas ao ser acessada de vários núcleos, ao mesmo tempo. Utilizando um banco de dados, este problema é solucionado. Entretanto, ainda é possível que dois usuários tentem adicionar o mesmo jogo simultaneamente. Mesmo com o sistema de concorrência do Mongo, de vários leitores, apenas um escritor, este problema ainda poderia aparecer.

4.5 Evitando duplicatas com Constraints

Inicialmente, para evitar qualquer tipo de duplicata, faz-se necessário especificar o que é um jogo único. No caso atual, somente o nome do jogo está como chave. Para garantir que o mongo reforce esta regra, é necessário especificar um índice único.

(defun unique-index-on (field)
  (db.ensure-index *game-collection*
		     ($ field 1)
		     :unique t))

Executando-a da seguinte forma:

(unique-index-on "name")

Desta forma, a constraint será criada, verificando sempre que cada nome receba um índice único.

4.6 CLOS: Observers for free

Inicialmente, vote-for era responsável por incrementar o número de votos. Porém, tal função, sozinha, não consegue mais cumprir seu trabalho, já que agora passa a ser necessário a propagação dos valores para o mongo. Entretanto, caso tal funcionalidade seja inserida em vote-for, a função passa a ter mais de um trabalho, o que não é uma boa estratégia funcional. Utilizando CLOS, fica trivial criar uma função que é chamada sempre que vote-for for invocada. Aqui, o CLOS apresenta uma solução elegante, com o conceito de combinação de métodos. Embora seja possível customizar essa combinação, a forma padrão da mesma já é suficiente para solucionar o problema. Funciona da seguinte forma:

  1. O programador especifica um método primário, especializado para determinada classe
  2. Antes que o método primário seja invocado, CLOS chama um possível método :before
  3. De forma simétrica, após a execução de um método, CLOS invoca os métodos :after
  4. Em adição, CLOS permite a especificação de métodos :around. Não serão utilizados,

nesse caso, mas basicamente eles são executados antes de qualquer outro método, e especificam quando o próximo método será chamado. O próximo método pode ser qualquer um entre before, after ou primary

Com isso, várias possibilidades de ortogonalidade são abertas, conceitos de log e trace se fazem possíveis de forma trivial.

(defmethod vote-for (user-selected-game)
  (incf (votes user-selected-game)))  

(defmethod vote-for :after (game)
  (let ((game-doc (game->doc game)))
    (db.update *game-collection*
		 ($ "name" (name game))
		 game-doc)))

Como desejamos fazer o update de um jogo já alterado, utiliza-se um método after. Finalmente, vale notar que código em Common Lisp não necessariamente lembra aqueles de Orientação a Objetos puro. Entretanto, este exemplo mostra uma versão dinâmica do design pattern Observer. O exemplo também engloba o princípio de encapsulamento, ao estender o comportamento de uma classe sem modificar código já existente.

4.7 Sorting games through MongoDB

Um último ajuste é necessário, antes de lançar o recurso de backend: a ordenação de jogos por popularidade.

(defun games ()
  (mapcar #'doc->game
	    (docs (iter (db.sort *game-collection*
				 :all
				 :field "votes"
				 :asc nil)))))

Novamente, utiliza-se funcionalidades de cl-mongo. db-sort é uma macro que expande para uma query find, ajustada para funcionar como um sort. Foi especificado que se deseja all games, ordenados pelo campo votes, com ascendente nulo, ou seja, de forma descendente. Entretanto, por razões de eficiência, o mongo não retorna o conjunto completo de dados, mas sim um cursor sobre o qual é possível iterar e recuperar todos os elementos. Com o iter, é possível recuperar todos os docs itens, convertidos para game, finalmente, ao mapear sobre a lista.

4.8 Remembering the Games

A função games completou a migração para um sistema persistente. Mas ainda gostaríamos de manter os jogos adicionados anteriormente. Afinal, os usuários não deveriam sentir alterações ao mudar a lógica do sistema. Para isso, basta que os jogos em games sejam adicionados ao banco, da seguinte forma:

(mapcar #'(lambda (old-game)
	      (db.insert *game-collection*
			 (game->doc old-game)))
	  *games*)

Era possível ter criado uma função para tal, mas sabendo que esta operação será realizada apenas uma vez, uma função anônima é mais adequada. Agora é possível settar games para nil, e até mesmo removê-lo do pacote, da forma:

(setf *games* nil)

(unintern '*games*)

5 MapReduce in Lisp

A abordagem tratada é simples. Começa-se com uma versão pequena e simples do sistema, e incrementa-a iterativamente, até que uma versão complexa seja alcançada, com as funcionalidades desejadas. Ao escolher bem os pacotes a serem utilizados, garante-se que o código esteja apto a suportar tais alterações.

Neste capítulo, uma nova funcionalidade é adicionada, para tratar de big data: O MapReduce.

5.1 Pushing work to the server-side

O sistema desenvolvido se encontra em estágios iniciais de desenvolvimento, sendo possível imaginar diversas adições ao sistema. Uma delas é a categorização dos jogos. Estender :retro-games para abranger categorias é trivial:

(defclass game ()
  ((name :reader name
	   :initarg :name)
   (votes :accessor votes
	    :initarg :votes
	    :initform 0)
   (category :accessor category
	       :initarg :category)))

A extensão do backend também é similar:

(defun game->doc (game)
  (with-slots (name votes category) game
    ($ ($ "name" name)
	 ($ "votes" votes)
	 ($ "category" category))))

(defun add-game (name category)
  (let ((game (make-instance 'game :name name :category category)))
    (db.insert *game-collection* (game->doc game))))

O código acima é suficiente para adicionar o recurso de categorias, de forma persistente. Um novo recurso passa a ser interessante: exibir quantos jogos por categoria. Tal funcionalidade permitiria a visualização do crescimento das categorias. Para isso, tal recurso poderia ser implementado na função games. Nela, os jogos seriam recuperados, agrupados por categoria, e enfim somados os números de jogos em cada categorias. É ineficiente. Melhor deixar para o banco. Para isso, um algoritmo de MapReduce é o recomendado.

5.2 The MapReduce algorithm in MongoDB

MongoDB abre várias possibilidades, todas acessíveis pelo REPL do Lisp. Já que o MapReduce é um algoritmo fundamental para lidar com grande volume de dados, o mesmo já é oferecido pelo Mongo. Tudo que é necessário fazer é apresentar as funções de map e de reduce. Mongo utiliza como linguagem de execução o JavaScript. Boas notícias para nós. Já sabemos escrever JS a partir do Lisp, com o Parenscript.

5.3 Specifying the steps with Parenscript

Começando pela função map. De acordo com a documentação do Mongo, a função deve retornar uma tupla de chave e valor. Já que queremos apenas a categoria de cada jogo, a função pode retornar o nome da categoria e o valor constante 1, da forma:

(defjs map_category()
  (emit (@ this category) 1))

Sendo defjs um utilitário, providenciado por cl-mongo, que nos permite definir funções JavaScript no lado do cliente. O código real é expresso em Parenscript. defjs apenas garante que a função é acessível nas futuras interações com o mongo. emit é um gerador JavaScript que produz um par de chave e valor, quando invocado no documento game.

A fase de redução irá coletar os dados gerados pelo map. A função reduce será invocada uma vez a cada sequência agregada produzida pelo passo anterior. Agora, reduce apenas necessita de somar os votos.

(defjs sum_games(c vals)
  (return ((@ Array sum vals))))

A macro @ já foi visitada. Ela é utilizada para acessar as propriedades de um objeto. Nesse caso, deseja-se invocar o método sum do módulo Array. Então, tal valor é colocado entre dois parênteses, para que se transforme em uma chamada de função. Este código se expande para:

(function (c, vals) {
	    return Array.sum(vals);
});

Definidos os dois passos do MapReduce, estamos prontos para executá-los.

5.4 Executing MapReduce from the REPL

Recapitulando, o map_category é invocado em todos os documentos e os categoriza de um em um, baseado no valor do campo category. O MapReduce agrupa as categorias iguais e finalmente, o reduce produz os resultados desejados ao somar todos os membros de um mesmo grupo.

Finalmente, basta apenas invocar o algoritmo com as funções. O cl-mongo fornece a macro $map-reduce, que recebe três argumentos:

  1. Uma coleção na qual ele opera (game)
  2. Uma função map (map_category)
  3. Uma função reduce (sum_games)

Encapsulando em um defun:

(defun sum-by-category ()
  (pp (mr.p ($map-reduce "game" map_category sum_games))))

Infelizmente, um erro está ocorrendo, referente à conexão mongod <-> cl-mongo. Tal erro é facilmente corrigido, trocando as funções dentro do map-reduce para JS puro, da forma:

(defun sum-by-category ()
  (pp (mr.p ($map-reduce "game"
			   "function (x) {return emit(this.category, 1);};"
			   "function (c, vals) {return Array.sum(vals);};"))))

Desta forma, é possível testar a funcionalidade no REPL:

(add-game "Tetris" "Classic")
(add-game "Theatre Europe" "Strategy")
(sum-by-category)

Esta função possui três partes principais. Inicialmente, o mongo retorna um documento especificando onde encontrar os resultados. Depois mr.p encontra esses resultados e pp imprime de forma legível no REPL.

Executar um MapReduce no MongoDB, utilizando funções em JavaScript mostram quão poderosa é linguagem, que consegue se transformar no que o usuário desejar.

6 Endgame

6.1 Final considerations

Tudo que foi tratado apenas mostra a superfície do poder do Lisp. Devido à natureza dinâmica e interativa do Lisp, é a escolha perfeita para protótipos. E devido à facilidade de evolução do Lisp, rapidamente o protótipo pode se tornar um produto completo.

About

Lisp for the Web by Adam Tornhill

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published