Continuando con la definición de construcción, esta página explica
la definición de build.sbt
con más detalle.
En lugar de pensar en la configuración como pares clave-valor una analogía más apropiada sería pensar en ella como un grafo acíclico dirigido (GAD) de tareas donde los vértices significan sucede antes de. Lo llamaremos grafo de tareas.
Repasemos los términos clave antes de seguir profundizando.
.settings(...)
.
SettingKey[A]
, TaskKey[A]
o InputKey[A]
.
SettingKey[A]
.
El valor es calculado una única vez durante la carga.
TaskKey[A]
.
El valor es calculado cada vez que es invocado.
En el DSL de build.sbt
se utiliza el método .value
para expresar una
dependencia de otra tarea o entrada. El método value
es especial y sólo puede
ser llamado como argumento de :=
(o +=
o ++=
, los cuales veremos más
adelante).
Como primer ejemplo supongamos que redefinimos scalacOption
para que dependa de
las tareas update
y clean
. A continuación se muestran las definiciones de
estas claves (tal cual están definidas en Keys).
Nota: Los valores calculados abajo no tienen mucho sentido para
scalacOptions
pero sirven a modo de demostración.
val scalacOptions = taskKey[Seq[String]]("Options for the Scala compiler.")
val update = taskKey[UpdateReport]("Resolves and optionally retrieves dependencies, producing a report.")
val clean = taskKey[Unit]("Deletes files produced by the build, such as generated sources, compiled classes, and task caches.")
A continuación se muestra cómo podemos redefinir scalacOptions
:
scalacOptions := {
val ur = update.value // la tarea update sucede antes de scalacOptions
val x = clean.value // la tarea clean sucede antes de scalacOptions
// ---- scalacOptions empieza aquí ----
ur.allConfigurations.take(3)
}
update.value
y clean.value
declaran dependencias de tarea, mientras que
ur.allConfigurations.take(3)
es el cuerpo de la tarea.
.value
no es un método de Scala normal. El DSL de build.sbt
utiliza una
macro para procesarlo fuera del cuerpo de la tarea. Ambas tareas, update
y clean
, ya han sido completadas en el momento en el que el motor
de tareas evalúa el cuerpo de scalacOptions
, sin importar que esas líneas
aparezcan en el cuerpo.
Mira el siguiente ejemplo:
ThisBuild / organization := "com.example"
ThisBuild / scalaVersion := "2.12.18"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "Hello",
scalacOptions := {
val out = streams.value // la tarea streams sucede antes de scalacOptions
val log = out.log
log.info("123")
val ur = update.value // la tarea update sucede antes de scalacOptions
log.info("456")
ur.allConfigurations.take(3)
}
)
Después, desde el shell de sbt si se escribe scalacOptions
:
> scalacOptions
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[info] 123
[info] 456
[success] Total time: 0 s, completed Jan 2, 2017 10:38:24 PM
Incluso aunque val ur = ...
aparezca entre log.info("123")
y
log.info("456")
la evaluación de la tarea update
se ha realizado antes de
tales líneas.
Aquí hay otro ejemplo:
ThisBuild / organization := "com.example"
ThisBuild / scalaVersion := "2.12.18"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "Hello",
scalacOptions := {
val ur = update.value // la tarea update sucede antes de scalacOptions
if (false) {
val x = clean.value // la tarea clean sucede antes de scalacOptions
}
ur.allConfigurations.take(3)
}
)
Después, si desde el shell de sbt se lanza run
y luego scalacOptions
:
> run
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[info] Compiling 1 Scala source to /Users/eugene/work/quick-test/task-graph/target/scala-2.12/classes...
[info] Running example.Hello
hello
[success] Total time: 0 s, completed Jan 2, 2017 10:45:19 PM
> scalacOptions
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[success] Total time: 0 s, completed Jan 2, 2017 10:45:23 PM
Si compruebas target/scala-2.12/classes/
verás que no existe ya que la tarea
clean
ha sido ejecutado incluso si aparece dentro de if (false)
.
Otra cosa importante a tener en cuenta es que no hay garantías sobre el orden
en el que las tareas update
y clean
son ejecutadas. Podría ejecutarse
primero update
y luego clean
, primero clean
y luego update
o ambas
ser ejecutadas en paralelo.
Como se ha explicado anteriormente, .value
es un método especial que es usado
para expresar dependencias de otras tareas y entradas.
Hasta que te familiarices con build.sbt
te recomendamos que pongas todas las
llamadas .value
al principio del cuerpo.
Sin embargo, a medida que te vayas sintiendo más cómodo, puedes optar por poner
dichas llamadas a .value
en línea ya que puede hacer que la tarea/entrada sea
más concisa, sin tener que utilizar variables.
A continuación se muestran unos cuantos ejemplos de llamadas en línea.
scalacOptions := {
val x = clean.value
update.value.allConfigurations.take(3)
}
Fíjate en que, aunque las llamadas a .value
estén en línea o en cualquier parte
del cuerpo de la tarea, siguen siendo evaluadas antes de entrar al cuerpo de
la tarea.
En el ejemplo anterior, scalacOptions
tiene una dependencia de las tareas
update
y clean
. Si pones ese ejemplo en build.sbt
y ejecutas la consola
interactiva de sbt y luego escribes inspect scalacOptions
deberías de ver
algo similar (en parte):
> inspect scalacOptions
[info] Task: scala.collection.Seq[java.lang.String]
[info] Description:
[info] Options for the Scala compiler.
....
[info] Dependencies:
[info] *:clean
[info] *:update
....
Así es cómo sbt sabe qué tareas dependen de otras.
Por ejemplo, si se lanza inspect tree compile
verás que depende de otra clave
incCompileSetup
que a su vez depende de otras claves como
dependencyClasspath
.
Sigue recorriendo las dependencias y verás cómo ocurre la mágia.
> inspect tree compile
[info] compile:compile = Task[sbt.inc.Analysis]
[info] +-compile:incCompileSetup = Task[sbt.Compiler$IncSetup]
[info] | +-*/*:skip = Task[Boolean]
[info] | +-compile:compileAnalysisFilename = Task[java.lang.String]
[info] | | +-*/*:crossPaths = true
[info] | | +-{.}/*:scalaBinaryVersion = 2.12
[info] | |
[info] | +-*/*:compilerCache = Task[xsbti.compile.GlobalsCache]
[info] | +-*/*:definesClass = Task[scala.Function1[java.io.File, scala.Function1[java.lang.String, Boolean]]]
[info] | +-compile:dependencyClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | +-compile:dependencyClasspath::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | |
[info] | | +-compile:externalDependencyClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | | +-compile:externalDependencyClasspath::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | | +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | |
[info] | | | +-compile:managedClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | | | +-compile:classpathConfiguration = Task[sbt.Configuration]
[info] | | | | | +-compile:configuration = compile
[info] | | | | | +-*/*:internalConfigurationMap = <function1>
[info] | | | | | +-*:update = Task[sbt.UpdateReport]
[info] | | | | |
....
Cuando escribes compile
sbt automáticamente realiza un update
, por ejemplo.
Esto funciona simplemente porque los valores de entrada requeridos por compile
necesitan que sbt lance un update
primero.
De esta forma, todas las dependencias de construcción en sbt son automáticas en lugar de tener que ser declaradas de forma explícita. Si usas el valor de una clave en otra computación entonces la computación dependerá de dicha clave.
scalacOptions
es una clave tarea.
Supongamos que ya ha sido establecida a algún valor, pero que quieres filtrar
"-Xfatal-warnings"
y "-deprecation"
para las versiones distintas a la 2.12.
lazy val root = (project in file("."))
.settings(
name := "Hello",
organization := "com.example",
scalaVersion := "2.12.18",
version := "0.1.0-SNAPSHOT",
scalacOptions := List("-encoding", "utf8", "-Xfatal-warnings", "-deprecation", "-unchecked"),
scalacOptions := {
val old = scalacOptions.value
scalaBinaryVersion.value match {
case "2.12" => old
case _ => old filterNot (Set("-Xfatal-warnings", "-deprecation").apply)
}
}
)
A continuación se muestra cómo aparecería en el shell de sbt:
> show scalacOptions
[info] * -encoding
[info] * utf8
[info] * -Xfatal-warnings
[info] * -deprecation
[info] * -unchecked
[success] Total time: 0 s, completed Jan 2, 2017 11:44:44 PM
> ++2.11.8!
[info] Forcing Scala version to 2.11.8 on all projects.
[info] Reapplying settings...
[info] Set current project to Hello (in build file:/xxx/)
> show scalacOptions
[info] * -encoding
[info] * utf8
[info] * -unchecked
[success] Total time: 0 s, completed Jan 2, 2017 11:44:51 PM
Ahora, cojamos estas dos claves (desde Keys):
val scalacOptions = taskKey[Seq[String]]("Options for the Scala compiler.")
val checksums = settingKey[Seq[String]]("The list of checksums to generate and to verify for dependencies.")
Nota: scalacOptions
y checksums
no tienen nada que ver la una con la
otra. Simplemente son dos claves con el mismo tipo de valor, donde una es una
tarea.
Es posible compilar un build.sbt
en donde scalacOptions
hace referencia a
checksums
, pero no en el sentido contrario.
Por ejemplo, lo siguiente está permitido:
// La tarea scalacOptions puede estar definida en
// terminos de la entrada checksums
scalacOptions := checksums.value
No hay forma de ir en la otra dirección. Es decir, una clave entrada no puede depender de una clave tarea. Esto se debe a que una clave entrada es computada una única vez cuando se carga el proyecto, por lo que una tarea no sería re-ejecutada cada vez y las tareas esperan justamente lo contrario.
// Mal ejemplo: La entrada checksums no puede ser definida en términos de la
// tarea scalacOptions
checksums := scalacOptions.value
En términos de ejecución, podemos pensar en las entradas como un tipo especial de tarea que se evalúa durante la carga del proyecto.
Consideremos definir la organización del proyecto para que coincida con el nombre del proyecto.
// nombramos nuestra organizacón basándonos en el proyecto
// (ambos son SettingKey[String])
organization := name.value
A continuación se muestra un ejemplo más realista.
Esto cambia el valor de la clave Compile / scalaSource
a un directorio
diferente sólo cuando scalaBinaryVersion
es "2.11"
.
Compile / scalaSource := {
val old = (Compile / scalaSource).value
scalaBinaryVersion.value match {
case "2.11" => baseDirectory.value / "src-2.11" / "main" / "scala"
case _ => old
}
}
El DSL de build.sbt
es un lenguage específico del dominio utilizado para
construir un GAD de entradas y tareas. Las expresiones de la configuración
construyen entradas, tareas y las dependencias entre ellas.
Esta estructura es común a Make (1976), Ant (2000) y Rake (2003).
La sintáxis básica de un Makefile tiene este aspecto:
objetivo: dependencias
[tab] comando 1
[tab] comando 2
En un Makefile el primer objetivo que aparece listado corresponde al objetivo
predeterminado (por convenio se suele nombrar all
).
Echemos un ojo a un Makefile
:
CC=g++
CFLAGS=-Wall
all: hello
hello: main.o hello.o
$(CC) main.o hello.o -o hello
%.o: %.cpp
$(CC) $(CFLAGS) -c $< -o $@
Al ejecutar make
sin parámetros se coge el primer objetivo listado (por
convenio all
).
El objetivo lista hello
como su dependencia, la cual aún no ha sido
construida, por lo que Make construirá hello
.
Después, Make comprueba si las dependencias del objetivo hello
han sido
construidas. hello
tiene dos objetivos: main.o
y hello.o
.
Una vez dichos objetivos han sido creados utilizando la última regla de
concordancia de patrones el comando de sistema es ejecutado para enlazar
main.o
y hello.o
en hello
.
Cuando trabajas con make
te puedes enfocar en qué objetivos quieres mientras
que Make calcula la secuencia y el orden exacto de comandos que necesitan ser
lanzados para construir los productos intermedios.
Se puede considerar como programación orientada a dependencias o
programanción basada en flujo. Make es en realidad considerado como un sistema
híbrido porque mientras que su DSL describe las dependencias entre tareas las
acciones son delegadas a comandos del sistema.
Este sistema híbrido es continuado por los sucesores de Make tales como Ant, Rake y sbt. Echemos un vistazo a la sintaxis básica para un Rakefile:
task name: [:prereq1, :prereq2] do |t|
# acciones (puede referenciar prerequisitos tales como t.name, etc...)
end
La innovación hecha por Rake fue que usaba un lenguage de programación para describir las acciones en lugar de comandos del sistema.
Existen varias razones para organizar la construcción de esta forma.
La primera es evitar la duplicación. Con la programación basada en flujo una
tarea es ejecutada una única vez incluso cuando de ella dependen múltiples
tareas.
Por ejemplo, incluso cuando múltiples tareas a lo largo del grafo de tareas
dependen de Compile / compile
la compilación será ejecutada exactamente una
única vez.
La segunda es la paralelización. Utilizando el grafo de tareas el motor de tareas puede programar tareas mutuamente no dependientes en paralelo.
La tercera es la separación de cometidos y la flexibilidad. El grafo de tareas permite al usuario cablear las tareas juntas de diferentes formas, mientras que sbt y los plugins pueden proporcionar varias características tales como compilación y gestión de dependencias de bibliotecas como funciones que pueden ser reutilizadas.
La estructura central de las definiciones de construcción es un GAD de tareas
donde los vértices denotan relaciones “sucede antes de”.
build.sbt
es un DSL diseñado para expresar programación orientada a
dependencias o programación basada en flujo, similar a un Makefile
o
Rakefile
.
La razón clave para utilizar programación basada en flujo es evitar duplicaciones, procesar en paralelo y la personalización.