Node.js – Podstawy – Praca z modułami …

Wstęp

Moja podróż z Node’ m zaczęła się ponad rok temu i była to moja pierwsza styczność z JavaScript na większą skalę. Postanowiłem spisać moje notatki i przemyślenia w tym zakresie.

Celem poniższego artykułu jest pokazanie co umożliwia Node w zakresie pracy z modułami, jako bazowego mechanizmu, który jest niezbędny w momencie w którym nasza aplikacja będzie się powiększać i jeden plik o nazwie index.js przestanie nam wystarczać.

Co potrzebujemy na start

Jeśli mamy już zainstalowanego Node’ a na komputerze (może być wersja LTS) potrzebujemy punktu startowego (Entry point of application). Może nim być index.js. Aby tradycji stało się zadość niech nasz pierwszy skrypt zawiera:

console.log('Hello There')

Uruchomienie jest równie proste jak sam skrypt, wystarczy uruchomić skrypt w następujący sposób:

node ./index.js.

Efekt:

Hello There

To tyle w zakresie podstaw, teraz przyjrzyjmy się co robić gdy kod zaczyna puchnąć

Gdy kod zaczyna robic się zbyt duży

Zacznijmy od kilku pytań pomocniczych:

  • Dlaczego mam tego używać?
  • Po co mam sobie zawracać głowę jakimś importowaniem modułów?

W pewnym momencie tworzenia aplikacji zauważymy, że pojedynczy plik, trzymający całość kodu staje się bardzo kłopotliwym rozwiązaniem. Można powiedzieć, że struktura aplikacji jest bardzo prosta i jeśli szukamy błędu to na pewno będzie w index.js, ale… traktowałbym to stwierdzenie z przymrużeniem oka, a na pewno nie traktowałbym go jako argument za.

W momencie kiedy kodu będzie przybywało poruszanie po nim stanie się trudne. Trzymanie tego w jednym pliku spowoduje, że ów plik będzie nam puchł w nieskończoność, a poruszanie się po nim w celu np.: znalezienia miejsca wystąpienia błędu będzie problematyczne ze względu na jego długość. Rozwiązaniem tego problemu będzie podzielenia go na mniejsze części. W naszym wypadku na moduły.

Przy pracy z kodem zależy nam na kilku rzeczach:

  • designie, który ułatwi nam rozszerzanie, modyfikowanie, utrzymywanie kodu oraz testowanie,
  • uwspólnianiu kodu,
  • nie duplikowanie kodu,
  • łatwiejszemu diagnozowaniu i znajdowaniu miejsc wystąpienia błędu.

W tym zakresie podział na mniejsze pliki zawierające część funkcjonalności wydaje się być dobrym rozwiązaniem.

Moduł:

Przyjrzyjmy się bliżej co w tym zakresie udostępnia nam Node.

Podstawowym elementem struktury jest moduł. Każdy plik traktowany jest jako osobny moduł. Tak – każdy. Pamiętacie jak wcześniej uruchomiliśmy nasz index.js. Uruchomienie go poprzez polecenie

node ./index.js.

sprawiło, że został on wczytany jako główny moduł tzw. main. Przypomina to w pewien sposób to co można zaobserwować w innych językach typu C#, C++ gdzie musiała istnieć funkcja main, która była właśnie punktem wejściowym naszej aplikacji.

Informacje o module można zobaczyć dzięki obiektowi Module :

Module {
  id: '.',
  exports: {},
  parent: null,
  filename: 'E:\\Code\\MattAsm\\index.js',
  loaded: false,
  children: [],
  paths:
   [ 'E:\\Code\\MattAsm\\node_modules',
     'E:\\Code\\node_modules',
     'E:\\node_modules' ] }

Module jest obiektem, który zawiera opis modułu. Można tu znaleźć kilka wartościowych informacji:

  • id – identyfikator modułu. W większości przypadków jest to pełna ścieżka dostępowa do modułu.
  • exports– o tym co moduł udostępnia na zewnątrz,
  • parent – jaki moduł zaimportował ten moduł w celu użycia,
  • filename– pełna ścieżka do pliku,
  • children– jakie moduły ten moduł zaimportował, gdyż potrzebuje ich do działania.

Obiekt ten będziemy jeszcze obserwować kilkukrotnie w tym artykule.

Jak importować moduły:

Do ładowania modułów służy nam module loader, którym zarządzamy za pomocą polecenia require.

Spróbujmy go użyć na prostym przykładzie biblioteki wbudowanej.

const etykietka = require('fs');

W tym konkretnym przypadku ładujemy bibliotekę fs, która jest jedną z bibliotek core’owych Node’ a. Nie wymaga żadnych dodatkowych kroków związanych z instalacją. Umożliwia ona np. odczytywanie zawartości plików.

Require przyjmuje string, który określa nazwę biblioteki lub ścieżkę, a zwraca to co eksportuje dany moduł. Istotne jest, że używając polecenia w taki sposób to my decydujemy pod jaką nazwą będziemy mieli dostęp do zawartości modułu.

Od tego momentu do wszystkiego co eksportuje moduł będziemy mieli dostęp poprzez etykietka.

Require umożliwia nam uzyskanie dostępu do:

  • bibliotek core’owych Node’ a,
  • modułów, które ściągnęliśmy za pomocą NPM do node_modules,
  • napisanych przez nas samych modułów.

Biblioteki core’owe są dostępne od razu po instalacji Node’a. Moduły zewnętrzne są dostępne po zainstalowaniu ich za pomocą polecenia npm install, są umieszczane w podfolderze node_modules. Require daje nam dostęp tylko do lokalnych modułów. Sam nie ma możliwości dociągania bibliotek poprzez protokół http.

W przypadku bibliotek core’owych oraz zewnętrznych, używamy ich nazwy bez podawania pełnej ścieżki dostępowej. Według twórców odwołania bezpośrednio do podfolderów node_modules jest uznawane za złą praktykę.

W tych przypadkach podajemy tylko nazwę biblioteki, a require przeszuka już sam katalog bibliotek core’owych oraz node_modules.

Definiowanie modułu:

Pora na omówienie w jaki sposób operować na zdefiniowanych przez nas modułach i bliżej przyjrzeć się w jaki sposób działa require.

Zdefiniujmy najpierw własny moduł i nazwijmy go “myModule.js”. Dla ułatwienia, będzie on utworzony w tym samym folderze co index.js.

Jeśli spróbujemy załadować ten moduł przy pomocy

const myModule = require('./mymodule.js')

zobaczymy, że zmienna myModule jest pustym obiektem: {} Jest to spowodowane tym, że nic nie eksportujemy. Require sprawi, że całość skryptu zostanie uruchomiona, ale nic nie zostanie udostępnione na zewnątrz. Jeśli zawartością naszego modułu będzie:

console.log('Hello Module')

To efektem uruchomienia require będzie:

Hello Module

Zmieniając zawartość modułu na console.log(module) będziemy mogli przyjrzeć się obiektowi module opisującemu nasz moduł. Efekt:

Module {
  id: 'E:\\Code\\MattAsm\\mymodule.js',
  exports: {},
  parent:
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: 'E:\\Code\\MattAsm\\index.js',
     loaded: false,
     children: [ [Circular] ],
     paths:
      [ 'E:\\Code\\MattAsm\\node_modules',
        'E:\\Code\\node_modules',
        'E:\\node_modules' ] },
  filename: 'E:\\Code\\MattAsm\\mymodule.js',
  loaded: false,
  children: [],
  paths:
   [ 'E:\\Code\\MattAsm\\node_modules',
     'E:\\Code\\node_modules',
     'E:\\node_modules' ] }

Wniosek: obiekt module zawsze zawiera informacje o aktualnym module w którym się znajdujemy.

Co można wyczytać z powyższego obiektu:

  • że parentem naszego modułu jest nasz skrypt index.js. To on go importował,
  • że nasz moduł nie posiada innych modułów załadowanych, o tym świadczy pusta kolekcja children. => children:[],
  • nic kompletnie nie eksportuje. O czym świadczy pusty obiekt exports. => exports: {}.

Jeśli zdecydujemy się coś udostępnić w zakresie modułu mamy na to kilka sposobów: odwołanie się bezpośrednio poprzez module.exports. Jest to obiekt, który pozwala nam zdefiniować co eksportujemy. Można to zrobić za pomocą: module.exports.nazwa. Obiekty eksportowane przypisujemy do właściwości obiektu eksportowanego. Może to być funkcja, klasa, wartość. Nazwa właściwości będzie widoczna poza modułem, Do module.exports przypisujemy obiekt który chcemy eksportować. Np: module.exports = 1. używamy aliasu exports, który tak naprawdę jest aliasem na poziomie modułu do module.exports. istotne jest, że nadpisanie exports nowym obiektem nie spowoduje nadpisania module.exports. Alias exports służy nam tylko i wyłącznie do dodawania nowych właściwości do obiektu eksportowanego, a nie tworzenie nowego, jeśli spróbujemy nie uzyskamy oczekiwanego efektu.

exports = {value : 1}
console.log(module.exports)

Efekt:

{}

Spróbujmy wyeksportować naszą zmienną, która przechowuje wartość 4.

const veryRandomValue = 4
module.exports.veryRandomValue = veryRandomValue;
 

Tym razem pozwólcie, że wyświetlę tylko zawartość module.exports i tak jak można się było spodziewać zobaczymy tylko to:

console.log(module.exports)
 
{ veryRandomValue: 4 }

Jeśli zdecydujemy się by eksportować tylko jedną rzecz, to możemy bezpośrednio przypisać to do module.exports.

module.exports = 4
console.log(module.exports)
 
4

To od nas zależy co chcemy eksportować. Ten ostatni można traktować jako exports.default 🙂

Załóżmy, że nasz moduł eksportuje dwie stałe:

module.exports.veryRandomValue = 4
module.exports.veryRandomerValue = 44
console.log(module.exports)
 
{ veryRandomValue: 4, veryRandomerValue: 44 } 

Importowanie modułów:

Przyjrzyjmy się jakie możliwości mamy w zakresie importu. W pliku index.js użycie require(‘./mymodule.js’) daje nam dostęp do tego co eksportujemy w module. Dosłownie do tego. Czyli jeśli eksportujemy obiekt to require zwróci nam obiekt, jeśli jedną rzecz to do tej właśnie rzeczy.

const mymodule = require('./mymodule')
console.log(mymodule)
{ veryRandomValue: 4, veryRandomerValue: 44 }

a efektem takie fragmentu kodu:

console.log(c.veryRandomvalue)

będzie:

4

Możliwe jest także odwołanie się do właściwości obiektu eksportowanego bezpośrednio na wyniku pracy require().

const veryRandomValue = require('./mymodule').veryRandomValue
 

Ale także użyć możliwości dekompozycji obiektów języka javascript i wyłuskać te właściwości które nas interesują.

const {veryRandomerValue, veryRandomValue} = require('./mymodule')
 

Require ma dodatkowe możliwości, o których warto wspomnieć. Nie musimy podawać rozszerzeń plikowych skryptu. Najpierw poszuka pliku o takiej nazwie, jeśli nie będzie takowego to: Poszuka o takiej nazwie i rozszerzeniu .js, następnie jeśli takiego nie będzie to .json i na samym końcu .node. jeśli podamy nazwę podfolderu To najpierw sprawdzi czy istnieje plik package.json a w nim punkt main. Jeśli nie będzie istnieć to postara się poszukać pliku o nazwie index.js wewnątrz tego folderu, potem index.json i na samym końcu index.node. Jest to związane z tym, że w pierwszym kroku node.js stara odnaleźć plik, a dokładniej absolutną ścieżkę do plik który chcemy załadować.

Odwołując się za pomocą obiektu module i wykorzystując właściwość children możemy zobaczyć jakie moduły są dostępne w zakresie tego modułu.

Jak widać eksportowanie i importowanie daje nam sporo możliwości.

Cache:

Następnym mechanizmem o którym chcę wspomnieć jest mechanizm cache’owania. Jeśli dany moduł został wczytany to zostanie on scache’owany pod nazwą pod którą go odczytaliśmy. Innymi słowy jeśli wczytamy go dwa razy to nie spowoduje to ponownego uruchomienia całego skryptu. Zmodyfikujemy nasz skrypt by wyświetlał “Hello”.

W naszym pliku index.js wpisujemy dwie linijki, które teoretycznie spowodują dwukrotne załadowanie modułu.

const mymodule = require('./mymodule')
const mymodule2 = require('./mymodule')
 

Ale efekt jest następujący:

Hello.

Każde następne odwołanie się za pomocą require() przy wykorzystaniu nazwy mymodule spowoduje, że wykorzystany zostanie obiekt cache’u (możemy zaobserwować co zostało scache’owane za pomocą require.cache) zamiast ponownego odczytu. Pamiętacie jak wspominałem o nazwie jaką podajemy w require. Na systemach operacyjnych lub plikowych gdzie nieważna jest wielkość liter (Case-Insensitive) jeśli odwołamy się do modułu wykorzystują innego stringa np. duże litery zamiast małych to moduł zostanie wczytany ponownie, gdyż dla require będzie to inny moduł.

const mymodule = require('./mymodule')
const mymodule2 = require('./MYMODULE')

Efekt:

Hello 
Hello

Uruchomienie

console.log(module.children)

Da efekt:

[Module {
    id: 'E:\\Code\\MattAsm\\mymodule.js',
    exports: {},
    parent:
     Module {
       id: '.',
       exports: {},
       parent: null,
       filename: 'E:\\Code\\MattAsm\\index.js',
       loaded: false,
       children: [Circular],
       paths: [Array] },
    filename: 'E:\\Code\\MattAsm\\mymodule.js',
    loaded: true,
    children: [],
    paths:
     [ 'E:\\Code\\MattAsm\\node_modules',
       'E:\\Code\\node_modules',
       'E:\\node_modules' ] },
  Module {
    id: 'E:\\Code\\MattAsm\\MYMODULE.js',
    exports: {},
    parent:
     Module {
       id: '.',
       exports: {},
       parent: null,
       filename: 'E:\\Code\\MattAsm\\index.js',
       loaded: false,
       children: [Circular],
       paths: [Array] },
    filename: 'E:\\Code\\MattAsm\\MYMODULE.js',
    loaded: true,
    children: [],
    paths:
     [ 'E:\\Code\\MattAsm\\node_modules',
       'E:\\Code\\node_modules',
       'E:\\node_modules' ] } ]

Podobną informację możemy zauważyć w require.cache. Moduł mymodule został załadowany dwukrotnie i będzie widoczny od tego momentu pod dwoma różnymi nazwami.

Importowanie modułów z bliska

Proces ładowanie modułów składa się z kilku kroków:

  • Pierwszy krok: Po podaniu stringa do funkcji require Node stara się odnaleźć plik. Próbuje znaleźć absolutną ścieżkę do tego pliku.
  • Druga krok: załadowanie zawartości pliku do pamięci.
  • Trzeci krok: owrappowanie (opakowanie) modułu – o tym wcześniej nie wspomniałem:
    • jak możecie zauważyć poniżej, exports, require oraz module nie są zmiennymi globalnymi, a parametrami przekazywanymi do opakowanego modułu,
    • zawartość modułu nie zostaje zmieniona, a nasz moduł zostanie przekształcony do postaci:
(function(exports, require, module, __filename, __dirname) {
// Tutaj zostanie wklejony nasz moduł
});
  • Czwarty krok to ewaluacja kodu modułu przez JavaScript VM.
  • Ostatni krok to scache’owanie modułu.

Podsumowanie:

W przypadku dużych aplikacji chcemy za wszelką cenę uniknąć sytuacji w której mamy jeden plik, który zawiera cały nasz kod. Podział strukturalny na podmoduły jest lepszym rozwiązaniem i daje nam większe możliwości i kontrolę nad zmianami oraz zmniejsza prawdopodobieństwo wystąpienia błędów.

Eksportowanie daje nam możliwość w bardziej świadomy sposób definiować jakie obiekty są publiczne dla innych, a jakie są prywatne w obrębie modułu. To my definiujemy nasze API. Część kodu może służyć nam tylko do wykonania pewnych czynności, których efekty nie będą wypływały poza zakres modułu lub też będą widoczne dzięki udostępnionym przez nas obiektom.

Jeśli jawnie nie sprecyzujemy co eksportujemy nasz moduł nie udostępni niczego.

Do importowania służy require, który da nam dostęp do tego co jest eksportowane dany moduł i tylko do tego.

Kod z modułu zostanie uruchomiony tylko raz pod warunkiem, że będziemy zawsze odwoływali się do niego w ten sam sposób. Jeśli zostanie spełniony ten warunek mechanizm cache’owania modułów zapewni nam dostęp do tak samo stworzonego modułu, niezależnie w ilu miejscach go będziemy starali się importować.

Importowanie i eksportowanie jest jednym z podstawowych mechanizmów wbudowanych w Node i nie należy się go bać. Obiekty module, require.cache i require.main pokazują użyteczne informację o samych modułach, o tym co jest eksportowane, cache’owane oraz o tym, który moduł jest naszym głównym punktem wejściowym aplikacji.

W przyszłości planowane jest wprowadzenie mechanizmów Import, Export z ES6.

Warto przeczytać:

W dzień Senior Big Data Architect | Lead Developer | Software Developer w firmie Future Processing, w nocy śpi. Ponad 10 lat doświadczenia w zakresie wytwarzania oprogramowania w różnych technologiach oraz domenach, również w takich, w których nikt nie chciał pracować. Jak trzeba usunąć problem w dowolnej dziedzinie to wiesz do kogo dzwonić :) Zafascynowany rozwojem technologii związanej z przetwarzaniem danych a w szczególności tworzeniem rozwiązań z rodziny Big Data. Prelegent oraz organizator licznych wydarzeń, których głównym celem jest dzielenie się wiedzą oraz krzewienie potrzeby stosowania dobrych praktyk, w celu maksymalizacji jakości wytwarzanego produktu. Współorganizator Wakacyjnych Praktyk w Future Processing oraz prowadzący przedmiot na Politechnice Śląskiej „Tworzenie Oprogramowania w Zmiennym Środowisku Biznesowym”.
PODZIEL SIĘ