In the previous chapter, we built a script that compiles its own dependencies. To share this on CPAN, we want a standard module structure.
Most modern Perl developers use Module::Build to manage the installation of their distributions. Module::Build can build an XS based module automatically but has no idea how to build with Affix::Build. To work around this, we must create a custom builder class to hook into the build process and compile our C library exactly when the user runs ./Build.
We're going to use Minilla to mint our dists. A complete copy of this demo can be found here on github.
1. The Setup
Initialize your project:
minil new Acme-Image-Stb
cd Acme-Image-Stb
mkdir builder
2. Configuration (minil.toml)
We need to tell Minilla two things:
- Use
Module::Build(instead ofModule::Build::Tiny). - Use our custom class (
builder::MyBuilder) to drive the process.
Edit minil.toml:
name = "Acme-Image-Stb"
badges = ["github-actions/test.yml"]
module_maker="ModuleBuild"
static_install = "auto"
[build]
build_class = "builder::MyBuilder"
3. The Custom Builder (builder/MyBuilder.pm)
This is where the magic happens. We subclass Module::Build and override the ACTION_code method. This method runs when the user types ./Build.
We use Affix::Build to build the library and place it directly into the staging directory (blib/arch), ensuring it gets installed correctly.
package builder::MyBuilder;
use v5.40;
use parent 'Module::Build';
use Affix::Build;
use HTTP::Tiny;
use Path::Tiny;
use Config;
sub ACTION_code ($self) {
unless ( defined $self->config_data('lib') ) {
say 'Building embedded C library...';
# Setup source directory
my $src_dir = path('_src');
$src_dir->mkpath;
# Download headers if missing. In reality, you would probably just bundle the headers with the dist.
# But this isn't reality.
my $http = HTTP::Tiny->new;
for my $file (qw(stb_image.h stb_image_resize2.h stb_image_write.h)) {
my $path = $src_dir->child($file);
next if $path->exists;
say " Fetching $file...";
my $res = $http->get("https://raw.githubusercontent.com/nothings/stb/master/$file");
die "Failed to download $file" unless $res->{success};
$path->spew_raw( $res->{content} );
}
# Determine output path
# We want the DLL to end up in: blib/arch/auto/Acme/Image/Stb/stb.so.xx.xx
# This ensures it is installed in the architecture-specific library path.
my $dist_name = $self->dist_name; # "Acme-Image-Stb"
my @parts = split /-/, $dist_name;
my $arch_dir = path( $self->blib, 'arch', 'auto', @parts );
$arch_dir->mkpath;
# Compile with Affix
my $c = Affix::Build->new(
version => $self->dist_version,
name => 'stb',
build_dir => $arch_dir,
flags => { cflags => "-I$src_dir -O3", ldflags => ( $^O eq 'MSWin32' ? '-Wl,--export-all-symbols' : '' ) }
);
$c->add( \<<~'C', lang => 'c' );
#if defined(_WIN32)
#define STBIDEF __declspec(dllexport)
#define STBIWDEF __declspec(dllexport)
#define STBIRDEF __declspec(dllexport)
#else
#define STBIDEF __attribute__((visibility("default")))
#define STBIWDEF __attribute__((visibility("default")))
#define STBIRDEF __attribute__((visibility("default")))
#endif
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "stb_image.h"
#include "stb_image_resize2.h"
#include "stb_image_write.h"
C
my $lib_file = $c->link;
say " Compiled: $lib_file";
$self->config_data( lib => $lib_file->basename );
}
# Run standard build steps (copying .pm files to blib/)
$self->SUPER::ACTION_code;
}
1;
4. Dependencies (cpanfile)
We need to add our build tools to the configure phase.
requires 'perl', '5.040';
requires 'Affix', 'v1.0.3';
on 'configure' => sub {
requires 'Module::Build';
requires 'HTTP::Tiny';
requires 'Path::Tiny';
requires 'Affix::Build', 'v1.0.3';
};
on 'test' => sub {
requires 'Test2::V0';
};
5. The Module (lib/Acme/Image/Stb.pm)
Since we installed the library into blib/arch/auto/..., we can't just look in the current directory. We need to look where Perl installs architecture-specific files (XS modules usually live here).
We use a helper to scan @INC.
package Acme::Image::Stb 0.01 {
use v5.40;
use Affix qw[:all];
use Carp qw[croak];
use Config;
use Acme::Image::Stb::ConfigData;
use parent 'Exporter';
our %EXPORT_TAGS = ( internals => [qw[stbi_load stbi_write_png stbir_resize_uint8_linear]], core => [qw[load_and_resize]] );
$EXPORT_TAGS{all} = [ our @EXPORT_OK = sort map {@$_} values %EXPORT_TAGS ];
# Locate Library
# We look for: .../auto/Image/Stb/stb.so (or .dll)
my $lib_name = Acme::Image::Stb::ConfigData->config('lib');
my $lib_path;
for my $dir (@INC) {
my $check = "$dir/auto/Acme/Image/Stb/$lib_name";
if ( -e $check ) {
$lib_path = $check;
last;
}
}
croak "Could not find compiled library '$lib_name' in \@INC" unless $lib_path;
my $lib = Affix::load_library($lib_path);
# Bindings
use constant STBIR_RGBA => 4;
affix $lib, 'stbi_load', [ String, Pointer [Int], Pointer [Int], Pointer [Int], Int ] => Buffer;
affix $lib, 'stbi_write_png', [ String, Int, Int, Int, Buffer, Int ] => Int;
affix $lib, 'stbir_resize_uint8_linear', [ Buffer, Int, Int, Int, Buffer, Int, Int, Int, Int ] => Buffer;
# API
sub load_and_resize ( $input, $output, $scale ) {
my ( $w, $h, $ch ) = ( 0, 0, 0 );
# Load
my $img = stbi_load( $input, \$w, \$h, \$ch, 4 );
return undef if is_null($img);
# Calculate
my $nw = int( $w * $scale );
my $nh = int( $h * $scale );
my $out_buf = "\0" x ( $nw * $nh * 4 );
# Resize
my $res = stbir_resize_uint8_linear( $img, $w, $h, 0, $out_buf, $nw, $nh, 0, STBIR_RGBA );
return undef if is_null($res);
# Save
stbi_write_png( $output, $nw, $nh, 4, $out_buf, 0 );
}
}
1;
__END__
=pod
=encoding utf-8
=head1 NAME
Acme::Image::Stb - It's new $module
=head1 SYNOPSIS
use Acme::Image::Stb;
=head1 DESCRIPTION
Acme::Image::Stb is ...
=head1 LICENSE
Copyright (C) You.
This library might be free software; you may or may not be able to redistribute it
and/or modify it under the same terms of the author.
=head1 AUTHOR
You E<lt>yournamehere@cpan.orgE<gt>
=cut
6. Development Workflow
With this setup, the standard Minilla commands work perfectly, but now they trigger a C compiler in the background.
# Install dependencies
$ cpanm --installdeps .
# Build and test
$ minil test
# Make the dist
$ minil dist
Kitchen Reminders
By subclassing Module::Build, we have seamlessly integrated Affix::Build into the standard Perl toolchain.
- Transparency: Users installing via
cpanmwon't know you aren't using XS. It just works. - Correctness: By targeting
blib/arch/auto, we respect Perl's directory structure for binary binaries. This demo does it in a rather unsophisticated way... - Flexibility: You can add logic to
MyBuilder.pmto detect system libraries (viapkg-configorxrepo) and only compile the bundled version if the system version is missing.
Pat yourself on the back. You have now created a binary distribution ready for CPAN without writing a single line of XS.