Álvaro González Sotillo

Palabras anagramadas online

Esta entrada es una continuación de palabras anagramadas, en la que adapto la aplicación de consola a una página web.

La idea es conseguir una interfaz que vaya mostrando incrementalmente los resultados de un cálculo largo. En una GUI tradicional se utilizarían threads, pero en una página web la solución más simple es utilizar un worker.

Un worker es un script que el navegador carga en un entorno aislado en su propio thread. Los scripts de la página solo pueden comunicarse con el worker mediante el envío y recepción de mensajes. Dicho envío es asíncrono.

Mi solución está programada en ScalaJS. Tengo varias razones para ello, en orden aproximado de importancia:

Aunque se pueden hacer builds multiproyecto con ScalaJS, he preferido hacer un único fichero javascript que tiene el código de la página y del worker. Eso me obliga a:

Corpus de palabras

En la aplicación Scala leo directamente el fichero de corpus de la RAE (unos 20 Mb), en un tiempo aceptable. He intentado reutilizar el código Scala en la aplicación ScalaJS, y simplemente tarda demasiado. Aunque no he llegado a medirlo, el culpable del bajo rendimiento parece ser el uso de expresiones regulares en Javascript.

Para acelerar la página, y por el ahorro de transferencia de datos, he convertido el corpus a un fichero JSON con una estructura simple: es un array que en la posición n tiene un array con las palabras de longitud n+1.

Carga del Worker

Desde BrowserMain localizo el último script cargado en la página, y lo vuelvo a cargar, esta vez como un worker. Para localizar el último script, intento utilizar la propiedad currentScript, y si no existe, utilizo la ruta de generación del fichero js al compilar.

  val currentScript = {
    val ret = js.Dynamic.global.document
    if( js.isUndefined(ret) ) None else Some(ret.currentScript)
  }

  val lastLoadedScript : Option[String] = currentScript.map{ c =>
    if( js.isUndefined(c) )
      "./palabras/js/target/scala-2.11/palabras-fastopt.js"
    else
      c.src.toString
  }

  val worker = lastLoadedScript.map( new org.scalajs.dom.raw.Worker(_) )

Tras este código, la variable worker es un Option, que puede ser None, o un Worker.

¿Soy parte de la página o un worker?

Como se ha visto, el script se puede cargar varias veces, así que es necesario saber en qué tipo de entorno se ejecuta. Una forma simple es detectar si existe document (página web) o importScripts (worker).

  def isBrowserPage = !js.isUndefined(document)
  def isBrowserWorker = !js.isUndefined(js.Dynamic.global.importScripts)
  def isNode = !isBrowserPage && !isBrowserWorker

Mensajes

Entre la página y el worker se intercambian mensajes. En un entorno Java, utilizaría una case class para cada tipo de mensaje, pero hay que tener en cuenta que entre la página y el worker no se puede intercambiar memoria, solo objetos JSON.

Esto obliga a utilizar objetos planos de Javascript, así que hay que implementar a mano las partes que hacen automáticamente las case class: la creación por factorías en vez de por constructor (método apply), y el pattern matching (método unapply). El siguiente ejemplo es del mensaje LoadCorpus, que tiene un único parámetro:

object Message{
  def jsProp[T](o: Any)(property: String) : Option[T] = {
    val value = o.asInstanceOf[js.Dynamic].selectDynamic(property)
    if( js.isUndefined(value) ) None else Some(value.asInstanceOf[T])
  }

  def jsStr(o: Any) = jsProp[String](o) _

  def unapply( o: Any ) : Option[String] = jsStr(o)("messageType")

  object LoadCorpus{
    def apply( file: String ) = js.Dynamic.literal( "messageType" -> "LoadCorpus", "file" -> file )
    def unapply( o: Any ) : Option[String] = o match{
      case Message("LoadCorpus") => jsStr(o)("file")
      case _ => None
    }
  }

  [...]
}

Los tipos de mensajes que he definido son:

  • De la página al worker:
    • LoadCorpus: Solicitud de descarga del corpus
    • SearchAnagram: Solicitud de búsqueda de un anagrama a partir de una palabra
    • SearchAnagramInSentence: Solicitud de búsqueda de un anagrama en una frase, con una longitud dada
  • Del worker a la página:
    • CorpusLoaded: El corpus ha sido descargado, y ya se pueden buscar anagramas.
    • AnagramFound: Se ha encontrado un anagrama.
    • NoMoreAnagrams: La búsqueda de anagramas ha terminado.

Recepción y envío de mensajes

Una vez definidos los mensajes, su recepción es muy simple con una estructura match. Este es el manejo de mensajes realizado en la página web:

  def onMessage( m: org.scalajs.dom.raw.MessageEvent ) = {

    m.data match{
      case CorpusLoaded(_) =>
        enableButtons()
        ui.output.text("")

      case AnagramFound(found,_) =>
        addWord(found)

      case NoMoreAnagrams(s) =>
        enableButtons()
        ui.botonPalabra.value("Busca anagramas")
        ui.botonFrase.value("Busca anagramas en la frase")
        addLog( s"No se encuentran más anagramas para «$s»" )

      case PreparseDone(size) =>
        addLog( s"Preparseadas las palabras con longitud $size" )

      case data =>
        println( s"No entiendo el mensaje en html:$data")
        js.Dynamic.global.console.log(data.asInstanceOf[js.Any])
    }

  }

Y este, en manejo de mensajes en el worker:

  def onMessage(msg: dom.MessageEvent) = {

    msg.data match{
      case LoadCorpus(file) =>
        Main.cargaCorpusJSON(file){ c =>
          corpus = c
          WorkerGlobal.postMessage( CorpusLoaded(file) )
        }


      case SearchAnagram(s) =>
        val coincidencias = PalabrasAnagramadas.buscaCoincidenciaExacta( Corpus.Palabra(s) );

        for( c <- coincidencias ){
          WorkerGlobal.postMessage( AnagramFound(c.original,s) )
        }

        WorkerGlobal.postMessage( NoMoreAnagrams(s) )

      case SearchAnagramInSentence(s,size) =>

        val coincidencias = PalabrasAnagramadas.buscaExactoEnFrase( s, size );

        for( c <- coincidencias ){
          WorkerGlobal.postMessage( AnagramFound(c.original,s) )
        }

        WorkerGlobal.postMessage( NoMoreAnagrams(s) )


      case data =>
        println( s"  worker: me llega algo que no sé lo que es: $data" )
        js.Dynamic.global.console.log(data.asInstanceOf[js.Any])

    }
  }

Código fuente

El código fuente puede consultarse en su repositorio de Github. La página web donde utilizarlo es https://alvarogonzalezsotillo.github.io/palabras-anagramadas.