Jak trzymać konfigurację skryptów?
Jak trzymać konfigurację skryptów? Żeby było bezpiecznie i wygodnie?
Przy prostych zadaniach konfiguracyjne parametry można (ale nie należy!) trzymać inline w kodzie. Ale lepiej w zmiennych (właściwie stałych) zdefiniowanych na początku pliku (i odpowiednio nazwanych, żeby wiedzieć że to stałe związane z konfiguracją). A najlepiej - w pliku zewnętrznym (lub zmiennych systemowych, ale tym się dzisiaj nie zajmiemy).

Photo by Ferenc Almasi on Unsplash
Dlaczego w pliku zewnętrznym? Z co najmniej dwóch powodów:
- prosta przenośność - można przenieść skrypt (albo cały ich zestaw, cały projekt) z jednego komputera na drugi i na przykład zmienić tylko ścieżki do plików z danymi. Można zmienić też adres (i inne dane dostępowe) bazy danych i dzięki temu nie zmieniając ani jednego znaku w oprogramowaniu przejść ze środowiska testowego na produkcyjne.
- bezpieczeństwo - w czasach kiedy kod (nawet firmowy) trzymany jest na GitHubie czy innych tego typu repozytoriach (nawet wewnętrznego BitBucketa to dotyczy) bezpieczeństwo jest ważne i dane dostępowe (loginy, hasła, klucze api itd. itp.) nie powinny być udostępniane. Żeby nie były - nie mogą być w kodzie, muszą być gdzieś poza kodem. W dużym skrócie popularne rozwiązanie wygląda tak, że developerzy nie znają danych dostępowych do produkcji, każdy trzyma lokalnie swoją konfigurację (z np. swoją bazą danych na potrzeby developmentu), a w pliku .gitignore wpisany jest plik konfiguracyjny. To powoduje, że plik z konfiguracją nie trafia do repozytorium.
No dobrze, ale skoro konfiguracja ma być w plikach zewnętrznych to w jakich? Najlepiej takich, które trzymają się jakiegoś standardu. Standardy są właściwie cztery, my zajmiemy się trzema. I to zajmiemy się w R oraz Pythonie.
Konfiguracja .INI
Pierwszy standard to plik .ini. To plik tekstowy (jak wszystkie inne) o przykładowej strukturze jak poniżej:
; to jest komentarz
[modul1]
zmienna_txt = "Jestem \"zmienną\" tekstową z 'ciapkami'"
zmienna_num = 123
zmienna_flo = 456.789
; a to jest komentarz przed drugim modułem
[modul2]
zmienna_arr_txt = ['str1', 'str2', 'str3'] ; komentarz inline
zmienna_arr_num = [123, 456, 789]
Co my tutaj widzimy? Mamy dwie sekcje nazwane modul1 i modul2, które mogą odpowiadać za na przykład różne elementy konfiguracyjne - bazę danych, ścieżki do plików, klucze api do różnych systemów. Takie moduły ułatwiają edycję konfiguracji.
W każdym z modułów mamy po dwie zmienne. W pierwszym są to zwykłe, pojedyncze zmienne, a w drugim - listy. Listy mogą być przydatne (na przykład lista adresów mailowych, odbiorców raportów).
Dodatkowo mamy możliwość komentowania w pliku - czasem to się przydaje. A jak działa?
Jak użyć takiego pliku? Zacznijmy od Pythona.
W Pythonie mamy wbudowany pakiet configparser, który bardzo dobrze radzi sobie z czytaniem (oraz zapisywaniem, ale to dzisiaj odpuszczamy) plików .ini. Przykładowy kod, który wczyta i pokaże nam wybrane pozycje z konfiguracji:
import configparser
# na potrzeby ładnego pokazywania obiektów
import pprint
pp = pprint.PrettyPrinter(indent=2)
# zbudowanie obiektu parsera
config = configparser.ConfigParser()
# wczytanie konfiguracji
config.read('config.ini')
Jak wygląda wczytany obiekt?
pp.pprint(config)
## <configparser.ConfigParser object at 0x7fd9654dda20>
Ma to swoją klasę, do elementów wewnątrz można dobrać się poprzez metodę get():
print(config.get('modul1', 'zmienna_txt'))
## "Jestem \"zmienną\" tekstową z 'ciapkami'"
print(config.get('modul1', 'zmienna_num'))
## 123
Ale można sięgnąć po wartość też tak jak (nie powinno się) w standardowych słownikach:
print(config['modul2']['zmienna_arr_txt'])
## ['str1', 'str2', 'str3'] ; komentarz inline
Ups - na końcu został nam komentarz. Cała linia (po znaku równości) traktowana jest jako wartość konkretnej zmiennej. Trzeba o tym pamiętać. A czy da się dostać do jednego elementu na liście?
print(config['modul2']['zmienna_arr_num'][1])
## 1
To nie jest jakiś konkretny element z listy (zobacz wyżej jak zdefiniowana jest zmienna_arr_num)! To jest drugi (indeksowanie od zera) znak w zmiennej zmienna_arr_num potraktowanej jako string. Bo tutaj wszystko jest stringiem:
print(type(config['modul1']['zmienna_num']))
## <class 'str'>
print(type(config['modul1']['zmienna_flo']))
## <class 'str'>
Tak, ta liczba też jest stringiem. Więc trzeba ewentualnie zrobić jawny casting do int lub float jeśli potrzebujecie.
Wszystko poza tymi niedogodnościami wygląda ok, prawda? I tak musimy znać ścieżki (jaki moduł, jaka zmienna) poszczególnych parametrów, więc nic dziwnego że odwołujemy się do nich po konkretnych nazwach.
Zwróć uwagę na to co otrzymujemy w zmienna_txt z modul1. W konfiguracji wartość zmiennej jest w cudzysłowach, aby było jasne że to ciąg tekstowy… ale te cudzysłowy trafiają do naszej zmiennej! No słabo trochę (z jednej strony, z drugiej ok). No i trzeba odpowiednio to w konfiguracji wyeskejpować.
A jak to wygląda w R? Podobnie jak w Pythonie - mamy gotową bibliotekę (już nie wbudowaną, trzeba ją sobie zainstalować przez install.packages("configr")) - configr. Ta z kolei jest na tyle uniwersalna, że posłuży nam przy wszystkich omawianych dzisiaj formatach plików konfiguracyjnych. Przykładowy skrypt wyciągający dane z pliku .ini:
library(configr)
# wczytanie konfiguracji
config <- read.config("config.ini")
print(config)
## $modul1
## $modul1$zmienna_txt
## [1] "\"Jestem \\\"zmienną\\\" tekstową z 'ciapkami'\""
##
## $modul1$zmienna_num
## [1] "123"
##
## $modul1$zmienna_flo
## [1] "456.789"
##
## $modul2
## $modul2$zmienna_arr_txt
## [1] "['str1', 'str2', 'str3'] ; komentarz inline"
##
## $modul2$zmienna_arr_num
## [1] "[123, 456, 789]"
cat(config$modul1$zmienna_txt)
## "Jestem \"zmienną\" tekstową z 'ciapkami'"
cat(config$modul1$zmienna_num)
## 123
cat(config$modul2$zmienna_arr_txt)
## ['str1', 'str2', 'str3'] ; komentarz inline
Tutaj podobnie jak w Pythonie - komentarz inline nie jest obsługiwany (w sumie .ini nie mają jako takiej sztywnej definicji, a gdyby miały - taki komentarz byłby niedozwolony).
cat(config$modul2$zmienna_arr_num)
## [123, 456, 789]
Uwaga - wynik ponownie jest stringiem a nie listą, co widać tutaj:
cat(class(config$modul2$zmienna_arr_txt))
## character
tutaj:
cat(class(config$modul2$zmienna_arr_num))
## character
oraz tutaj:
cat(config$modul2$zmienna_arr_num[2])
## NA
Z grubsza więc jest tak samo jak w Pythonie. Podsumowując - pliki INI:
- pozwalają na komentarze - ale tylko jako jedna linia, bez inline
- każda wartość przypisana do zmiennej jest typu string
- nie można użyć zagnieżdżeń
[mailerlite_form form_id=4]
Konfiguracja .JSON
Pliki .ini nie pozwalają na zagnieżdżanie elementów - mamy płaską listę zmiennych (dla wygody upakowaną w moduły, o ile istnieje taka potrzeba). A pliki .json już takie zagnieżdżanie umożliwiają. Po co to? Ano na przykład w jednym pliku możemy trzymać konfigurację wszystkich środowisk (zagnieżdżony obiekt to konfiguracja jednego ze środowisk) i tylko jedną zmienną (też w pliku konfiguracyjnym) decydować którego środowiska używamy. Średnio to bezpieczne rozwiązanie, ale się da. Zbudujmy więc kolejną konfigurację, tym razem już szerszą - taką, która pozwala na wykorzystanie zarówno zagnieżdżenia pojedynczych obiektów jak i na zbudowanie listy obiektów:
{
"modul1": {
"zmienna_txt": "Jestem \"zmienną\" tekstową z 'ciapkami'",
"zmienna_num": 123,
"zmienna_flo": 456.789
},
"modul2": {
"zmienna_arr_txt": ["str1", "str2", "str3"],
"zmienna_arr_num": [123, 456, 789]
},
"modul3": {
"obiekt1": {
"nazwa": "Obiekt Jeden",
"numer": 1,
"tablica_num": [1, 1, 1],
"tablica_str": [
"obj 1 - string 1",
"obj 1 - string 2",
"obj 1 - string 3"
]
},
"obiekt2": {
"nazwa": "Obiekt dwa",
"numer": 2,
"tablica_num": [2, 2, 2],
"tablica_str": [
"obj 2 - string 1",
"obj 2 - string 2"
]
}
},
"modul4": [
{
"nazwa": "array of objects 1",
"wartosc": 1,
"inna_wartosc": "abc"
},
{
"nazwa": "array of objects 2",
"wartosc": 2,
"inna_wartosc": "def"
},
{
"nazwa": "array of objects 3",
"wartosc": 3,
"inna_wartosc": "ghi"
},
{
"nazwa": "array of objects 4",
"wartosc": 4,
"inna_wartosc": "jkl"
}
]
}
W powyższej konfiguracji modul1 oraz modul2 są żywcem przeniesione z pliku .ini, a kolejne dwa moduły pokazują rzeczy (cuda, o jakich nie śniło się filozofom! ;-) na które format INI nie pozwala.
W modul3 mamy dwa obiekty zagnieżdżone pod modułem. Oba obiekty z grubsza są takie same, ale są to dwa odrębne byty. Aby dostać się do nazwy obiektu trzeba przejść po drzewie: moduł -> obiekt -> pole -> wartość.
Z kolei w modul4 mamy cztery obiekty zagnieżdżone pod modułem ale w formie listy. Zatem aby dostać się do nazwy konkretnego elementu trzeba przejść ścieżką: moduł -> jeden z elementów listy -> pole -> wartość.
Odczytajmy taką konfigurację w Pythonie. Będzie bardzo prosto, o ile potrafisz odczytać pliki JSON.
import json
import pprint
pp = pprint.PrettyPrinter(indent=2)
# wczytujemy plik JSON
with open('config.json', 'r') as fp:
config = json.load(fp)
# jak wygląda w całości nasz obiekt?
pp.pprint(config)
## { 'modul1': { 'zmienna_flo': 456.789,
## 'zmienna_num': 123,
## 'zmienna_txt': 'Jestem "zmienną" tekstową z \'ciapkami\''},
## 'modul2': { 'zmienna_arr_num': [123, 456, 789],
## 'zmienna_arr_txt': ['str1', 'str2', 'str3']},
## 'modul3': { 'obiekt1': { 'nazwa': 'Obiekt Jeden',
## 'numer': 1,
## 'tablica_num': [1, 1, 1],
## 'tablica_str': [ 'obj 1 - string 1',
## 'obj 1 - string 2',
## 'obj 1 - string 3']},
## 'obiekt2': { 'nazwa': 'Obiekt dwa',
## 'numer': 2,
## 'tablica_num': [2, 2, 2],
## 'tablica_str': [ 'obj 2 - string 1',
## 'obj 2 - string 2']}},
## 'modul4': [ { 'inna_wartosc': 'abc',
## 'nazwa': 'array of objects 1',
## 'wartosc': 1},
## { 'inna_wartosc': 'def',
## 'nazwa': 'array of objects 2',
## 'wartosc': 2},
## { 'inna_wartosc': 'ghi',
## 'nazwa': 'array of objects 3',
## 'wartosc': 3},
## { 'inna_wartosc': 'jkl',
## 'nazwa': 'array of objects 4',
## 'wartosc': 4}]}
Wygląda (z grubsza, jeśli pominąć formatowanie) jak nasz źródłowy JSON, prawda? Jak dostać się do wartości kolejnych elementów? Tak jak do elementów słownika (dict) w Pythonie. Tutaj akurat słownik słowników:
print(config['modul1']['zmienna_txt'])
## Jestem "zmienną" tekstową z 'ciapkami'
A jak mamy dodatkowo listę to po prostu używamy indeksu:
print(config['modul3']['obiekt2']['tablica_str'][1])
## obj 2 - string 2
Dla listy obiektów - najpierw indeks na liście, a potem nazwa elementu:
print(config['modul4'][1]['nazwa'])
## array of objects 2
A co z typem? Jeśli liczba nie jest w ciapkach to…
print(type(config['modul1']['zmienna_num']))
## <class 'int'>
print(type(config['modul1']['zmienna_flo']))
## <class 'float'>
…jest tego typu którego być powinna (int lub float). Wszystko zgodnie z intuicją - dokładnie tak, jak można się spodziewać. Czy w R jest tak samo?
library(configr)
config <- read.config("config.json")
print(config)
## $modul1
## $modul1$zmienna_txt
## [1] "Jestem \"zmienną\" tekstową z 'ciapkami'"
##
## $modul1$zmienna_num
## [1] 123
##
## $modul1$zmienna_flo
## [1] 456.789
##
## $modul2
## $modul2$zmienna_arr_txt
## [1] "str1" "str2" "str3"
##
## $modul2$zmienna_arr_num
## [1] 123 456 789
##
## $modul3
## $modul3$obiekt1
## $modul3$obiekt1$nazwa
## [1] "Obiekt Jeden"
##
## $modul3$obiekt1$numer
## [1] 1
##
## $modul3$obiekt1$tablica_num
## [1] 1 1 1
##
## $modul3$obiekt1$tablica_str
## [1] "obj 1 - string 1" "obj 1 - string 2" "obj 1 - string 3"
##
## $modul3$obiekt2
## $modul3$obiekt2$nazwa
## [1] "Obiekt dwa"
##
## $modul3$obiekt2$numer
## [1] 2
##
## $modul3$obiekt2$tablica_num
## [1] 2 2 2
##
## $modul3$obiekt2$tablica_str
## [1] "obj 2 - string 1" "obj 2 - string 2"
##
##
## $modul4
## nazwa wartosc inna_wartosc
## 1 array of objects 1 1 abc
## 2 array of objects 2 2 def
## 3 array of objects 3 3 ghi
## 4 array of objects 4 4 jkl
##
cat(config$modul1$zmienna_txt)
## Jestem "zmienną" tekstową z 'ciapkami'
cat(config$modul3$obiekt2$tablica_str[2])
## obj 2 - string 2
Sięgamy do konkretnego elementu tablicy!
cat(config$modul4$nazwa[2])
## array of objects 2
Uwaga - inna kolejność parametrów niż w pliku konfiguracyjnym: najpierw mamy nazwę pola, a potem numer na liście (indeks) obiektów. Może to wprowadzić zamieszanie. Sprawdźmy jeszcze czy zachowane są typy danych:
cat(class(config$modul1$zmienna_num))
## integer
cat(class(config$modul1$zmienna_flo))
## numeric
Jest tak jak być powinno - liczby całkowite są typu integer, a zmiennoprzecinkowe - numeric.
Zwróć też uwagę, że config$modul4 to właściwie gotowy data frame… co ma swoje zalety. W Pythonie z użyciem Pandas też łatwo data frame’a z JSONa zbudować.
Podsumowując - pliki JSON:
- nie pozwalają na komentarze
- zachowane są typy danych
- stringi trzeba ubierać w ciapki
- można użyć zagnieżdżeń i list elementów/obiektów
Konfiguracja .YAML
Pliki YAML pozwalają na wszystko to co JSON, ale mają dwa miłe dodatki. Po pierwsze - są chyba nieco bardziej czytelne - nie ma tylu nawiasów. Ale trzeba dbać o wcięcia. Na szczęście większość edytorów potrafi tego pilnować (albo ma wtyczki do tego). Druga zaleta - komentarze. Zobaczmy przykładową konfigurację - identyczną z tą z JSONa:
modul1:
# tutaj można dodać komentarz
zmienna_txt: Jestem "zmienną" tekstową z 'ciapkami'
zmienna_num: 123
zmienna_flo: 456.789
modul2:
zmienna_arr_txt: # na tym poziomie też można komentować!
- "str1"
- "str2"
- "str3"
zmienna_arr_num: [123, 456, 789]
modul3:
obiekt1:
nazwa: "Obiekt Jeden"
numer: 1
tablica_num: [1, 1, 1]
tablica_str:
- "obj 1 - string 1"
- "obj 1 - string 2" # a nawet na tym!
- "obj 1 - string 3"
obiekt2:
nazwa: "Obiekt dwa"
numer: 2
tablica_num: [2, 2, 2]
tablica_str: ["obj 2 - string 1", "obj 2 - string 2"] # zapisane jako tablica, a nie od kresek
# oczywiście tutaj komentarze też są dozwolone
modul4:
- nazwa: array of objects 1
wartosc: 1
inna_wartosc: abc
- nazwa: array of objects 2
wartosc: 2
inna_wartosc: def
- nazwa: array of objects 3
wartosc: 3
inna_wartosc: ghi
- nazwa: array of objects 4
wartosc: 4
inna_wartosc: jkl
Przeczytajmy to w Pythonie. Potrzebujemy pakietu pyyaml - jeśli go nie masz to zainstaluj via pip lub conda (jeśli używasz Anacondy).
import yaml
import pprint
pp = pprint.PrettyPrinter(indent=2)
with open('config.yaml', 'r') as fp:
config = yaml.safe_load(fp)
pp.pprint(config)
## { 'modul1': { 'zmienna_flo': 456.789,
## 'zmienna_num': 123,
## 'zmienna_txt': 'Jestem "zmienną" tekstową z \'ciapkami\''},
## 'modul2': { 'zmienna_arr_num': [123, 456, 789],
## 'zmienna_arr_txt': ['str1', 'str2', 'str3']},
## 'modul3': { 'obiekt1': { 'nazwa': 'Obiekt Jeden',
## 'numer': 1,
## 'tablica_num': [1, 1, 1],
## 'tablica_str': [ 'obj 1 - string 1',
## 'obj 1 - string 2',
## 'obj 1 - string 3']},
## 'obiekt2': { 'nazwa': 'Obiekt dwa',
## 'numer': 2,
## 'tablica_num': [2, 2, 2],
## 'tablica_str': [ 'obj 2 - string 1',
## 'obj 2 - string 2']}},
## 'modul4': [ { 'inna_wartosc': 'abc',
## 'nazwa': 'array of objects 1',
## 'wartosc': 1},
## { 'inna_wartosc': 'def',
## 'nazwa': 'array of objects 2',
## 'wartosc': 2},
## { 'inna_wartosc': 'ghi',
## 'nazwa': 'array of objects 3',
## 'wartosc': 3},
## { 'inna_wartosc': 'jkl',
## 'nazwa': 'array of objects 4',
## 'wartosc': 4}]}
print(config['modul1']['zmienna_txt'])
## Jestem "zmienną" tekstową z 'ciapkami'
Porównaj wynik z tym jak ciąg został wpisany w konfigurację - jest znak w znak tak samo. To bardzo wygodne.
print(type(config['modul1']['zmienna_num']))
## <class 'int'>
print(type(config['modul1']['zmienna_flo']))
## <class 'float'>
Jak widać klasy danych liczbowych są zachowane. To również wygodne, ale nic szczególnego w porównaniu z JSONem.
print(config['modul3']['obiekt2']['tablica_str'][1])
## obj 2 - string 2
print(config['modul4'][1]['nazwa'])
## array of objects 2
Właściwie nie ma czego komentować - wszystko zachowuje się tak jak powinno. Typy zmiennych są rozpoznawane, stringi nie potrzebują ciapek, listy da się indeksować - nie ważne czy to listy elementów jednego typu czy całych obiektów.
A jak czytać to w R? Przy użyciu pakietu configr właściwie ciągle tak samo:
library(configr)
config <- read.config("config.yaml")
print(config)
## $modul1
## $modul1$zmienna_txt
## [1] "Jestem \"zmienną\" tekstową z 'ciapkami'"
##
## $modul1$zmienna_num
## [1] 123
##
## $modul1$zmienna_flo
## [1] 456.789
##
## $modul2
## $modul2$zmienna_arr_txt
## [1] "str1" "str2" "str3"
##
## $modul2$zmienna_arr_num
## [1] 123 456 789
##
## $modul3
## $modul3$obiekt1
## $modul3$obiekt1$nazwa
## [1] "Obiekt Jeden"
##
## $modul3$obiekt1$numer
## [1] 1
##
## $modul3$obiekt1$tablica_num
## [1] 1 1 1
##
## $modul3$obiekt1$tablica_str
## [1] "obj 1 - string 1" "obj 1 - string 2" "obj 1 - string 3"
##
## $modul3$obiekt2
## $modul3$obiekt2$nazwa
## [1] "Obiekt dwa"
##
## $modul3$obiekt2$numer
## [1] 2
##
## $modul3$obiekt2$tablica_num
## [1] 2 2 2
##
## $modul3$obiekt2$tablica_str
## [1] "obj 2 - string 1" "obj 2 - string 2"
##
##
## $modul4
## $modul4[[1]]
## $modul4[[1]]$nazwa
## [1] "array of objects 1"
##
## $modul4[[1]]$wartosc
## [1] 1
##
## $modul4[[1]]$inna_wartosc
## [1] "abc"
##
## $modul4[[2]]
## $modul4[[2]]$nazwa
## [1] "array of objects 2"
##
## $modul4[[2]]$wartosc
## [1] 2
##
## $modul4[[2]]$inna_wartosc
## [1] "def"
##
## $modul4[[3]]
## $modul4[[3]]$nazwa
## [1] "array of objects 3"
##
## $modul4[[3]]$wartosc
## [1] 3
##
## $modul4[[3]]$inna_wartosc
## [1] "ghi"
##
## $modul4[[4]]
## $modul4[[4]]$nazwa
## [1] "array of objects 4"
##
## $modul4[[4]]$wartosc
## [1] 4
##
## $modul4[[4]]$inna_wartosc
## [1] "jkl"
Jak widzimy zmienia się tylko delikatnie (w porównaniu do JSONa) struktura obiektu stworzonego po wczytaniu YAMLa. Jak sięgać do środka?
cat(config$modul1$zmienna_txt)
## Jestem "zmienną" tekstową z 'ciapkami'
cat(config$modul1$zmienna_num)
## 123
cat(class(config$modul1$zmienna_num))
## integer
cat(class(config$modul1$zmienna_flo))
## numeric
cat(config$modul2$zmienna_arr_txt)
## str1 str2 str3
cat(config$modul2$zmienna_arr_num)
## 123 456 789
Sięganie do elementu tablicy jest intuicyjne:
cat(config$modul3$obiekt2$tablica_str[2])
## obj 2 - string 2
Nawet jeśli to tablica bardziej złożonych obiektów:
cat(config$modul4[[2]]$nazwa)
## array of objects 2
Uwaga - inna kolejność sięgania do parametrów niż w przypadku sięgania do nich wczytanych z JSONa, ale tutaj zgodna z logiką. Trudniej z YAMLa (w porównaniu do JSONa) uzyskać data frame. Ale YAML to format do konfiguracji a nie przekazywana danych. Do danych JSON sprawdza się zdecydowanie lepiej (co widać we wszelakich API). Podsumowując - pliki YAML:
- pozwalają na komentarze na każdym poziomie
- zachowane są typy danych
- stringów mogą być bez ciapek
- można użyć zagnieżdżeń właściwie na każdym poziomie
- format daje nam trochę swobody w pisaniu (np. definiowanie tablic) i czyta się podobnie do naturalnego języka
No ideał! ;)
GitHub
Żeby było łatwiej i nie trzeba było kopiować kodu z tego wpisu wszystko wrzuciłem na swojego GitHuba - śmiało forkuj, jeśli potrzebujesz. A jeśli jesteś już tutaj to możesz też wykonać jeden klik i zapisać się na newsletter.