I identified some of its major concerns and developed my own implementation that is composed of layers in which each layer gradually adds a responsibility until it has most of the features that the upstream version also has.
In addition to providing a better separation of concerns, I also identified a pattern that I repeatedly use to create these abstraction layers:
{stdenv, foo, bar}: {name, buildInputs ? [], ...}@args: let extraArgs = removeAttrs args [ "name" "buildInputs" ]; in stdenv.someBuildFunction ({ name = "mypackage-"+name; buildInputs = [ foo bar ] ++ buildInputs; } // extraArgs)
Build function abstractions that follow this pattern (as outlined in the code fragment shown above) have the following properties:
- The outer function header (first line) specifies all common build-time dependencies required to build a project. For example, if we want to build a function abstraction for Python projects, then python is such a common build-time dependency.
- The inner function header specifies all relevant build parameters and accepts an arbitrary number of arguments. Some arguments have a specific purpose for the kind of software project that we want to build (e.g. name and buildInputs) while other arguments can be passed verbatim to the build function abstraction that we use as a basis.
- In the body, we invoke a function abstraction (quite frequently stdenv.mkDerivation {}) that builds the project. We use the build parameters that have a specific meaning to configure specialized build properties and we pass all remaining build parameters that are not conflicting verbatim to the build function that we use a basis.
A subset of these arguments have no specific meaning and are simply exposed as environment variables in the builder environment.
Because some parameters are already being used for a specific purpose and others may be incompatible with the build function that we invoke in the body, we compose a variable named: extraArgs in which we remove the conflicting arguments.
Aside from having a function that is tailored towards the needs of building a specific software project (such as a Python project), using this pattern provides the following additional benefits:
- A build procedure is extendable/tweakable -- we can adjust the build procedure by adding or changing the build phases, and tweak them by providing build hooks (that execute arbitrary command-line instructions before or after the execution of a phase). This is particularly useful to build additional abstractions around it for more specialized deployment procedures.
- Because an arbitrary number of arguments can be propagated (that can be exposed as environment variables in the build environment), we have more configuration flexibility.
The original objective of using this pattern is to create an abstraction function for GNU Make/GNU Autotools projects. However, this pattern can also be useful to create custom abstractions for other kinds of software projects, such as Python, Perl, Node.js etc. projects, that also have (mostly) standardized build procedures.
After completing the blog post about layered build function abstractions, I have been improving the Nix packages/projects that I maintain. In the process, I also identified a new kind of packaging scenario that is not yet covered by the pattern shown above.
Deploying SDKs
In the Nix packages collection, most build-time dependencies are fully functional software packages. Notable exceptions are so-called SDKs, such as the Android SDK -- the Android SDK "package" is only a minimal set of utilities (such as a plugin manager, AVD manager and monitor).
In order to build Android projects from source code and manage Android app installations, you need to install a variety of plugins, such as build-tools, platform-tools, platform SDKs and emulators.
Installing all plugins is typically a much too costly operation -- it requires you to download many gigabytes of data. In most cases, you only want to install a very small subset of them.
I have developed a function abstraction that makes it possible to deploy the Android SDK with a desired set of plugins, such as:
with import <nixpkgs> {}; let androidComposition = androidenv.composeAndroidPackages { toolsVersion = "25.2.5"; platformToolsVersion = "27.0.1"; buildToolsVersions = [ "27.0.3" ]; includeEmulator = true; emulatorVersion = "27.2.0"; }; in androidComposition.androidsdk
When building the above expression (default.nix) with the following command-line instruction:
$ nix-build /nix/store/zvailnl4f1261cn87s9n29lhj9i7y7iy-androidsdk
We get an Android SDK installation, with tools plugin version 25.2.5, platform-tools version 27.0.1, one instance of the build-tools (version 27.0.1) and an emulator of version 27.0.2. The Nix package manager will download the required plugins automatically.
Writing build function abstractions for SDKs
If you want to create function abstractions for software projects that depend on an SDK, you not only have to execute a build procedure, but you must also compose the SDK in such a way that all plugins are installed that a project requires. If any of the mandatory plugins are missing, the build will most likely fail.
As a result, the function interface must also provide parameters that allow you to configure the plugins in addition to the build parameters.
A very straight forward approach is to write a function whose interface contains both the plugin and build parameters, and propagates each of the required parameters to the SDK composition function, but manually writing this mapping has a number of drawbacks -- it duplicates functionality of the SDK composition function, it is tedious to write, and makes it very difficult to keep it consistent in case the SDK's functionality changes.
As a solution, I have extended the previously shown pattern with support for SDK deployments:
{composeMySDK, stdenv}: {foo, bar, ...}@args: let mySDKFormalArgs = builtins.functionArgs composeMySDK; mySDKArgs = builtins.intersectAttrs mySDKFormalArgs args; mySDK = composeMySDK mySDKArgs; extraArgs = removeAttrs args ([ "foo" "bar" ] ++ builtins.attrNames mySDKFormalArgs); in stdenv.mkDerivation ({ buildInputs = [ mySDK ]; buildPhase = '' ${mySDK}/bin/build ''; } // extraArgs)
In the above code fragment, we have added the following steps:
- First, we dynamically extract the formal arguments of the function that composes the SDK (mySDKFormalArgs).
- Then, we compute the intersection of the formal arguments of the composition function and the actual arguments from the build function arguments set (args). The resulting attribute set (mySDKArgs) are the actual arguments we need to propagate to the SDK composition function.
- The next step is to deploy the SDK with all its plugins by propagating the SDK arguments set as function parameters to the SDK composition function (mySDK).
- Finally, we remove the arguments that we have passed to the SDK composition function from the extra arguments set (extraArgs), because these parameters have no specific meaning for the build procedure.
With this pattern, the build abstraction function evolves automatically with the SDK composition function without requiring me to make any additional changes.
To build an Android project from source code, I can write an expression such as:
{androidenv}: androidenv.buildApp { # Build parameters name = "MyFirstApp"; src = ../../src/myfirstapp antFlags = "-Dtarget=android-16"; # SDK composition parameters platformVersions = [ 16 ]; toolsVersion = "25.2.5"; platformToolsVersion = "27.0.1"; buildToolsVersions = [ "27.0.3" ]; }
The expression shown above has the following properties:
- The above function invocation propagates three build parameters: name referring to the name of the Nix package, src referring to a filesystem location that contains the source code of an Android project, and antFlags that contains command-line arguments that are passed to the Apache Ant build tool.
- It propagates four SDK composition parameters: platformVersions referring to the platform SDKs that must be installed, toolsVersion to the version of the tools package, platformToolsVersion to the platform-tools package and buildToolsVersion to the build-tool packages.
By evaluating the above function invocation, the Android SDK with the plugins will be composed, and the corresponding SDK will be passed as a build input to the builder environment.
In the build environment, Apache Ant gets invoked build that builds the project from source code. The android.buildApp implementation will dynamically propagate the SDK composition parameters to the androidenv.composeAndroidPackages function.
Availability
The extended build function abstraction pattern described in this blog post is among the structural improvements I have been implementing in the mobile app building infrastructure in Nixpkgs. Currently, it is used in standalone test versions of the Nix android build environment, iOS build environment and Titanium build environment.
The Titanium SDK build function abstraction (a JavaScript-based cross-platform development framework that can produce Android, iOS, and several other kinds of applications from the same codebase) automatically composes both Xcode wrappers and Android SDKs to make the builds work.
The test repositories can be found on my GitHub page and the changes live in the nextgen branches. At some point, they will be reintegrated into the upstream Nixpkgs repository.
Besides mobile app development SDKs, this pattern is generic enough to be applied to other kinds of projects as well.