CacheLane Logo

Chapter 04

Refactoring the code

Note

The code for the entire chapter can be found at the src/chapter_04.1 directory

In this section, we will explore how to improve the organization and maintenance of our library before introducing more features. Currently, all the code is in one file i.e index.js. index.js also serves as the entry point of our project. We will show how to move the code to multiple files without changing how it works.

The Need for Refactoring

The code has become too large and difficult to manage. This chapter covers the benefits of breaking it into smaller files.

Splitting the code into separate files creates a more organized and manageable codebase. Each part should have a clear responsibility, making it easier to work with and understand. This simplification lays the foundation for future improvements, ensures that the project remains consistent and easy to work with, and allows for the introduction of new features.

Some of the key benefits of organizing/splitting your code into smaller re-usable pieces:

  1. Modularity: Breaking the code into smaller files helps us manage each component better. This way, the codebase is easier to understand and work with.
  2. Readability: Smaller files are easier to read and understand. Even people who haven't written any code in your library can quickly understand each file's purpose and contents.
  3. Maintainability: When the codebase is organized into separate files by functionality, it becomes easier to maintain and update. Changes are limited to specific modules, reducing the risk of unintended consequences.
  4. Testing: Individual components can be tested separately when code is in separate files. This leads to more thorough testing and fewer interdependent tests. (We'll cover testing in a later chapter of this book.)

Creating Separate Files

Let's work together to split the code in index.js into separate files for each class and utility. First, create a new directory called lib. Inside the lib directory, create two folders named utils and config. Add a file logtar.js inside the root of the lib directory.

Inside the utils directory, create two files named rolling-options.js and log-level.js. Inside the config directory, create two files named rolling-config.js and log-config.js.

Finally, create a file named logger.js at the root of the directory, where the index.js and package.json files are located.

Your directory structure should now look something like this:

lib/
├── logtar.js
├── logger.js
├── utils/
│     ├── rolling-options.js
│     ├── log-level.js
├── config/
│     ├── rolling-config.js
│     ├── log-config.js
index.js (entry point)
package.json

Explanation

The logtar.js file serves as the key file that exports all the necessary functionality to the client.

The logger.js file exports our Logger class and some related functionality.

The utils/rolling-options.js file exports our RollingSizeOptions and RollingTimeOptions classes.

The index.js file only contains a single line of code:

module.exports = require("./lib/logtar");

The other files export functionality based on their names.

Note

Note: If you are not working with TypeScript and are using vanilla JavaScript, get into the habit of using JSDoc as much as possible. Use it for every function's argument and return type. Be explicit. This may take a bit of time, but it will be convenient in the long run. Using JSDoc will make your workflow much smoother as your project grows.

The index.js file

Here are the contents inside the index.js file

module.exports = require("./lib/logtar");

The lib/logtar.js file

module.exports = {
  Logger: require("./logger").Logger,
  LogConfig: require("./config/log-config").LogConfig,
  RollingConfig: require("./config/rolling-config").RollingConfig,
  LogLevel: require("./utils/log-level").LogLevel,
  RollingTimeOptions: require("./utils/rolling-options").RollingTimeOptions,
  RollingSizeOptions: require("./utils/rolling-options").RollingSizeOptions,
};

The lib/logger.js file

const { LogConfig } = require("./config/log-config");
const { LogLevel } = require("./utils/log-level");

class Logger {
  /**
   * @type {LogConfig}
   */
  #config;

  /**
   * @returns {Logger} A new instance of Logger with default config.
   */
  static with_defaults() {
    return new Logger();
  }

  /**
   *
   * @param {LogConfig} log_config
   * @returns {Logger} A new instance of Logger with the given config.
   */
  static with_config(log_config) {
    return new Logger(log_config);
  }

  /**
   * @param {LogConfig} log_config
   */
  constructor(log_config) {
    log_config = log_config || LogConfig.with_defaults();
    LogConfig.assert(log_config);
    this.#config = log_config;
  }

  /**
   * @returns {LogLevel} The current log level.
   */
  get level() {
    return this.#config.level;
  }
}

module.exports = { Logger };

The lib/config/log-config.js file

const fs = require("node:fs");

const { LogLevel } = require("../utils/log-level");
const { RollingConfig } = require("./rolling-config");

class LogConfig {
  /**
   * @type {LogLevel}
   * @private
   * @description The log level to be used.
   */
  #level = LogLevel.Info;

  /**
   * @type RollingConfig
   * @private
   */
  #rolling_config;

  /**
   * @type {string}
   * @private
   * @description The prefix to be used for the log file name.
   *
   * If the file prefix is `MyFilePrefix_` the log files created will have the name
   * `MyFilePrefix_2021-09-01.log`, `MyFilePrefix_2021-09-02.log` and so on.
   */
  #file_prefix = "Logtar_";

  constructor() {
    this.#rolling_config = RollingConfig.with_defaults();
  }

  /**
   * @returns {LogConfig} A new instance of LogConfig with default values.
   */
  static with_defaults() {
    return new LogConfig();
  }

  /**
   * @param {string} file_path The path to the config file.
   * @returns {LogConfig} A new instance of LogConfig with values from the config file.
   * @throws {Error} If the file_path is not a string.
   */
  static from_file(file_path) {
    const file_contents = fs.readFileSync(file_path);
    return LogConfig.from_json(JSON.parse(file_contents));
  }

  /**
   * @param {Object} json The json object to be parsed into {LogConfig}.
   * @returns {LogConfig} A new instance of LogConfig with values from the json object.
   */
  static from_json(json) {
    let log_config = new LogConfig();
    Object.keys(json).forEach((key) => {
      switch (key) {
        case "level":
          log_config = log_config.with_log_level(json[key]);
          break;
        case "rolling_config":
          log_config = log_config.with_rolling_config(json[key]);
          break;
        case "file_prefix":
          log_config = log_config.with_file_prefix(json[key]);
          break;
      }
    });
    return log_config;
  }

  /**
   * @param {LogConfig} log_config The log config to be validated.
   * @throws {Error} If the log_config is not an instance of LogConfig.
   */
  static assert(log_config) {
    if (arguments.length > 0 && !(log_config instanceof LogConfig)) {
      throw new Error(`log_config must be an instance of LogConfig. Unsupported param ${JSON.stringify(log_config)}`);
    }
  }

  /**
   * @returns {LogLevel} The current log level.
   */
  get level() {
    return this.#level;
  }

  /**
   * @param {LogLevel} log_level The log level to be set.
   * @returns {LogConfig} The current instance of LogConfig.
   * @throws {Error} If the log_level is not an instance of LogLevel.
   */
  with_log_level(log_level) {
    LogLevel.assert(log_level);
    this.#level = log_level;
    return this;
  }

  /**
   * @returns {RollingConfig} The current rolling config.
   */
  get rolling_config() {
    return this.#rolling_config;
  }

  /**
   * @param {RollingConfig} config The rolling config to be set.
   * @returns {LogConfig} The current instance of LogConfig.
   * @throws {Error} If the config is not an instance of RollingConfig.
   */
  with_rolling_config(config) {
    this.#rolling_config = RollingConfig.from_json(config);
    return this;
  }

  /**
   * @returns {String} The current max file size.
   */
  get file_prefix() {
    return this.#file_prefix;
  }

  /**
   * @param {string} file_prefix The file prefix to be set.
   * @returns {LogConfig} The current instance of LogConfig.
   * @throws {Error} If the file_prefix is not a string.
   */
  with_file_prefix(file_prefix) {
    if (typeof file_prefix !== "string") {
      throw new Error(`file_prefix must be a string. Unsupported param ${JSON.stringify(file_prefix)}`);
    }

    this.#file_prefix = file_prefix;
    return this;
  }
}

module.exports = { LogConfig };

The lib/config/rolling-config.js file

const { RollingTimeOptions, RollingSizeOptions } = require("../utils/rolling-options");

class RollingConfig {
  /**
   * Roll/Create new file every time the current file size exceeds this threshold in `seconds`.
   *
   * @type {RollingTimeOptions}
   * @private
   *
   */
  #time_threshold = RollingTimeOptions.Hourly;

  /**
   * @type {RollingSizeOptions}
   * @private
   */
  #size_threshold = RollingSizeOptions.FiveMB;

  /**
   * @returns {RollingConfig} A new instance of RollingConfig with default values.
   */
  static with_defaults() {
    return new RollingConfig();
  }

  /**
   * @param {number} size_threshold Roll/Create new file every time the current file size exceeds this threshold.
   * @returns {RollingConfig} The current instance of RollingConfig.
   */
  with_size_threshold(size_threshold) {
    RollingSizeOptions.assert(size_threshold);
    this.#size_threshold = size_threshold;
    return this;
  }

  /**
   * @param {time_threshold} time_threshold Roll/Create new file every time the current file size exceeds this threshold.
   * @returns {RollingConfig} The current instance of RollingConfig.
   * @throws {Error} If the time_threshold is not an instance of RollingTimeOptions.
   */
  with_time_threshold(time_threshold) {
    RollingTimeOptions.assert(time_threshold);
    this.#time_threshold = time_threshold;
    return this;
  }

  /**
   * @param {Object} json The json object to be parsed into {RollingConfig}.
   * @returns {RollingConfig} A new instance of RollingConfig with values from the json object.
   * @throws {Error} If the json is not an object.
   */
  static from_json(json) {
    let rolling_config = new RollingConfig();

    Object.keys(json).forEach((key) => {
      switch (key) {
        case "size_threshold":
          rolling_config = rolling_config.with_size_threshold(json[key]);
          break;
        case "time_threshold":
          rolling_config = rolling_config.with_time_threshold(json[key]);
          break;
      }
    });

    return rolling_config;
  }
}

module.exports = { RollingConfig };

The lib/utils/log-level.js file

class LogLevel {
  static #Debug = 0;
  static #Info = 1;
  static #Warn = 2;
  static #Error = 3;
  static #Critical = 4;

  static get Debug() {
    return this.#Debug;
  }

  static get Info() {
    return this.#Info;
  }

  static get Warn() {
    return this.#Warn;
  }

  static get Error() {
    return this.#Error;
  }

  static get Critical() {
    return this.#Critical;
  }

  static assert(log_level) {
    if (![this.Debug, this.Info, this.Warn, this.Error, this.Critical].includes(log_level)) {
      throw new Error(`log_level must be an instance of LogLevel. Unsupported param ${JSON.stringify(log_level)}`);
    }
  }
}

module.exports = { LogLevel };

The lib/utils/rolling-options.js class

class RollingSizeOptions {
  static OneKB = 1024;
  static FiveKB = 5 * 1024;
  static TenKB = 10 * 1024;
  static TwentyKB = 20 * 1024;
  static FiftyKB = 50 * 1024;
  static HundredKB = 100 * 1024;

  static HalfMB = 512 * 1024;
  static OneMB = 1024 * 1024;
  static FiveMB = 5 * 1024 * 1024;
  static TenMB = 10 * 1024 * 1024;
  static TwentyMB = 20 * 1024 * 1024;
  static FiftyMB = 50 * 1024 * 1024;
  static HundredMB = 100 * 1024 * 1024;

  static assert(size_threshold) {
    if (typeof size_threshold !== "number" || size_threshold < RollingSizeOptions.OneKB) {
      throw new Error(`size_threshold must be at-least 1 KB. Unsupported param ${JSON.stringify(size_threshold)}`);
    }
  }
}

class RollingTimeOptions {
  static Minutely = 60; // Every 60 seconds
  static Hourly = 60 * this.Minutely;
  static Daily = 24 * this.Hourly;
  static Weekly = 7 * this.Daily;
  static Monthly = 30 * this.Daily;
  static Yearly = 12 * this.Monthly;

  static assert(time_option) {
    if (![this.Minutely, this.Hourly, this.Daily, this.Weekly, this.Monthly, this.Yearly].includes(time_option)) {
      throw new Error(
        `time_option must be an instance of RollingConfig. Unsupported param ${JSON.stringify(time_option)}`
      );
    }
  }
}

module.exports = {
  RollingSizeOptions,
  RollingTimeOptions,
};

See how we can still benefit from the strong jsdoc type completion for our classes, even if they exist in different files? This isn't something achievable with regular JavaScript – all credit goes to jsdoc.

Note

The code for the entire chapter can be found at the src/chapter_04.1 directory

Previous
logtar our own logging library