2. 모듈 시스템 심층 탐구#

또는: 세상을 모듈로 감싸기

Author:

Silvan Mosberger @infinisil

Editors:

Valentin Gagarin @fricklerhandwerk, Alexander Groleau @proofconstruction

이 튜토리얼에서는 기존 API를 Nix 모듈로 감싸는 방법을 폭넓게 보여 주는 시연을 따라가 봅니다.

2.1. 개요#

이 튜토리얼은 Summer of Nix 2021 참가자를 위해 진행된 @infinisil의 모듈 발표(소스)를 따라갑니다.

이 튜토리얼과 함께 발표 영상을 재생하면 앞으로 작업할 코드의 변경 사항을 더 잘 따라갈 수 있습니다.

2.1.1. 무엇을 배우게 됩니까?#

Google Maps API와 상호 작용하는 모듈을 작성하면서 지도 도형, 위치 핀 등을 나타내는 모듈 옵션을 선언합니다.

튜토리얼을 진행하는 동안 먼저 몇 가지 잘못된 설정을 작성해 봅니다. 이를 통해 발생하는 오류 메시지와 그 해결 방법을 논의할 기회를 마련하며, 특히 타입 검사를 다룰 때 이를 살펴봅니다.

2.1.2. 무엇이 필요합니까?#

이 실습에서는 보조 스크립트 두 개를 사용합니다. map.sh와 geocode.sh를 작업 디렉터리에 내려받으십시오.

경고

이 튜토리얼의 예제를 실행하려면 $XDG_DATA_HOME/google-api/key에 Google API 키가 있어야 합니다.

2.2. 빈 모듈#

default.nix라는 파일에 다음 내용을 작성하십시오:

default.nix#
{ ... }:
{

}

2.3. 옵션 선언하기#

몇 가지 보조 함수가 필요한데, 이 함수들은 모듈 시스템이 lib로 전달하는 Nixpkgs 라이브러리에서 가져옵니다:

default.nix#
- { ... }:
+ { lib, ... }:
{

}

lib.mkOption을 사용하여 scripts.output 옵션의 타입을 lines로 선언하십시오:

default.nix#
 { lib, ... }: {

+ options = {
+   scripts.output = lib.mkOption {
+     type = lib.types.lines;
+   };
+ };

 }

lines 타입은 문자열만 유효한 값이며, 여러 정의가 있으면 줄바꿈으로 이어 붙여야 함을 의미합니다.

참고

옵션의 이름과 속성 경로는 임의로 정할 수 있습니다. 여기서는 나중에 다른 스크립트를 추가할 것이므로 scripts를 사용하고, 이 스크립트는 결과 지도를 출력하므로 output이라고 부릅니다.

2.4. 모듈 평가하기#

lib.evalModules를 호출하여 default.nix의 모듈을 평가하는 새 파일 eval.nix를 작성하십시오:

eval.nix#
let
  nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-23.11";
  pkgs = import nixpkgs { config = {}; overlays = []; };
in
pkgs.lib.evalModules {
  modules = [
    ./default.nix
  ];
}

다음 명령을 실행하십시오:

경고

이 명령은 오류를 일으킵니다.

nix-instantiate --eval eval.nix -A config.scripts.output
자세한 설명

nix-instantiate --eval은 지정한 경로의 Nix 파일을 파싱하고 평가한 뒤 결과를 출력합니다. evalModules는 최종 설정 값이 config 속성에 나타나는 속성 집합을 생성합니다. 따라서 eval.nix의 Nix 표현식을 속성 경로 config.scripts.output에서 평가합니다.

오류 메시지는 scripts.output 옵션이 사용되었지만 정의되지 않았음을 나타냅니다. 옵션에 접근하기 전에 값을 설정해야 합니다. 이 작업은 다음 단계에서 진행합니다.

2.5. 타입 검사#

앞서 언급했듯이 lines 타입은 문자열 값만 허용합니다.

경고

이 절에서는 잘못된 값을 설정하여 타입 오류를 직접 겪어 봅니다.

대신 옵션에 정수를 할당하려고 하면 어떻게 됩니까?

default.nix에 다음 줄을 추가하십시오.

default.nix#
 { lib, ... }: {

  options = {
    scripts.output = lib.mkOption {
      type = lib.types.lines;
    };
  };

+ config = {
+   scripts.output = 42;
+ };
 }

이제 이전 명령을 실행하여 첫 번째 모듈 오류를 확인하십시오.

$ nix-instantiate --eval eval.nix -A config.scripts.output
error:
...
       error: A definition for option `scripts.output' is not of type `strings concatenated with "\n"'. Definition values:
       - In `/home/nix-user/default.nix': 42

scripts.output = 42; 정의는 타입 오류를 일으켰습니다. 정수는 줄바꿈 문자로 이어 붙인 문자열이 아니기 때문입니다.

이 모듈이 타입 검사를 통과하고 scripts.output 옵션을 성공적으로 평가하도록 이제 scripts.output에 문자열을 할당합니다.

여기서는 현재 디렉터리에 있는 map 스크립트를 실행하는 셸 명령을 할당합니다. 이 스크립트는 다시 Google Maps Static API를 호출하여 세계 지도를 생성합니다. 그 출력은 미니멀한 이미지 뷰어인 feh로 전달되어 화면에 표시됩니다.

scripts.output의 값을 다음 문자열로 바꾸어 default.nix를 수정하십시오.

default.nix#
   config = {
-    scripts.output = 42;
+    scripts.output = ''
+      ./map.sh size=640x640 scale=2 | feh -
+    '';
   };

2.6. 막간: 재현 가능한 스크립트#

이 간단한 명령은 필요한 의존성(curl과 feh)이 없을 수 있으므로 여러분의 시스템에서 의도대로 작동하지 않을 가능성이 높습니다. 원본 map 스크립트를 pkgs.writeShellApplication으로 패키징하면 이 문제를 해결할 수 있습니다.

먼저 config._module.args를 설정하는 모듈을 추가하여 모듈 평가에서 pkgs 인수를 사용할 수 있게 하십시오.

eval.nix#
 pkgs.lib.evalModules {
   modules = [
+    ({ config, ... }: { config._module.args = { inherit pkgs; }; })
     ./default.nix
   ];
 }

참고

이 메커니즘은 현재 모듈 시스템 코드에만 문서화되어 있으며, 그 문서도 불완전하고 오래되었습니다.

그런 다음 default.nix의 내용을 다음과 같이 변경하십시오.

default.nix#
{ pkgs, lib, ... }: {

  options = {
    scripts.output = lib.mkOption {
      type = lib.types.package;
    };
  };

  config = {
    scripts.output = pkgs.writeShellApplication {
      name = "map";
      runtimeInputs = with pkgs; [ curl feh ];
      text = ''
        ${./map.sh} size=640x640 scale=2 | feh -
      '';
    };
  };
}

이렇게 하면 앞서 추가한 pkgs 인수에 접근하여 의존성을 사용할 수 있고, 현재 디렉터리의 map 파일을 Nix 스토어에 복사하여 래핑된 스크립트가 이 파일을 사용할 수 있게 됩니다. 래핑된 스크립트 역시 Nix 스토어에 위치합니다.

다음 명령으로 스크립트를 실행하십시오.

nix-build eval.nix -A config.scripts.output
./result/bin/map

더 빠르게 반복 작업을 하려면 새 터미널을 열고, 현재 디렉터리의 소스 파일이 변경될 때마다 스크립트를 다시 실행하도록 entr를 설정하십시오.

nix-shell -p entr findutils bash --run \
  "ls *.nix | \
   entr -rs ' \
     nix-build eval.nix -A config.scripts.output --no-out-link \
     | xargs printf -- \"%s/bin/map\" \
     | xargs bash \
   ' \
  "

이 명령은 다음을 수행합니다.

  • 모든 .nix 파일을 나열합니다.

  • entr가 해당 파일들의 변경을 감시하게 합니다. -r을 사용하여 변경이 있을 때마다 실행 중인 명령을 종료합니다.

  • 변경이 있을 때마다:

    • 위와 같이 nix-build를 호출하되, ./result 심볼릭 링크는 추가하지 않습니다

    • 그 결과로 나온 스토어 경로에 /bin/map을 덧붙입니다

    • 이렇게 만든 경로의 실행 파일을 실행합니다

2.7. 옵션 더 선언하기#

스크립트 매개변수를 모두 직접 설정하는 대신 모듈 시스템을 통해 설정하겠습니다. 이렇게 하면 타입 검사를 통해 안전성이 어느 정도 높아질 뿐 아니라, 늘어나는 복잡성과 변화하는 요구 사항을 관리할 추상화를 구축할 수 있습니다.

먼저 Google Maps API에 보내는 요청의 매개변수를 나타낼 또 다른 옵션인 requestParams를 도입합시다.

이 옵션의 타입은 한 가지 타입의 요소로 이루어진 리스트인 listOf <elementType>입니다.

이 경우에는 리스트 요소의 타입으로 lines 대신 일반 문자열 타입인 str을 사용해야 합니다.

str과 lines의 차이는 병합 방식에 있습니다. 모듈 옵션 타입은 값이 유효한지 검사할 뿐 아니라, 한 옵션에 대한 여러 정의를 하나로 합치는 방법도 지정합니다.

  • lines의 경우 여러 정의를 줄바꿈으로 이어 붙여 병합합니다.

  • str의 경우 여러 정의가 허용되지 않습니다. 리스트 요소는 여러 번 정의할 수 없으므로 여기서는 문제가 되지 않습니다.

default.nix 파일에 다음 내용을 추가하십시오:

default.nix#
     scripts.output = lib.mkOption {
       type = lib.types.package;
     };
+
+    requestParams = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+    };
   };

  config = {
    scripts.output = pkgs.writeShellApplication {
      name = "map";
      runtimeInputs = with pkgs; [ curl feh ];
      text = ''
        ${./map.sh} size=640x640 scale=2 | feh -
      '';
    };
+
+    requestParams = [
+      "size=640x640"
+      "scale=2"
+    ];
   };
 }

2.8. 옵션 간 의존성#

모듈은 보통 다른 곳에서 사용할 결과를 만들어 내는 옵션을 하나 선언합니다. 이 예에서는 scripts.output이 그 옵션입니다.

옵션은 다른 옵션에 의존할 수 있으며, 이를 통해 더 유용한 추상화를 구축할 수 있습니다.

여기서는 scripts.output 옵션이 requestParams의 값을 ./map 스크립트의 인자로 사용하도록 하려고 합니다.

2.8.1. 옵션 값에 접근하기#

모듈에서 옵션 값을 사용하려면 모듈을 선언하는 함수의 인자에 config 속성이 포함되어야 합니다.

default.nix를 수정하여 config 속성을 추가하십시오:

default.nix#
-{ pkgs, lib, ... }: {
+{ pkgs, lib, config, ... }: {

옵션을 설정하는 모듈이 평가되면 그 결과 값에는 config 아래의 해당 속성 이름으로 접근할 수 있습니다.

참고

옵션 값은 같은 모듈에서 직접 접근할 수 없습니다.

모듈 시스템은 전달받은 모든 모듈을 평가하며, 그중 어느 모듈이든 특정 옵션의 값을 정의할 수 있습니다. 여러 모듈이 한 옵션을 설정할 때 어떻게 되는지는 그 옵션의 타입에 따라 결정됩니다.

경고

config 인자는 config 속성과 같지 않습니다:

  • config 인자에는 모듈 시스템의 지연 평가 결과가 담기며, 이 평가는 evalModules에 전달된 모든 모듈과 그 모듈들의 imports를 반영합니다.

  • 모듈의 config 속성은 해당 모듈의 옵션 값을 평가를 위해 모듈 시스템에 노출합니다.

이제 default.nix를 다음과 같이 수정하십시오:

default.nix#
   config = {
     scripts.output = pkgs.writeShellApplication {
       name = "map";
       runtimeInputs = with pkgs; [ curl feh ];
       text = ''
-        ${./map.sh} size=640x640 scale=2 | feh -
+        ${./map.sh} ${lib.concatStringsSep " "
+          config.requestParams} | feh -
       '';

여기서 config.requestParams 속성의 값은 같은 파일에 있는 정의를 바탕으로 모듈 시스템이 채웁니다.

참고

Nix 언어의 지연 평가 덕분에 모듈 시스템은 어떤 값을 정의하는 모듈에 전달되는 config 인수에서도 그 값을 사용할 수 있게 해 줍니다.

그런 다음 lib.concatStringsSep " "를 사용하여 config.requestParams 값의 각 리스트 요소를 하나의 문자열로 합치며, 이때 requestParams의 리스트 요소는 공백 문자로 구분됩니다.

이 결과는 ./map 스크립트에 전달할 명령줄 인수 목록을 나타냅니다.

2.9. 조건부 정의#

때로는 옵션 값을 선택 사항으로 두고 싶을 때가 있습니다. 이는 다음 경우처럼 옵션의 값을 반드시 정의하지 않아도 될 때 유용합니다.

지도의 확대/축소 수준을 제어하는 새 옵션 map.zoom을 정의하겠습니다. Google Maps API는 해당 인수가 전달되지 않으면 확대/축소 수준을 추론합니다. 이러한 상황은 <type> 타입의 값 또는 null을 나타내는 nullOr <type>으로 표현할 수 있습니다. 그렇다고 해서 옵션이 정의되지 않았을 때 그 옵션의 값이 자동으로 null이 되는 것은 아닙니다 – 여전히 기본값을 정의해야 합니다.

다음과 같이 zoom 옵션을 담은 map 속성 집합을 최상위 options 선언에 추가하십시오:

default.nix#
     requestParams = lib.mkOption {
       type = lib.types.listOf lib.types.str;
     };
+
+    map = {
+      zoom = lib.mkOption {
+        type = lib.types.nullOr lib.types.int;
+        default = null;
+      };
+    };
   };

이를 활용하려면 조건이 true로 평가될 때만 정의를 추가하는 mkIf <condition> <definition> 함수를 사용하십시오. config 블록의 requestParams 리스트에 다음 내용을 추가하십시오:

default.nix#
     requestParams = [
       "size=640x640"
       "scale=2"
+      (lib.mkIf (config.map.zoom != null)
+        "zoom=${toString config.map.zoom}")
     ];
   };

이렇게 하면 config.map.zoom의 값이 null이 아닐 때만 스크립트 호출에 zoom 매개변수가 추가됩니다.

2.10. 기본값#

우리 애플리케이션에서는 확대/축소 수준을 10으로 설정하는 다른 기본 동작을 원한다고 가정해 봅시다. 즉, 자동 확대/축소는 명시적으로 활성화해야만 합니다.

이는 mkOption의 default 인수로 할 수 있습니다. 이 값은 해당 인수를 선언한 옵션의 값이 따로 지정되지 않았을 때 사용됩니다.

해당 줄을 수정하십시오:

default.nix#
     map = {
       zoom = lib.mkOption {
         type = lib.types.nullOr lib.types.int;
-        default = null;
+        default = 10;
       };
     };
   };

2.11. 셸 명령 감싸기#

지금까지 지도의 크기와 확대/축소 수준을 제어하는 옵션을 선언했지만, 지도의 중심 위치를 지정하는 방법은 아직 제공하지 않았습니다.

이제 center 옵션을 추가하십시오. 기본값으로 자신의 위치를 지정해도 좋습니다:

default.nix#
         type = lib.types.nullOr lib.types.int;
         default = 10;
       };
+
+      center = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = "switzerland";
+      };
     };
   };

이 동작을 구현하기 위해 위치 이름을 좌표로 변환하는 geocode 유틸리티를 사용하겠습니다. 새 패키지를 사용할 수 있게 하는 방법은 여러 가지가 있지만, 연습 삼아 모듈 시스템의 옵션으로 추가하겠습니다.

먼저 패키지를 담을 새 옵션을 추가하십시오:

default.nix#
   options = {
     scripts.output = lib.mkOption {
       type = lib.types.package;
     };
+
+    scripts.geocode = lib.mkOption {
+      type = lib.types.package;
+    };

그런 다음 원래 스크립트 호출을 writeShellApplication으로 감싸 재현 가능하게 만드는 부분에서 해당 옵션의 값을 정의하십시오:

default.nix#
   config = {
+    scripts.geocode = pkgs.writeShellApplication {
+      name = "geocode";
+      runtimeInputs = with pkgs; [ curl jq ];
+      text = ''exec ${./geocode.sh} "$@"'';
+    };
+
     scripts.output = pkgs.writeShellApplication {
       name = "map";
       runtimeInputs = with pkgs; [ curl feh ];

이제 requestParams 목록에 mkIf 호출을 하나 더 추가하십시오. 여기에서는 config.scripts.geocode를 통해 래핑된 패키지에 접근하고, 그 안의 실행 파일 /bin/geocode를 실행합니다:

default.nix#
       "scale=2"
       (lib.mkIf (config.map.zoom != null)
         "zoom=${toString config.map.zoom}")
+      (lib.mkIf (config.map.center != null)
+        "center=\"$(${config.scripts.geocode}/bin/geocode ${
+          lib.escapeShellArg config.map.center
+        })\"")
     ];
   };

이번에는 escapeShellArg를 사용하여 config.map.center 값을 geocode에 명령줄 인수로 전달하고, 그 결과를 문자열 보간으로 center 값을 설정하는 requestParams 문자열에 다시 넣었습니다.

셸 명령 실행을 Nix 모듈로 감싸는 것은 시스템 변경을 제어하는 데 유용한 기법입니다. 이스케이프의 까다로운 부분을 직접 다루는 대신, 더 편리한 속성과 값 인터페이스를 사용하기 때문입니다.

2.12. 모듈 분할하기#

모듈 스키마에는 imports 속성이 포함되어 있습니다. 이 속성을 사용하면 다른 모듈을 추가로 포함할 수 있으며, 예를 들어 큰 설정을 여러 파일로 나눌 수 있습니다.

특히 이를 통해 옵션 선언을 설정에서 해당 옵션을 사용하는 부분과 분리할 수 있습니다.

지도에 위치 핀과 기타 마커를 정의하는 옵션을 선언할 새 모듈 marker.nix를 만드십시오:

marker.nix#
{ lib, config, ... }: {

}

imports 속성을 사용하여 default.nix에서 이 새 파일을 참조하십시오:

default.nix#
 { pkgs, lib, config, ... }: {

+  imports = [
+    ./marker.nix
+  ];
+

2.13. submodule 타입#

지도에 여러 마커를 설정하려고 합니다. 마커는 여러 필드를 가진 복합 타입입니다.

바로 여기에서 모듈 시스템의 타입 시스템에 포함된 가장 유용한 타입 중 하나인 submodule이 활약합니다. 이 타입을 사용하면 자체 옵션을 가진 중첩 모듈을 정의할 수 있습니다.

여기에서는 서브모듈 목록을 타입으로 가지는 새 map.markers 옵션을 정의합니다. 각 서브모듈에는 중첩된 location 타입이 있으며, 이를 통해 지도의 마커 목록을 정의할 수 있습니다.

각 마커 할당은 최상위 config를 평가하는 동안 타입 검사를 받습니다.

marker.nix를 다음과 같이 변경하십시오:

marker.nix#
-{ lib, config, ... }: {
+{ lib, config, ... }:
+let
+  markerType = lib.types.submodule {
+    options = {
+      location = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = null;
+      };
+    };
+  };
+in {
+
+  options = {
+    map.markers = lib.mkOption {
+      type = lib.types.listOf markerType;
+    };
+  };

2.14. 다른 모듈에서 옵션 정의하기#

모듈 시스템이 옵션 정의를 합성하는 방식 덕분에, 다른 모듈에서 정의된 옵션에도 자유롭게 값을 할당할 수 있습니다.

이 경우에는 map.markers 옵션을 사용하여 requestParams 목록에 새 요소를 만들어 추가함으로써, 선언한 마커가 반환된 지도에 표시되도록 합니다. 다만 이 작업은 marker.nix에 선언된 모듈에서 수행합니다.

이 동작을 구현하려면 marker.nix에 다음 config 블록을 추가하십시오:

marker.nix#
+  config = {
+
+    map.markers = [
+      { location = "new york"; }
+    ];
+
+    requestParams = let
+      paramForMarker =
+        builtins.map (marker: "$(${config.scripts.geocode}/bin/geocode ${
+          lib.escapeShellArg marker.location})") config.map.markers;
+    in [ "markers=\"${lib.concatStringsSep "|" paramForMarker}\"" ];
+  };

경고

map 옵션 설정 및 최종 config.map 설정 값과 혼동하지 않도록, 여기에서는 map 함수를 builtins.map으로 명시적으로 사용합니다.

여기에서도 escapeShellArg와 문자열 보간을 사용하여 Nix 문자열을 생성했으며, 이번에는 지오코딩된 위치 속성을 파이프로 구분한 목록을 만들었습니다.

또한 requestParams 값을 결과 문자열 목록으로 설정했습니다. list 타입의 기본 병합 동작 덕분에 이 목록은 default.nix에 정의된 requestParams 목록에 덧붙여집니다.

여러 마커를 정의할 때는 지도의 적절한 중심이나 확대/축소 수준을 정하기 어려울 수 있습니다. 이 작업은 API에 맡기는 편이 더 쉽습니다.

이를 위해 marker.nix의 requestParams 선언 위에 다음 내용을 추가하십시오:

marker.nix#
+    map.center = lib.mkIf
+      (lib.length config.map.markers >= 1)
+      null;
+
+    map.zoom = lib.mkIf
+      (lib.length config.map.markers >= 2)
+      null;
+
     requestParams = let
       paramForMarker = marker:
         let

이 경우 Google Maps API는 중심이나 확대/축소 수준을 전달받지 않으면 기본적으로 주어진 모든 마커의 기하학적 중심을 선택하고, 모든 마커를 한 번에 볼 수 있는 적절한 확대/축소 수준을 설정합니다.

2.15. 중첩 서브모듈#

다음으로, 이름이 있는 여러 사용자가 각자 마커 목록을 정의할 수 있게 하려고 합니다.

이를 위해 lib.types.attrsOf <subtype> 타입의 users 옵션을 추가합니다. 이 옵션을 사용하면 값의 타입이 <subtype>인 속성 집합으로 users를 정의할 수 있습니다.

여기서 그 서브타입은 출발 마커를 선언할 수 있는 또 다른 서브모듈이며, API에 여행 추천 경로를 질의하는 데 적합합니다.

이 서브모듈도 markerType 서브모듈을 사용하므로 서브모듈이 중첩된 구조가 됩니다.

users의 마커 정의를 map.markers 옵션으로 전파하려면 다음과 같이 변경하십시오.

let 블록에서:

marker.nix#
+  userType = lib.types.submodule {
+    options = {
+      departure = lib.mkOption {
+        type = markerType;
+        default = {};
+      };
+    };
+  };
+
 in {

이 코드는 markerType 타입의 departure 옵션을 가진 사용자용 서브모듈 타입을 정의합니다.

options 블록의 map.markers 위에:

marker.nix#
+    users = lib.mkOption {
+      type = lib.types.attrsOf userType;
+    };

이렇게 하면 marker.nix를 임포트하는 모든 서브모듈에서 config에 users 속성 집합을 추가할 수 있으며, 각 속성은 이전 단계에서 선언한 userType 타입이 됩니다.

config 블록의 map.center 위에:

marker.nix#
   config = {

-    map.markers = [
-      { location = "new york"; }
-    ];
+    map.markers = lib.filter
+      (marker: marker.location != null)
+      (lib.concatMap (user: [
+        user.departure
+      ]) (lib.attrValues config.users));

     map.center = lib.mkIf
       (lib.length config.map.markers >= 1)

이 코드는 config 인자에 있는 모든 사용자의 departure 마커를 모두 가져와서, location 속성이 null이 아닌 경우 map.markers에 추가합니다.

config.users 속성 집합은 attrValues에 전달되며, 이 함수는 집합(여기서는 여러분이 정의한 config.users 집합)에 있는 각 속성의 값을 알파벳순으로 정렬한 목록을 반환합니다(Nix 언어에서는 속성 이름이 이 순서로 저장됩니다).

다시 default.nix로 돌아가 보면, 결과로 나온 map.markers 옵션 값은 여전히 requestParams에서 참조하며, requestParams는 다시 최종적으로 Google Maps API를 호출하는 스크립트의 인자를 생성하는 데 사용됩니다.

이런 방식으로 옵션을 정의하면 여러 개의 users.<name>.departure.location 값을 설정할 수 있고, 모든 users의 departure.location 값 집합에 해당하는 핀이 표시되며 확대/축소 수준과 중심이 적절한 지도를 생성할 수 있습니다.

2021년 Summer of Nix에서는 이것이 여러 사람이 함께 쓰는 인터랙티브 지도 데모의 기반이 되었습니다.

2.16. strMatching 타입#

이제 여러 마커로 지도를 렌더링할 수 있으므로 스타일을 사용자 지정하는 기능을 추가할 차례입니다.

마커를 서로 구별할 수 있도록 markerType 서브모듈에 옵션을 하나 더 추가하여 지도에 고정되는 각 마커 핀에 레이블을 붙일 수 있게 하십시오.

API 문서에 따르면 이 레이블은 대문자 한 글자 또는 숫자여야 합니다.

이는 strMatching "<regex>" 타입으로 구현할 수 있습니다. 여기서 <regex>는 일치하는 모든 값을 허용하는 정규 표현식이며, 이 경우에는 대문자 한 글자 또는 숫자를 허용합니다.

let 블록에서:

marker.nix#
         type = lib.types.nullOr lib.types.str;
         default = null;
       };
+
+      style.label = lib.mkOption {
+        type = lib.types.nullOr
+          (lib.types.strMatching "[A-Z0-9]");
+        default = null;
+      };
     };
   };

이번에도 types.nullOr로 null 값을 허용하며, 기본값은 null로 설정되어 있습니다.

paramForMarker 함수에서:

marker.nix#
     requestParams = let
-      paramForMarker =
-        builtins.map (marker: "$(${config.scripts.geocode}/bin/geocode ${
-         lib.escapeShellArg marker.location})") config.map.markers;
-    in [ "markers=\"${lib.concatStringsSep "|" paramForMarker}\"" ];
+      paramForMarker = marker:
+        let
+          attributes =
+            lib.optional (marker.style.label != null)
+            "label:${marker.style.label}"
+            ++ [
+              "$(${config.scripts.geocode}/bin/geocode ${
+                lib.escapeShellArg marker.location
+              })"
+            ];
+        in "markers=\"${lib.concatStringsSep "|" attributes}\"";
+      in
+        builtins.map paramForMarker config.map.markers;

이제 label 속성과 location 속성을 이어 붙여 사용자마다 고유한 marker를 만들고, 이를 requestParams에 할당한다는 점에 주목하십시오. 각 marker의 레이블은 marker.style.label이 설정된 경우에만 CLI 매개변수로 전파됩니다.

2.17. 서브모듈 인자로서의 함수#

현재는 레이블을 명시적으로 설정하지 않으면 아무것도 표시되지 않습니다. 하지만 모든 users 속성에는 이름이 있으므로, 대신 그 이름을 자동 값으로 사용할 수 있습니다.

이 firstUpperAlnum 함수를 사용하면 사용자 이름의 첫 글자를 departure.style.label에 전달하기에 알맞은 타입으로 가져올 수 있습니다:

marker.nix#
{ lib, config, ... }:
 let
+  # Returns the uppercased first letter
+  # or number of a string
+  firstUpperAlnum = str:
+    lib.mapNullable lib.head
+    (builtins.match "[^A-Z0-9]*([A-Z0-9]).*"
+    (lib.toUpper str));

   markerType = lib.types.submodule {
     options = {

lib.types.submodule에 전달하는 인수를 함수로 바꾸면 그 안에서 인수에 접근할 수 있습니다.

서브모듈에서 자동으로 사용할 수 있는 특별한 인수 중 하나는 name이며, 이를 attrsOf에서 사용하면 서브모듈이 정의된 속성의 이름을 얻을 수 있습니다:

marker.nix#
-  userType = lib.types.submodule {
+  userType = lib.types.submodule ({ name, ... }: {
     options = {
       departure = lib.mkOption {
         type = markerType;
         default = {};
       };
     };
-  };

이 경우에는 마커 서브모듈의 label 옵션에서 이름에 쉽게 접근할 수 없습니다. 그렇지 않았다면 그곳에서 default 값을 설정할 수 있었을 것입니다.

대신 다음과 같이 user 서브모듈의 config 부분을 사용하여 기본값을 설정할 수 있습니다:

marker.nix#
+
+    config = {
+      departure.style.label = lib.mkDefault
+        (firstUpperAlnum name);
+    };
+  });

 in {

참고

모듈 옵션에는 정수로 표현되는 우선순위가 있으며, 이 우선순위는 옵션을 특정 값으로 설정할 때 어느 값이 우선하는지를 결정합니다. 값을 병합할 때는 숫자 값이 가장 낮은 우선순위가 이깁니다.

lib.mkDefault 수정자는 인수 값의 우선순위를 가장 낮은 우선순위인 1000으로 설정합니다.

이렇게 하면 같은 옵션에 설정된 다른 값이 우선하게 됩니다.

2.18. either 타입과 enum 타입#

시각적 대비를 높이려면 마커의 색상을 변경하는 방법이 있으면 유용합니다.

여기서는 이를 위해 두 가지 새로운 타입 함수를 사용합니다:

  • either <this> <that>: 두 타입을 인수로 받아 둘 중 어느 쪽이든 허용합니다

  • enum [ <allowed values> ]: 허용되는 값의 목록을 받아 그중 어느 값이든 허용합니다

let 블록에 지정된 색상 이름 또는 RGB 값을 담은 문자열을 가질 수 있는 다음 colorType 옵션을 추가하여 새로운 복합 타입을 추가하십시오:

marker.nix#
     ...
     (builtins.match "[^A-Z0-9]*([A-Z0-9]).*"
     (lib.toUpper str));

+  # Either a color name or `0xRRGGBB`
+  colorType = lib.types.either
+    (lib.types.strMatching "0x[0-9A-F]{6}")
+    (lib.types.enum [
+      "black" "brown" "green" "purple" "yellow"
+      "blue" "gray" "orange" "red" "white" ]);
+
   markerType = lib.types.submodule {
     options = {
       location = lib.mkOption {

이 타입은 24비트 16진수와 일치하거나 지정된 색상 이름 중 하나와 같은 문자열을 허용합니다.

let 블록의 맨 아래에 style.color 옵션을 추가하고 기본값을 지정하십시오:

marker.nix#
           (lib.types.strMatching "[A-Z0-9]");
         default = null;
       };
+
+      style.color = lib.mkOption {
+        type = colorType;
+        default = "red";
+      };
     };
   };

이제 새 옵션을 사용하는 항목을 paramForMarker 목록에 추가하십시오:

marker.nix#
               (marker.style.label != null)
               "label:${marker.style.label}"
             ++ [
+              "color:${marker.style.color}"
               "$(${config.scripts.geocode}/bin/geocode ${
                 lib.escapeShellArg marker.location
               })"

여러 가지 마커를 설정하는 경우에는 각 마커의 크기를 개별적으로 변경할 수 있으면 유용합니다.

marker.nix에 새 style.size 옵션을 추가하여 미리 정의된 크기 집합에서 선택할 수 있도록 하십시오:

marker.nix#
         type = colorType;
         default = "red";
       };
+
+      style.size = lib.mkOption {
+        type = lib.types.enum
+          [ "tiny" "small" "medium" "large" ];
+        default = "medium";
+      };
     };
   };

이제 paramForMarker에 API에 전달할 알맞은 문자열을 선택하는 크기 매개변수 매핑을 추가하십시오:

marker.nix#
     requestParams = let
       paramForMarker = marker:
         let
+          size = {
+            tiny = "tiny";
+            small = "small";
+            medium = "mid";
+            large = null;
+          }.${marker.style.size};
+

마지막으로 선택한 크기를 사용하도록 attributes 문자열에 lib.optional 호출을 하나 더 추가하십시오:

marker.nix#
           attributes =
             lib.optional
               (marker.style.label != null)
               "label:${marker.style.label}"
+            ++ lib.optional
+              (size != null)
+              "size:${size}"
             ++ [
               "color:${marker.style.color}"
               "$(${config.scripts.geocode}/bin/geocode ${

2.19. pathType 서브모듈#

지금까지 출발 마커를 선언하는 옵션과 마커의 시각적 표현을 설정하는 여러 옵션을 만들었습니다.

이제 사용자의 위치에서 특정 목적지까지의 경로를 계산하여 표시하려고 합니다.

다음 절에서 정의할 새 옵션으로 도착 마커를 설정할 수 있으며, 이를 출발 마커와 함께 사용하면 아래에서 정의할 새 모듈을 이용해 지도에 경로를 그릴 수 있습니다.

먼저 다음 내용으로 새 path.nix 파일을 만드십시오.

path.nix#
{ lib, config, ... }:
let
  pathType = lib.types.submodule {
    options = {
      locations = lib.mkOption {
        type = lib.types.listOf lib.types.str;
      };
    };
  };
in
{
  options = {
    map.paths = lib.mkOption {
      type = lib.types.listOf pathType;
    };
  };
  config = {
    requestParams =
      let
        attrForLocation = loc:
          "$(${config.scripts.geocode}/bin/geocode ${lib.escapeShellArg loc})";
        paramForPath = path:
          let
            attributes =
              builtins.map attrForLocation path.locations;
          in
          ''path="${lib.concatStringsSep "|" attributes}"'';
      in
      builtins.map paramForPath config.map.paths;
  };
}

path.nix 모듈은 map에 경로 목록을 정의하는 옵션을 선언하며, 각 경로는 지리적 위치를 나타내는 문자열의 목록입니다.

config 속성에서는 좌표를 적절히 변환한 값으로 requestParams 옵션 값을 설정하여 API 호출을 보강하며, 이 값은 다른 곳에서 설정된 요청 매개변수와 연결됩니다.

이제 marker.nix 모듈에서 이 새 path.nix 모듈을 가져오십시오.

marker.nix#
 in {

+  imports = [
+    ./path.nix
+  ];
+
   options = {

     users = lib.mkOption {

초기 경로 구현을 완성하기 위해 marker.nix에서 departure 옵션 선언을 새 arrival 옵션으로 복사하십시오.

marker.nix#
         type = markerType;
         default = {};
       };
+
+      arrival = lib.mkOption {
+        type = markerType;
+        default = {};
+      };
     };

다음으로 departure.style.label과 마찬가지로 config 블록에 arrival.style.label 속성을 추가하십시오.

marker.nix#
     config = {
       departure.style.label = lib.mkDefault
         (firstUpperAlnum name);
+      arrival.style.label = lib.mkDefault
+        (firstUpperAlnum name);
     };
   });

마지막으로 map.markers에서 concatMap에 전달되는 함수의 반환 목록을 수정하여 각 사용자의 arrival 마커도 포함되도록 하십시오.

marker.nix#
     map.markers = lib.filter
       (marker: marker.location != null)
       (lib.concatMap (user: [
-        user.departure
+        user.departure user.arrival
       ]) (lib.attrValues config.users));

     map.center = lib.mkIf

이제 출발 지점과 도착 지점의 쌍을 연결하여 지도에 경로를 정의할 기반이 마련되었습니다.

경로 모듈에서 모든 사용자의 출발 위치와 도착 위치를 연결하는 경로를 정의하십시오.

path.nix#
   config = {
+
+    map.paths = builtins.map (user: {
+      locations = [
+        user.departure.location
+        user.arrival.location
+      ];
+    }) (lib.filter (user:
+      user.departure.location != null
+      && user.arrival.location != null
+    ) (lib.attrValues config.users));
+
     requestParams = let
       attrForLocation = loc:
         "$(geocode ${lib.escapeShellArg loc})";

새 map.paths 속성에는 모든 사용자에 대해 정의된 유효한 경로가 모두 담긴 목록이 들어 있습니다.

경로는 해당 사용자에게 departure 속성과 arrival 속성이 모두 설정된 경우에만 유효합니다.

2.20. 정수 값에 대한 between 제약 조건#

사용자들이 의견을 밝혔습니다. 이들은 weight 옵션으로 경로의 스타일을 사용자 지정할 수 있는 기능을 요구합니다.

앞에서와 마찬가지로, 이제 경로 스타일을 위한 새 서브모듈을 선언합니다.

style.weight 옵션을 직접 선언할 수도 있지만, 이 경우에는 나중에 경로 스타일 타입을 재사용할 수 있도록 서브모듈을 사용해야 합니다.

path.nix의 let 블록에 pathStyleType 서브모듈 옵션을 추가하십시오:

path.nix#
 { lib, config, ... }:
 let
+
+  pathStyleType = lib.types.submodule {
+    options = {
+      weight = lib.mkOption {
+        type = lib.types.ints.between 1 20;
+        default = 5;
+      };
+    };
+  };
+
   pathType = lib.types.submodule {

참고

ints.between <lower> <upper> 타입은 주어진 범위(양 끝 포함)의 정수를 허용합니다.

경로 굵기의 기본값은 5이지만 1에서 20 사이의 임의의 정수 값으로 설정할 수 있으며, 값이 클수록 지도에 더 굵은 경로가 그려집니다.

이제 파일 아래쪽에 있는 options 집합에 style 옵션을 추가하십시오.

path.nix#
     options = {
       locations = lib.mkOption {
         type = lib.types.listOf lib.types.str;
       };
+
+      style = lib.mkOption {
+        type = pathStyleType;
+        default = {};
+      };
     };

   };

마지막으로 paramForPath의 attributes 목록을 업데이트하십시오:

path.nix#
       paramForPath = path:
         let
           attributes =
-            builtins.map attrForLocation path.locations;
+            [
+              "weight:${toString path.style.weight}"
+            ]
+            ++ builtins.map attrForLocation path.locations;
         in "path=\"${lib.concatStringsSep "|" attributes}\"";

2.21. pathStyle 서브모듈#

사용자는 아직 경로 스타일을 실제로 사용자 지정할 수 없습니다. 각 사용자에 대해 새 pathStyle 옵션을 도입하십시오.

모듈 시스템에서는 하나의 옵션에 값을 여러 번 선언할 수 있으며, 타입이 허용하는 경우 각 선언의 값을 병합하는 작업도 처리합니다.

이 덕분에 marker.nix 모듈에 users 옵션의 정의를 두면서 path.nix에도 users 정의를 둘 수 있습니다:

path.nix#
 in {
   options = {
+
+    users = lib.mkOption {
+      type = lib.types.attrsOf (lib.types.submodule {
+        options.pathStyle = lib.mkOption {
+          type = pathStyleType;
+          default = {};
+        };
+      });
+    };
+
     map.paths = lib.mkOption {
       type = lib.types.listOf pathType;
     };

그런 다음 각 사용자의 경로를 처리하는 map.paths에 user.pathStyle 옵션을 사용하는 줄을 추가하십시오:

path.nix#
         user.departure.location
         user.arrival.location
       ];
+      style = user.pathStyle;
     }) (lib.filter (user:
       user.departure.location != null
       && user.arrival.location != null

2.22. 경로 스타일 지정: 색상#

마커와 마찬가지로 경로도 색상을 사용자 지정할 수 있어야 합니다.

이는 지금까지 이미 접한 타입들을 사용하여 구현할 수 있습니다.

path.nix에 새 colorType 블록을 추가하고, 허용되는 색상 이름과 RGB/RGBA 16진수 값을 지정하십시오:

path.nix#
 { lib, config, ... }:
 let

+  # Either a color name, `0xRRGGBB` or `0xRRGGBBAA`
+  colorType = lib.types.either
+    (lib.types.strMatching "0x[0-9A-F]{6}([0-9A-F]{2})?")
+    (lib.types.enum [
+      "black" "brown" "green" "purple" "yellow"
+      "blue" "gray" "orange" "red" "white"
+    ]);
+
   pathStyleType = lib.types.submodule {

weight 옵션 아래에 새 colorType 값을 사용하는 새 color 옵션을 추가하십시오:

path.nix#
         type = lib.types.ints.between 1 20;
         default = 5;
       };
+
+      color = lib.mkOption {
+        type = colorType;
+        default = "blue";
+      };
     };
   };

마지막으로 attributes 목록에 color 옵션을 사용하는 줄을 추가하십시오:

path.nix#
           attributes =
             [
               "weight:${toString path.style.weight}"
+              "color:${path.style.color}"
             ]
             ++ map attrForLocation path.locations;
         in "path=${

2.23. 추가 스타일 지정#

여기까지 왔으니 렌더링된 지도의 미관을 더욱 개선하기 위해, 지구상의 두 지점 사이의 “직선” 최단 거리인 측지선으로 경로를 그릴 수 있게 하는 스타일 옵션을 하나 더 추가하십시오.

이 기능은 켜거나 끌 수 있으므로 true 또는 false 값을 가질 수 있는 bool 타입을 사용하여 구현할 수 있습니다.

이제 path.nix를 다음과 같이 변경하십시오:

path.nix#
         type = colorType;
         default = "blue";
       };
+
+      geodesic = lib.mkOption {
+        type = lib.types.bool;
+        default = false;
+      };
     };
   };

옵션 값이 API 호출에 포함되도록 attributes 목록에 해당 값을 사용하는 줄도 반드시 추가하십시오:

path.nix#
             [
               "weight:${toString path.style.weight}"
               "color:${path.style.color}"
+              "geodesic:${lib.boolToString path.style.geodesic}"
             ]
             ++ map attrForLocation path.locations;
         in "path=${

2.24. 마무리#

이 튜토리얼에서는 Nixpkgs lib의 여러 새로운 유틸리티 함수를 활용하여 외부 서비스를 선언적으로 제어하는 사용자 정의 Nix 모듈을 작성하는 방법을 배웠습니다.

여러 파일에 걸쳐 여러 모듈을 정의했으며, 각 모듈은 모듈 시스템의 타입 검사를 활용하는 별도의 서브모듈을 가지고 있습니다.

이 모듈들은 외부 API의 기능을 선언적인 방식으로 노출했습니다.

이제 Nix로 세상을 정복할 수 있습니다.