Organizacja kodu schematu GraphQL w aplikacji node.js

W pierwszym wpisie dotyczącym implementacji GraphQL w aplikacji node.js wspominałem, że chciałbym ten temat rozwinąć jeszcze szerzej. Wspominałem też, że jest to pierwsza bazowa implementacja, która z pewnością w przyszłości ewoluuje. I tak też się stało – poświęciłem ostatnio trochę czasu na organizacji kodu podczas deklarowania schematu GraphQL i tym właśnie chciałbym się z wami podzielić w dzisiejszym wpisie.

Jak było na początku?

Przy pierwszej iteracji projektu deklaracja elementów schematu (Query i Mutation) odbywało się w jednym miejscu zbiorczo dla każdego z typów.

'use strict';

import { GraphQLObjectType, GraphQLID, GraphQLList } from 'graphql';

import authorType from './../type/authorType';
import bookType from './../type/bookType';

import { getAllAuthors, getAuthorById } from './../../controllers/authorsController';
import { getBookById, getAllBooks } from './../../controllers/booksController';

export default new GraphQLObjectType({
    name: 'Query',
    description: 'Root type for queries',
    fields: {
        authors: {
            type: new GraphQLList(authorType),
            description: 'List of all authors',
            resolve: getAllAuthors
        },
        author: {
            type: authorType,
            description: 'Get author by ID',
            args: { id: { type: GraphQLID } },
            resolve: getAuthorById
        },
        book: {
            type: bookType,
            description: 'Get book by ID',
            args: { id: { type: GraphQLID } },
            resolve: getBookById
        },
        books: {
            type: new GraphQLList(bookType),
            description: 'List of all books',
            resolve: getAllBooks
        }
    }
});

Zacznijmy od tego, że pierwotna forma schematu, która została zaprezentowana w poprzednim wpisie nie była najgorsza. Sprawdzała się, bo w aplikacji póki co nie ma wielu akcji do wykonania i nawet jeśli cała deklaracja odbywała się w jednym miejscu, nie było to uciążliwe. Jednak rozbudowujemy sandbox, na którym warto trenować i sprawdzać różne rozwiązania. Popatrzmy na wklejony kod powyżej, dotyczy on deklarowania typu głównego jakim jest Query – wszystko ładowane jest w jednym miejscu i wraz z rozrastaniem się aplikacji wzrasta liczba linii kodu w tym pliku i wszystko staje się coraz mniej czytelne.

Jaki jest cel?

Dobrze by było odwrócić proces komponowania schematu ponieważ pozwoli nam to na lepsze panowanie nad kodem i zwiększy możliwości testowania. Po analizie kilku wariantów na organizację kodu dobrym sposobem wydaje się zastosowanie wzorca kompozyt. Oczywiście nie uda się dokonać tego w całości chociażby z tego względu, że JavaScript nie posiada interfejsów, ale będziemy się wzorować na głównych założeniach tego wzorca.

Rozdzielenie każdego elementu schematu

Z wyżej przedstawionego schematu każde pole (authors, author, books, book) zostaje przeniesione do osobnych klas, których instancje będą dołączane w odpowiednich momentach.

'use strict';

import { GraphQLID } from 'graphql';

import authorType from './../../type/authorType';

export default class AuthorQuery {
    constructor(name, action) {
        this._name = name;
        this._action = action;
    }

    getName() {
        return this._name;
    }

    schema() {
        return {
            type: authorType,
            description: 'Get author by ID',
            args: { id: { type: GraphQLID } },
            resolve: (root, { id }) => this._action.execute(id)
        }
    }
}

Dla przykładu: powyżej przedstawiona jest definicja zapytania, która pobiera dane autora. Funkcja schema zwraca praktycznie to samo co w pierwotnej wersji. Warto też zauważyć, że funkcja resolvera zostaje dołączona w konstruktorze, co znacznie ułatwi testowanie.

Resolver jako akcja – oderwanie od warstwy aplikacji

Kolejnym klockiem w układance jest stworzenie osobnych akcji. Powinny zostać zaprojektowane w taki sposób aby były całkowicie odseparowane od zasad panujących w GraphQL dzięki czemu zachowane zostaną osobne warstwy aplikacji.

'use strict';

export default class GetAuthorsAction {
    constructor(authorsDao) {
        this._authorsDao = authorsDao;
    }

    execute(authorId) {
        return this._authorsDao.getById(authorId);
    }
}

Być może wydaje się to przerost formy nad treścią gdyż funkcja execute nie robi nic spektakularnego, jednak warto myśleć przyszłościowo i mieć na uwadze, że projekty informatyczne ciągle się zmieniają, a jednym z lepszych sposobów, który pozwoli rozwijać aplikację jest rozbijcie odpowiedzialności.

Root type

Czas przejść poziom niżej i umożliwić teraz złączenie wszystkich pól w celu zbudowania schematu. Stworzona została odpowiednia klasa, którą można zobaczyć poniżej.

'use strict';

import { GraphQLObjectType } from 'graphql';
import _ from 'lodash';

export default class ObjectType {
    constructor(name, description) {
        this._name = name;
        this._description = description;
        this._fields = [];
    }

    schema() {
        return new GraphQLObjectType({
            name: this._name,
            description: this._description,
            fields: _.reduce(this._fields, (result, field) => _.set(result, field.getName(), field.schema()), {})
        });

    }

    addField(field) {
        this._fields.push(field);
        return this;
    }
}

Jeśli przyjrzeć się temu co zwraca funkcja schema można zauważyć, że jest to dokładnie to samo co zostało przedstawione na 1 listingu jednak w tym wypadku jest sparametryzowana co dodaje większej elastyczności, którą można dostosować odpowiednio zarówno do obiektu typu Query czy Mutation.

Budowanie elementów

Teraz czas złączyć typ Query wraz z jego wszystkimi polami – do tego celu stworzona została fabryka. W tym miejscu następuje też inicjalizacja akcji, które będą wstrzyknięte do elementów Query i wykorzystane jako resolvery.

'use strict';

import ObjectType from './rootObjectType'

import AuthorsQuery from './queries/authorsQuery';
import AuthorQuery from "./queries/authorQuery";
import BooksQuery from "./queries/booksQuery";
import BookQuery from "./queries/bookQuery";

import GetAuthorsAction from "../../actions/getAuthorsAction";
import GetAuthorAction from "../../actions/getAuthorAction";
import GetBookAction from "../../actions/getBookAction";
import GetBooksAction from "../../actions/getBooksAction";

export default function queryTypeFactory(authorsDao, booksDao) {
    return () => {
        const queryType = new ObjectType('Query', 'Root type for queries');
        queryType.addField(authorsQuery())
            .addField(authorQuery())
            .addField(booksQuery())
            .addField(bookQuery());

        return queryType.schema();
    };
    
    function authorsQuery() {
        const action = new GetAuthorsAction(authorsDao);
        return new AuthorsQuery('authors', action);
    }
    
    function authorQuery() {
        const action = new GetAuthorAction(authorsDao);
        return new AuthorQuery('author', action);
    }
    
    function booksQuery() {
        const action = new GetBooksAction(booksDao);
        return new BooksQuery('books', action);
    }
    
    function bookQuery() {
        const action = new GetBookAction(booksDao);
        return new BookQuery('book', action);
    }
}

Podobna fabryka powstaje dla typu Mutation po czym można już przejść do ostatniego kroku budowanie schematu.

Schemat

Stworzone wcześniej fabryki można wykorzystać do ustawienia ostatecznego schematu. Jak widać poniżej jest to teraz niezwykle prosta sprawa 😉

'use strict';

import { GraphQLSchema } from 'graphql';

import queryTypeFactory from './root/queryTypeFactory';
import mutationTypeFactory from "./root/mutationTypeFactory";

export default class SchemaFactory {
    constructor(authorsDao, booksDao) {
        this._queryFactory = queryTypeFactory(authorsDao, booksDao);
        this._mutationFactory = mutationTypeFactory(authorsDao, booksDao);
    }

    create() {
        return new GraphQLSchema({
            query: this._queryFactory(),
            mutation: this._mutationFactory()
        });
    }
}

Wywołanie schematu

Posiadając już wszystkie klocki poskładane ostatecznym krokiem jest „powołanie do życia” serwera GraphQL z wykorzystaniem nowo stworzonego schematu. Poniżej wycinek kodu z pliku index.js.

const schemaFactory = new SchemaFactory(authorDao, bookDao);
app.use('/', graphqlHTTP({
    schema: schemaFactory.create(),
    graphiql: true
}));

Podsumowanie

W dzisiejszym poście zaprezentowałem propozycję organizacji kodu podczas projektowania schematu GraphQL dzięki któremu można zyskać kilka korzyści:

  • stosowana jest zasada SRP
  • stosowana jest zasada DIP (o ile można tak uznać naciąganie JavaScriptu 😜)
  • łatwość w testowaniu wszystkich elementów w całkowitej separacji
  • rozdzielenie akcji od warstwy API – gdyby GraphQL w jakimś miejscu był złym rozwiązaniem to wystarczy przekierować użycie akcji w inne miejsce bez konieczności jej modyfikacji
  • rozrastająca się aplikacja API nie powinny stać się problemem w tak szybkim czasie jak miałoby to miejsce w pierwotnej wersji

Podobnie jak poprzednio obecna wersja projektu dostępna jest na GitHubie pod konkretnym tagiem, aby zamrozić obecny stan kodu.

Programista skupiony głównie wokół technologii webowych, ale nie przywiązujący się do konkretnych języków i narzędzi. Skoncentrowany na ciągłym rozwoju, zwolennik ruchu Software Crafmanship. Na codzień pracując w DAZN ma okazję rozwijać interesujący projekt do streamingu wydarzeń sportowych. Prywatnie fan sportu, a szczególnie piłki nożnej. Po godzinach próbuje również swoich sił w piwowarstwie domowym.
PODZIEL SIĘ