Прощай, cabal. Здравствуй, stack!

Приветствую, друзья!

Наконец-то я добрался до этой темы. А то слух, знаете ли, всё громче и громче, а что к чему - знают не все. В общем, решил я разобраться с новым Haskell-инструментом под названием stack.

О чём идёт речь

Утилита stack - это новый инструмент от команды FP Complete, предназначенный для работы с Haskell-проектами. Штуковина очень молодая (первая версия датируется июнем сего года), однако шуму наделать уже успела.

Постойте, скажете вы, у нас же есть старый добрый cabal! Да, есть, но, как показала практика, не такой уж он и добрый. И вот теперь настал его смертный час, ибо к нам пришёл stack

Замена Cabal?

Определимся с понятиями. Когда мы слышим слово “кабал” - речь может идти о:

  1. спецификации Cabal (“Common Architecture for Building Applications and Libraries”) и формате метаданных Haskell-проекта. Тот самый формат, который мы видим в файле с расширением .cabal;
  2. реализации спецификации Cabal в виде библиотеки Cabal;
  3. утилите cabal-install, в виде знакомой нам команды cabal.

Так вот утилита stack - это замена утилите cabal-install. Больше не будет никаких cabal update и cabal sandbox init. Кстати, некоторые ошибочно полагали, что stack представляет собой надстройку над cabal-install и скрыто использует последнюю, однако это не так: stack использует Cabal-библиотеку (см. выше пункт 2), но никак не соприкасается с cabal-install. Иными словами, вы можете спокойно удалить cabal-install с вашего компьютера и забыть о ней навсегда.

Зачем это было нужно

По результатам большого опроса, проведённого среди множества коммерческих пользователей Haskell, главной болью при работе с Haskell-проектами была-таки cabal-install. Для этой боли даже название придумали: “cabal hell”. А всё дело было в сложных/перекрёстных зависимостях между (многочисленными) пакетами, задействованными в ваших проектах. Фундаментальная проблема cabal-install такова:

То, что работает сегодня, может перестать работать завтра.

Естественно, такое положение дел не устраивало сообщество, а в особенности тех, кто уже использовал Haskell в коммерческих проектах. Никому не хочется столкнуться с ситуацией, когда, после очередного cabal update, проект, прекрасно собирающийся вчера вечером, перестаёт собираться сегодня днём. Подобная непредсказуемость неизбежно разрушает доверие.

Были предприняты попытки решить указанную проблему. В cabal-1.18 были введены песочницы, а в cabal-1.20 - команда cabal freeze. Теперь проекты могли быть изолированными друг от друга, а пакетные зависимости можно было заморозить. И проблемы действительно уменьшились, но, к сожалению, не ушли. Поэтому команда FP Complete создаёт stack. Цель этого проекта - избавить мир от cabal hell, раз и навсегда. А заодно максимально упростить работу с проектами.

Поехали

Идём сюда и скачиваем stack для своей ОС. На момент написания данной статьи последней версией была 1.2.0, датируемая 6 июля. После скачивания архива распаковываем его - и вот он наш файлик. Переименовываем его в stack, делаем исполняемым, копируем куда-нибудь в PATH (я положил в /usr/local/bin) - и можно работать.

Начнём с пустышки

Делаем следующее:

$ mkdir probe
$ cd probe
$ stack new

Команда new создаёт простейший проект-пустышку в текущем каталоге:

$ ls
LICENSE
Setup.hs
app/
new-template.cabal
src/
stack.yaml
test/

Перед нами - самый обыкновенный Haskell-проект. Кстати, он прямо сейчас, без каких-либо дополнительных манипуляций готов к сборке, в отличие от проекта, создаваемого с помощью cabal init.

Самая важная часть, которой не было раньше - файл stack.yaml. Заглянем в него:

flags: {}
packages:
- '.'
extra-deps: []
resolver: lts-2.15

Обратите внимание на последнюю строчку, ибо она очень, очень важна. lts-2.15 - это версия нашей Haskell-экосистемы. LTS - от “Long Term Support” - представляет собой фиксированный набор Haskell-пакетов. Но что значит “фиксированный набор”? Поясню.

Stackage

Как мы знаем, наш любимый Hackage содержит очень много пакетов: на данный момент их чуть более 8400 штук. Но будет честными - далеко не всё из этой кучи пригодно для реального применения. Многие из пакетов - сугубо экспериментальные (читай “игрушечные”), а есть и вообще заброшенные. Поэтому друзья из FP Complete придумали Stackage, от “Stable Hackage”.

Идея в следующем: отобрать часть конкретных пакетов - и закрепить их в своего рода снимке (snapshot), присвоив этому снимку версию. Например, снимок версии 2.15 включает в себя 1066 пакетов, причём версия каждого из них явно указана (взгляните на список).

Именно благодаря этому мы избавляемся от упомянутой выше непредсказуемости. Версия lts-снимка однозначно определяет версию каждого пакета, используемого в нашем проекте. Поэтому когда N программистов, работающих с неким проектом, используют одну и ту же версию lts-снимка - у них всё гарантированно работает (как сегодня, так и завтра).

Это особенно полезно при расширении разработки. Пришёл новый человек в команду - никаких проблем с вечно обновляющимися пакетами: он просто берёт ту же версию снимка, что и все остальные коллеги, собирает - и у него всё ок.

Собираем

Пора собрать наш проект:

$ stack build
Downloaded lts-2.15 build plan.
Caching build plan
Fetched package index.
Populated index cache.
GHC version mismatched, found 7.8.3 (x86_64), but expected version 7.8.4 (x86_64) (based on resolver setting in /Users/dshevchenko/probe_stack/stack.yaml). Try running stack setup

В начале происходит загрузка “lts-2.15 build plan”, чтобы stack понял, с какими версиями пакетов следует работать в данном проекте. А теперь обратите внимание на последнюю строчку: stack сообщает нам о несоответствии версий GHC. На моей машине стоит 7.8.3, однако ожидается версия 7.8.4. Заглянем на lts-страницу - так и есть:

LTS Haskell 2.15 - GHC 7.8.4

Данный снимок был проверен с использованием 7.8.4, а следовательно, эту версию и нужно использовать. Однако на моей машине, в рамках последней Haskell Platform, установлена версия 7.8.3. Что же делать? Следует установить версию 7.8.4 (спасибо, Кэп!).

Ставим нужный компилятор

К счастью, возможность установки нужного компилятора входит в обязанности stack! Да-да, он и это умеет. Более того, он сам подсказал нам нужную команду. Вспомните ту самую последнюю строчку, в самом конце: “Try running stack setup”. Сделаем же это:

$ stack setup
Downloaded ghc-7.8.4.
Installed GHC.
Would add the following to PATH: /Users/dshevchenko/.stack/programs/x86_64-osx/ghc-7.8.4/bin

Немного терпения - и новый компилятор готов к использованию. Обратите внимание, что установлен он в скрытый каталог .stack, созданный в вашем домашнем каталоге. Следовательно, никаких конфликтов с уже имеющимся компилятором не будет:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.8.3

Новая версия 7.8.4 будет использоваться исключительно в процессе сборки нашего проекта. А кстати, вдруг я вас обманываю? Как мы можем проверить версию реально используемого компилятора? Очень просто, ведь stack умеет запускать интерпретатор ghci:

$ stack ghci
Configuring GHCi with the following packages: new-template
GHCi, version 7.8.4: http://www.haskell.org/ghc/  :? for help
...

Та-дам! Действительно 7.8.4.

Теперь уже точно собираем

Выполняем:

$ stack build
new-template-0.1.0.0: configure
Configuring new-template-0.1.0.0...
new-template-0.1.0.0: build
Building new-template-0.1.0.0...
Preprocessing library new-template-0.1.0.0...
[1 of 1] Compiling Lib              ( src/Lib.hs, .stack-work/dist/x86_64-osx/Cabal-1.18.1.5/build/Lib.o )
In-place registering new-template-0.1.0.0...
Preprocessing executable 'new-template-exe' for new-template-0.1.0.0...
[1 of 1] Compiling Main             ( app/Main.hs, .stack-work/dist/x86_64-osx/Cabal-1.18.1.5/build/new-template-exe/new-template-exe-tmp/Main.o )
Linking .stack-work/dist/x86_64-osx/Cabal-1.18.1.5/build/new-template-exe/new-template-exe ...
new-template-0.1.0.0: install
Installing library in
/Users/dshevchenko/probe_stack/.stack-work/install/x86_64-osx/lts-2.15/7.8.4/lib/x86_64-osx-ghc-7.8.4/new-template-0.1.0.0
Installing executable(s) in
/Users/dshevchenko/probe_stack/.stack-work/install/x86_64-osx/lts-2.15/7.8.4/bin
Registering new-template-0.1.0.0...

Как видите, в корне проекта появился скрытый каталог .stack-work, в котором и осуществляется вся сборка. Взгляните, куда установилась наша программка:

~/.stack-work/install/x86_64-osx/lts-2.15/7.8.4/bin

Как видите, указана не только версия используемого компилятора, но и версия используемого lts-снимка.

Проверяем версии пакетов

Итак, собрали. А теперь убедимся в правоте наших ожиданий в отношении версий используемых пакетов.

Раньше версии пакетов (или диапазоны версий) указывались в .cabal-файле, в секции build-depends:

    build-depends:   split    >=0.2    && <0.3,
                     filepath >=1.3    && <1.4,
    ...

Теперь же, как мы помним, версии пакетов определяются используемым нами lts-снимком. Проверим это.

Я воспользуюсь одним из своих блогов, создаваемых, как многие знают, с помощью hakyll. Проект зависит от маленькой библиотеки split, помогающей разделывать строчки на кусочки. lts-снимок версии 2.15 содержит указание версии:

...
split-0.2.2         Combinator library for splitting lists
...

Итак, используется 0.2.2. А теперь я сделаю вид, что напутал с версиями и указал в .cabal-файле следующее:

    build-depends:   split  ==0.3.*,
    ...

На самом деле, версия 0.2.2 является последней, но суть не в этом. Суть в том, что проект не соберётся, потому что stack строг и аккуратен по отношению к версиям.

$ cd hand
$ stack init
Writing default config file to: /Users/dshevchenko/hand/stack.yaml
Basing on cabal files:
- /Users/dshevchenko/hand/hand.cabal

Checking against build plan lts-2.15

* Build plan did not match your requirements:
    split version 0.2.2 found
    - hand requires ==0.3.*

Первое, на что следует обратить внимание - новая команда. Мы используем stack init, потому что собираемся использовать stack для уже существующего Haskell-проекта. stack создаёт в его корне уже знакомый нам файл stack.yaml. И кстати, если в корне вашего проекта есть cabal-песочницы или файл cabal.config - смело удаляйте всё это.

Сразу после создания .yaml-файла stack проверяет готовность проекта быть собранным в соответствии с lts-снимком версии 2.15. И тут - неприятный, но вполне ожидаемый результат:

* Build plan did not match your requirements:
    split version 0.2.2 found
    - hand requires ==0.3.*

Версии пакета в .lts-снимке и в .cabal-файле различаются, поэтому сборка не пройдёт. Решение - убрать указание версий из .cabal-файла (ещё раз спасибо, Кэп!). Больше никакой путаницы - версии всех используемых пакетов явно указаны в lts-снимке, и нигде более.

Больше компиляторов, хороших и разных

Ну что ж, раз можно поставить дополнительный “локальный” компилятор, исходя из версии экосистемы конкретного проекта, значит, можно поставить и несколько компиляторов.

Допустим, я хочу поэкспериментировать с новым GHC 7.10.1 с существующим проектом. Перехожу в проект:

$ cd new_hs_project
$ stack init

Теперь открываем stack.yaml и заменяем последнюю строчку на:

resolver: nightly-2015-07-12

Оказывается, lts-снимок - не единственная возможность указания Haskell-экосистемы. LTS прекрасно подходит для серьёзных долгоживущих проектов, но для экспериментаторов, предпочитающих быть на гребне волны, подойдёт одна из ночных сборок. В данном случае - сборка от 12 июля сего года. Живёт она здесь. Откроем её:

Stackage Nightly 2015-07-12 - GHC 7.10.1

То, что нам нужно! Раз данная сборка проверена с помощью 7.10.1, то при выполнении команды stack setup произойдёт установка именно этой версии компилятора:

$ stack setup
Downloaded ghc-7.10.1.
Installed GHC.
Would add the following to PATH: /Users/dshevchenko/.stack/programs/x86_64-osx/ghc-7.10.1/bin

И теперь у нас есть два разных компилятора, не мешающих друг другу. Соответственно, все мои проекты, зависящие от 7.8.4, будут использовать только его, а проекты, зависящие от 7.10.1 - его. Никакой путаницы.

А куда он ставит пакеты?

До появляения cabal sandbox все пакеты ставились в глобальное пространство, и это было ужасно. Идея песочницы частично решила проблему: каждый проект теперь скрыто включал свою собственную песочницу, в которую устанавливалось всё, необходимое для данного проекта.

Вроде бы неплохое решение, но оно грубое в силу своей избыточности. Представьте себе, что у вас на машине есть три yesod-проекта. Соответственно, у вас появится три песочницы с тремя полными копиями всех пакетов, входящих в yesod-зависимости (кто работал с yesod, тот знает, что зависимостей этих, мягко выражаясь, много). Во-первых, попусту растрачивается дисковое пространство, а во-вторых, при появлении четвёртого yesod-проекта (ну вот любите вы yesod, почему бы и нет?) весь yesod будет заново установлен в чертвёртую песочницу. И особенно обидно будет при условии, если во всех четырёх проектах используется одна и та же версия yesod

В stack всё умнее. Больше никаких избыточных копий пакетов внутри каждого из проектов. Теперь пакеты устанавливаются в единое глобальное место, а именно в упомянутый ранее скрытый каталог .stack в вашем домашнем каталоге. Но! Пакеты больше не валятся в одну кучу. Напротив, они устанавливаются иерархично, в соответствии с lts-снимками (ну и ночными сборками, если таковые используются).

Заглянем:

$ ls ~/.stack/snapshots/x86_64-osx/
lts-2.15
nightly-2015-07-12

Вот они, наши снимки. Глянем поглубже:

$ ls ~/.stack/snapshots/x86_64-osx/lts-2.15/7.8.4/lib/x86_64-osx-ghc-7.8.4/
HTTP-4000.2.19                          data-default-instances-containers-0.0.1 regex-tdfa-1.2.0
HUnit-1.2.5.2                           data-default-instances-dlist-0.0.1      resourcet-1.1.5
JuicyPixels-3.2.5.2                     data-default-instances-old-locale-0.0.1 rfc5051-0.1.0.3
MonadCatchIO-transformers-0.3.1.3       deepseq-generics-0.1.1.2                scientific-0.3.3.8
...

И вот они, все наши пакеты. Из пути сразу понятно, о каком снимке идёт речь и какая версия компилятора используется.

Таким образом, если упомянутые ранее три yesod-проекта используют одну и ту же версию lts-снимка - скажем, 2.15 - тогда использованы будут пакеты из каталога ~/.stack/snapshots/x86_64-osx/lts-2.15/7.8.4/. Если же четвёртый yesod-проект будет использовать, скажем, одну из ночных сборок совместно с GHC 7.10.1, то будут использованы пакеты из ~/.stack/snapshots/x86_64-osx/nightly-2015-07-12/7.10.1/. Никаких излишних копий, и при этом никаких конфликтов.

За пределами снимка

Как было сказано выше, lts-снимки (равно как и ночные сборки) содержат не всё то, что есть на просторах Hackage. Ну а вдруг мой проект использует некий пакет, которого нет в используемом мною снимке! Что делать?

Допустим, мне понадобился какой-нибудь string-qq. Если я просто укажу его в своём .cabal-файле, при попытке сборки stack выразит свой категорический протест:

--  While attempting to add dependency,
    Could not find package string-qq in known packages

Поэтому открываем stack.yaml, ищем строчку:

extra-deps: []

и заменяем её на:

extra-deps:
- string-qq-0.0.2

Готово. Теперь при сборке stack увидит, что данный пакет нужно искать не в используемом снимке, а напрямую в Hackage. Соответственно, если мне вдруг понадобится более одного внешнего пакета, я просто дописываю их следом:

extra-deps:
- string-qq-0.0.2
- hfsevents-0.1.5

И всё заработает.

Установка в удобное место

Иногда после сборки проекта бывает удобно установить исполняемый файл приложения в какое-нибудь доступное место. Для этого служит команда install:

$ stack install
Copying from /Users/dshevchenko/probe/.stack-work/install/x86_64-osx/lts-2.15/7.8.4/bin/new-template-exe to /Users/dshevchenko/.local/bin/new-template-exe

И если путь ~/.local/bin/ добавлен в PATH, то приложение будет немедленно доступно напрямую. Весьма удобно.

Вывод

Вывод только один - классную штуку сделали друзья из FP Complete. Лично я перевожу на stack все мои проекты. Прощай, cabal!

Кстати, если вы работаете на Linux - для вас хорошая новость: для Ubuntu, Debian, CentOS/Red Hat и Fedora уже готовы соответствующие пакеты. Ну а нам, маководам, осталось дождаться добавления stack в brew… ;-)

Полезные ссылки