dotCMS / core

Headless/Hybrid Content Management System for Enterprises
http://dotcms.com
Other
852 stars 467 forks source link

Jalinson: Drop Everything & Learn Jun 19 - Jul 9 #28965

Closed nollymar closed 2 months ago

zJaaal commented 3 months ago

I'll be doing this Udemy Course and tracking my progress in the way using this ticket

zJaaal commented 3 months ago

JavaScript Design Patterns

Time spent: 37 minutes

What did I learned?

At the moment I learned about the first 2 principles of SOLID and created 2 JS Snippets that work as example for both.

S (Single Responsibility Principle)

All classes should have one responsibility and not mix responsibility. We should avoid the creation of a God Object and apply the Separation of Concerns Principle

import fs from 'fs';

// A class should have one responsibility in this case manage a Journal
class Journal {
    constructor() {
        this.entries = [];
        this.count = 0;
    }

    addEntry(text) {
        this.entries.push(`${++this.count}: ${text}`);

        return this.count;
    }

    removeEntry(index) {
        this.entries.splice(index, 1);
    }

    toString() {
        return this.entries.join('\n');
    }

    // Since all the operations that are below this comment are related and probably will have the same behavior, they could be in another class.

    // This is an antipattern because we are mixing responsibilities, Journal should only manage entries
    // save(filename) {
    //     fs.writeFileSync(filename, this.toString());
    // }

    // // This is an antipattern because we are mixing responsibilities, Journal should only manage entries
    // load(filename) {
    //     // Load from file
    // }

    // // This is an antipattern because we are mixing responsibilities, Journal should only manage entries
    // loadFromWeb(url) {
    //     // Load from web
    // }
}

// This class should be responsible for saving and loading the journal
// Also this way we apply the separation of concerns principle and avoid th God object antipattern
class PersistenceManager {
    preprocess(j) {
        // Do something
    }

    saveToFile(journal, filename) {
        fs.writeFileSync(filename, journal.toString());
    }
}

let j = new Journal();

j.addEntry('I started a course today.');
j.addEntry('I am learning about design patterns.');

console.log(j.toString());

let p = new PersistenceManager();

let filename = 'journal.txt';

// Dont want to run this, is just for the example
// p.saveToFile(j, filename);

O (Open and Closed Principle)

All classes should be opened for extension and closed for modification. This means that once a Class is created, tested and deployed we should avoid the modification of it and instead design it and write it in a way that we could extend it without modifying the base class.

And it states that we should avoid the space explosion anti-pattern. Which means that if we let a class open for modification, we can end with a class that covers infinite cases in infinite methods and infinite properties, as the example shows in the ProductsFilter class

// Just enums to handle constants
let Color = Object.freeze({
    red: 'red',
    green: 'green',
    blue: 'blue'
});

let Size = Object.freeze({
    small: 'small',
    medium: 'medium',
    large: 'large'
});

// Class that represents a product
class Product {
    constructor({ name, color, size }) {
        this.name = name;
        this.color = color;
        this.size = size;
    }
}

// Objects are open for extension but closed for modification.
class ProductFilter {
    // This class represents the anti-pattern of the open-closed principle

    filterByColor(products, color) {
        return products.filter((p) => p.color === color);
    }
    // This is modifying the class, we should avoid this
    filterBySize(products, size) {
        return products.filter((p) => p.size === size);
    }
    // This is modifying the class, we should avoid this
    filterBySizeAndColor(products, size, color) {
        return products.filter((p) => p.size === size && p.color === color);
    }

    // State space explosion, with more and more criteria the number of methods grows exponentially
    // 3 criteria = 7 methods
    // color
    // size
    // price
    // size and color
    // size and price
    // color and price
    // size and color and price
    // ...
}

// Criterias

// This way we don't need to modify the BetterFilter class to add new criterias
class ColorSpecification {
    constructor(color) {
        this.color = color;
    }

    isSatisfied(item) {
        return item.color === this.color;
    }
}

class SizeSpecification {
    constructor(size) {
        this.size = size;
    }

    isSatisfied(item) {
        return item.size === this.size;
    }
}

// Combinator

// This way we can combine criterias
class AndSpecification {
    constructor(...specs) {
        this.specs = specs;
    }

    isSatisfied(item) {
        return this.specs.every((spec) => spec.isSatisfied(item));
    }
}

// Better filter, that is open for extension but closed for modification
class BetterFilter {
    filter(items, spec) {
        return items.filter((item) => spec.isSatisfied(item));
    }
}
// init products
let products = [
    {
        name: 'Apple',
        color: Color.green,
        size: Size.small
    },
    {
        name: 'Tree',
        color: Color.green,
        size: Size.large
    },
    {
        name: 'House',
        color: Color.blue,
        size: Size.large
    }
].map((p) => new Product(p));

let pf = new ProductFilter();

console.log('Green products (old):');

for (let p of pf.filterByColor(products, Color.green)) {
    console.log(`* ${p.name} is green`);
}

// With this approach we can add new criterias without modifying the BetterFilter class
let bf = new BetterFilter();
let colorSpec = new ColorSpecification(Color.green);

console.log('Green products (new):');

for (let p of bf.filter(products, colorSpec)) {
    console.log(`* ${p.name} is green`);
}

let sizeSpec = new SizeSpecification(Size.large);

console.log('Large and Green products:');

// Combine color and size Specifications
let andSpec = new AndSpecification(colorSpec, sizeSpec);

for (let p of bf.filter(products, andSpec)) {
    console.log(`* ${p.name} is large and green`);
}
zJaaal commented 2 months ago

JavaScript Design Patterns

Time spent: 1h 45min

What did I learned?

In this iteration I completed the section 2 of the Course. It covers the SOLID principles before jumping it to Design Patterns.

I also did some research while seeing the videos.

L (Liskov Substitution Principle)

It states that any subclass should be able to replace the superclass without affecting the correctness of the program.

Here's an snippet.

// Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

// This code should fail, since I'm redeclaring classes, is just for learning purposes

class Rectangle {
    constructor(width, height) {
        this._width = width;
        this._height = height;
    }

    get width() {
        return this._width;
    }

    set width(value) {
        this._width = value;
    }

    get height() {
        return this._height;
    }

    set height(value) {
        this._height = value;
    }

    get area() {
        return this._width * this._height;
    }

    toString() {
        return `${this._width}x${this._height}`;
    }
}

// This is a violation of Liskov Substitution Principle. Square is a subclass of Rectangle, but it doesn't work as expected.
class Square extends Rectangle {
    constructor(size) {
        super(size, size);
    }

    set width(value) {
        this._width = this._height = value;
    }

    set height(value) {
        this._width = this._height = value;
    }
}

let rc = new Rectangle(2, 3);

console.log(rc.toString());

let sq = new Square(2);

sq.width = 4; // We should not be able to set the width of a square.

console.log(sq.toString(), 'should be 4x4');

// Problem
// This should work for both Rectangle and Square, because Square is a subclass of Rectangle. But it doesn't work for Square, violating Liskov Substitution Principle.
let useIt = (rc) => {
    let width = rc._width; // We should not be able to access the width of a square this way, since is hinted to be a private property
    rc.height = 10; // This will change the width of the square

    console.log(`Expected area of ${10 * width}, got ${rc.area}`);
};

useIt(rc);
useIt(sq); // This will give wrong result, since the width of the square will be changed.

// Teacher didn't provide a solution, but I think I can provide one.

// This way all shapes will have the same interface, and we can use the same function for all shapes, they have different implementations, but the same interface.
// Square and Rectangle are not related in this case, they are both shapes, but they don't share the same properties, so they shouldn't be related in the inheritance chain.
// This way we can use the same function for all shapes, and we can be sure that it will work for all shapes.

class Shape {
    _type = 'Raw Shape';

    get type() {
        return this._type;
    }

    get area() {
        throw new Error('Area method should be implemented');
    }

    toString() {
        throw new Error('toString method should be implemented');
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this._width = width;
        this._height = height;
        this._type = 'Rectangle';
    }

    get width() {
        return this._width;
    }

    set width(value) {
        this._width = value;
    }

    get height() {
        return this._height;
    }

    set height(value) {
        this._height = value;
    }

    get area() {
        return this._width * this._height;
    }

    toString() {
        return `${this._width}x${this._height}`;
    }
}

class Square extends Shape {
    constructor(size) {
        super();
        this._size = size;
        this._type = 'Square';
    }

    get size() {
        return this._size;
    }

    set size(value) {
        this._size = value;
    }

    get area() {
        return this._size * this._size;
    }

    toString() {
        return `${this._size}x${this._size}`;
    }
}

// In this case, useIt is not a valid function, since it relies in the fact that Rectangle and Square are related, but they are not.

// And example would be this new useIt function

useIt = (shapes) => {
    shapes.forEach((shape) => {
        try {
            console.log(`This shape is a ${shape.type} with area ${shape.area}`); // This will work for all shapes, because they all have the same interface
        } catch (error) {
            console.log(error.message); // in case the method is not implemented
        }
    });
};

I (Interface Segregation Principle)

It states that any interface that we create should be designed in a way that when we implement it, we shouldn't have any dangling properties or not implemented methods, because our classes or objects should work as a whole and not as a piece of an interface.

This way we can comply to principles as the least surprise principle, our methods should be as predictable as possible.

Here's an snippet.

// Example class
class NotImplementedError extends Error {
    constructor(name) {
        super(`${name} is not implemented`);
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, NotImplementedError); // Captures a stack trace for an error
        }
    }
}

// Document to use in Machines
class Document {
    constructor(name) {
        this.name = name;
    }
}

// Javascript does not have interfaces, but we can simulate them using classes
// TypeScript has interfaces, which are a better way to implement the Interface Segregation Principle
// This is our Interface
class Machine {
    constructor() {
        if (this.constructor.name === 'Machine') {
            throw new Error('Machine is an abstract class'); // This way we can't create an instance of Machine. Is ugly, but is the JS way since we don't have abstract classes
        }
    }

    print(doc) {} // This way we can use the same function for all machines, and we can be sure that it will work for all machines
    fax(doc) {} // This way we can use the same function for all machines, and we can be sure that it will work for all machines
    scan(doc) {} // This way we can use the same function for all machines, and we can be sure that it will work for all machines
}

class MultiFunctionPrinter extends Machine {
    print(doc) {
        // Do something
    }

    fax(doc) {
        // Do something
    }

    scan(doc) {
        // Do something
    }
}

// This is not user friendly, since printer and fax are not implemented, making it an anti-pattern
class OldFashionedPrinter extends Machine {
    print(doc) {
        // Do something
    }

    // The following methods are not implemented because OldFashionedPrinter is an old fashioned printer and it can't fax or scan
    fax(doc) {
        // We can avoid this by not implementing the method, but it will call the parent method which is also empty
        // But we need to comply to the principle of least surprise, we need predictable results for our users
        // So we need throw an error to let the user know that this method is not implemented and will not work because the printer is old fashioned
        throw new ErrNotImplementedErroror('fax');
    }

    scan(doc) {
        // We can avoid this by not implementing the method
        // But we need to comply to the principle of least surprise, we need predictable results for our users
        // So we need throw an error to let the user know that this method is not implemented and will not work because the printer is old fashioned
        throw new NotImplementedError('scan');
    }
}

let printer = new OldFashionedPrinter();

printer.fax(); // This will throw an error because the method is not implemented

// This means that Machine is not a good interface, because it has methods that are not implemented by all machines

// We can fix this by creating interfaces that are more specific to the machine we want to implement

class Printer {
    constructor() {
        if (this.constructor.name === 'Printer') {
            throw new Error('Printer is an abstract class');
        }
    }

    print(doc) {}
}

class Scanner {
    constructor() {
        if (this.constructor.name === 'Scanner') {
            throw new Error('Scanner is an abstract class');
        }
    }

    scan(doc) {}
}

// This is not possible in JS, but you get the idea
// class MultiFunctionDevice extends Printer, Scanner {
//     constructor() {
//         super();
//     }
// }

// We can do a JS Hack as solution, it was explained in the lecture but that is really complex and obscure and I think I will never use it, so no worth to learn it now and also the teacher said we will revisit that functionality later.

// but the idea is to segregate the interfaces in a way that we don't leave dangling methods that are not implemented by all machines

D (Dependency Inversion Principle)

It states that any High Level Module should only depend on abstract classes or interfaces, that way if we change any Low Level Module that works as implementation of any of these abstract classes or interfaces, we won't need to also change the High Level Module in order to make it work.

Dependency Injection Pattern complies with this principle.

Also learned what High/Low Level Modules are.

Here's an snippet.

// Dependency Inversion Principle (DIP)
// High-level modules should not depend on low-level modules. Both should depend on abstractions.

// An enum to represent the relationship between two people
const Relationship = Object.freeze({
    parent: 0,
    child: 1,
    sibling: 2
});

// Basic High Level module, this could have a lot of methods to work with people
class Person {
    constructor(name) {
        this.name = name;
    }
}

class RelationshipBrowser {
    constructor() {
        if (this.constructor.name === 'RelationshipBrowser') {
            throw new Error('RelationshipBrowser is abstract!');
        }
    }

    findAllChildrenOf(name) {
        throw new Error('findAllChildrenOf is abstract!');
    }
}

// Low-level module, a low level module is a module that works with High-level modules as Person and Relationships
// Is basically a module that stores data and manage High-level modules
class Relationships extends RelationshipBrowser {
    constructor() {
        super();
        this.data = [];
    }

    // Not mentioned but this violates the open-closed principle, we can have N combinations of relationships
    addParentAndChild(parent, child) {
        this.data.push({
            from: parent,
            type: Relationship.parent,
            to: child
        });
    }
    // This way Research class can use this method to find all children of a person and don't need to know how the data is stored
    findAllChildrenOf(name) {
        return this.data
            .filter((r) => r.from.name === name && r.type === Relationship.parent)
            .map((r) => r.to);
    }
}

// High-level module, because it uses low-level modules and works
class Research {
    // This class is tightly coupled with Relationships class, if we want to change the way we store relationships we need to change this class
    // This means that it violates the dependency inversion principle
    // if we refactor the Relationships class to use a database, we need to change this class
    // constructor(relationships) {
    //     // Find all children of John
    //     let relations = relationships.data;
    //     let childrenOfJohn = relations.filter(
    //         (r) => r.from.name === 'John' && r.type === Relationship.parent
    //     );
    //     for (let rel of childrenOfJohn) {
    //         console.log(`John has a child named ${rel.to.name}`);
    //     }
    // }

    // This way we can use the RelationshipBrowser interface to find all children of a person and don't need to know how the data is stored
    // Now the High Level module doesn't depend on the Low Level module, it depends on an abstraction
    // If browser changes the way it finds children, we don't need to change this class, because it should have the same interface
    constructor(browser) {
        let childrenOfJohn = browser.findAllChildrenOf('John');
        for (let { name } of childrenOfJohn) {
            console.log(`John has a child named ${name}`);
        }
    }
}

let parent = new Person('John');
let child1 = new Person('Chris');
let child2 = new Person('Matt');

let relationships = new Relationships();

relationships.addParentAndChild(parent, child1);
relationships.addParentAndChild(parent, child2);

new Research(relationships); // Will print John has a child named Chris and John has a child named Matt
zJaaal commented 2 months ago

With this last iteration I made it to the 2hrs of learning. So I will close this card.

I will continue with the 3rd lecture and so in the next sprints.

I have a public repository that I use for this kind of learning and experimentation.