Чистая архитектура делает ваши приложения простыми в обслуживании и расширяемыми. Но наши коды имеют тенденцию быть предвзятыми из-за различных стилей кодирования, управляемых фреймворком. В этой статье я хочу показать, как преобразовать ваш код, основанный на реакции, в чистую архитектуру.

Чистая архитектура определяет несколько слоев, расположенных вертикально друг над другом, каждый из которых представляет разные области программного обеспечения. Верхние уровни представляют базовые политики приложения, а нижние уровни представляют механизмы.

Главное правило, благодаря которому эта архитектура работает, – правило зависимостей. Это правило гласит, что зависимости исходного кода могут указывать только вверх. Благодаря слоям и правилу зависимости вы можете разрабатывать приложения с очень низкой степенью связанности и независимыми от деталей технической реализации, таких как базы данных и фреймворки.

Я использую слои, определенные в этой статье, со следующими определениями:

Слой Domain описывает, что делает ваш проект или приложение. Коды на уровне домена не должны зависеть от платформ и фреймворков.

  • Модели представляют собой объекты реального мира, связанные с проблемой.
  • Репозиторий предоставляет интерфейс для доступа к моделям.
  • Случаи использования включают всю бизнес-логику вашего приложения.

Уровень Представление описывает, КАК ваше приложение взаимодействует с внешним миром.

Уровень Данные описывает, как ваше приложение управляет данными.

Основной уровень (самый нижний уровень) предоставляет загрузочный код, который отвечает за объединение всех программных компонентов на других уровнях в одно приложение.

Но в реальных приложениях поток управления не всегда направлен вверх. Например, бизнес-логика на уровне UseCase использует интерфейс на уровне репозитория, а репозиторию (верхний уровень) требуется доступ к данным, управляемым на уровне данных (нижний уровень). См. рисунок ниже.

Чтобы устранить это нарушение правила зависимости, мы обычно используем принцип инверсии зависимости. Мы организуем связь между интерфейсом (например, RepositoryX) и его реализацией (например, RepositoryImpl) так, чтобы зависимость исходного кода была направлена ​​вверх. С помощью этого метода верхние уровни могут вызывать реализации, определенные на нижних уровнях.

Превратите приложение React в чистую архитектуру

Когда вы запускаете код своего приложения из шаблона реагирующего приложения (например, скелета, созданного create-react-app), все коды сначала включаются в уровень представления. Это связано с тем, что реакция (и все UI-фреймворки) сосредоточена на том, как представлять данные пользователям. В этом разделе мы преобразуем код приложения, основанного на реакции, и сделаем его совместимым с чистой архитектурой.

Вот исходный код TicTacToe, использованный в официальном руководстве по реакции.

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

class Board extends React.Component {
  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null)
        }
      ],
      stepNumber: 0,
      xIsNext: true
    };
  }

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([
        {
          squares: squares
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0
    });
  }

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = "Winner: " + winner;
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={i => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

// ========================================

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Game />);

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

А вот скриншот TicTacToe.

Слой модели проекта

Во-первых, давайте извлечем модели данных в слое Model. По определению, определенные здесь модели не должны зависеть ни от платформы, ни от фреймворка, и они должны быть сосредоточены исключительно на бизнес-правилах.

Даже если в исходном коде нет четких определений типов, вы обязательно должны определить эти типы, чтобы использовать механизм вывода типов TypeScript и позволить ему поддерживать вас во время разработки.

export type Square = null | "X" | "O";

export type Board = Square[];

type HistoryStep = {
  board: Board;
};

export type History = HistoryStep[];

Разработка слоев UseCase и Repository

Следующим шагом является извлечение вариантов использования. Вариант использования можно формализовать как «когда происходит Х, делай Y».

В приложениях React варианты использования обычно реализуются как (1) функция рендеринга, которая вызывается платформой React, (2) обработчики событий для пользовательского ввода или (3) автономные эффекты. В примере TicTacToe у нас есть три варианта использования.

  • render(): Эта функция вызывается при обновлении любых данных.
  • handleClick(i): При нажатии квадрата на доске вызывается эта функция.
  • jumpTo(step): Когда вы нажимаете кнопку «Перейти к перемещению #x», вызывается эта функция.

Но вы заметили, что исходные функции вариантов использования (render(), handleClick(), jumpTo()) включают коды из нескольких уровней (UseCase, Repository, Data, Presentation (реагирование)). Нам как-то нужно распутать эти спагетти и распределить коды по соответствующим слоям.

Обычно я начинаю это распутывание с анализа зависимостей между переменными. Затем я нахожу первичные источники данных, которые нельзя вывести ни из каких других переменных. В примере TicTacToe вы можете легко обнаружить два источника данных history и stepNumber, как показано на рисунке ниже. Эти первичные данные должны храниться в постоянном хранилище данных, и мы помещаем их на уровень данных.

Определение границы между слоями UseCase и Repository является субъективным и частично зависит от вас. Уровень репозитория определяется как центральное место для хранения всех операций, связанных с моделью. Кроме того, у меня есть политика для определения операций на уровне репозитория следующим образом:

  • Операции с хранилищем должны быть минимальными. Предоставление всех необработанных функций установки/получения для первичных источников данных не является хорошей идеей, поскольку это может легко привести к недопустимым/несогласованным данным.
  • Операции с репозиторием должны быть нейтральными и независимыми от бизнес-логики, определенной на уровне UseCase.
  • Каждая операция репозитория должна обращаться к нескольким источникам данных только тогда, когда операция должна изменить их сразу (как атомарная операция), чтобы обеспечить согласованность между источниками данных.

На основе этой политики я разделяю слой UseCase и слой Repository следующим образом:

Давайте определим интерфейс Repository. Реализация приходит позже.

export type Step = {
  board: Board;
  stepNumber: number;
  numOfAllSteps: number;
};

/**
 * Repository managing the history of TicTacToe steps.
 * Each step consists of a board.
 */
export interface Repository {
  getCurrentStep(): Promise<Step>;
  setCurrentStepNumber(stepNumber: number): Promise<void>;
  deleteStepsAfterCurrentStepNumber(): Promise<void>;
  addStep(board: Board): Promise<void>;
}

Затем вы можете определить UseCase функций. Теперь вы можете более четко понять бизнес-логику.

export async function clickOnBoard(
  indexOnBoard: number,
  repository: Repository
) {
  const { board, stepNumber } = await repository.getCurrentStep();
  const newBoard = board.slice();
  if (calculateWinnerOnBoard(newBoard) || newBoard[indexOnBoard]) {
    return;
  }
  newBoard[indexOnBoard] = isNextTurnX(stepNumber) ? "X" : "O";
  await repository.deleteStepsAfterCurrentStepNumber();
  await repository.addStep(newBoard);
  await repository.setCurrentStepNumber(stepNumber + 1);
}

export async function jumpToStep(
  stepNumber: number,
  repository: Repository
): Promise<void> {
  return repository.setCurrentStepNumber(stepNumber);
}

Слой представления дизайна

В слое Presentation наиболее важным советом является формирование MVC (Model-View-Controller). В реагирующих приложениях мы обычно объединяем модель и контроллер в один объект, который действует как мост между уровнем представления и уровнем варианта использования.

См. TicTacToeModelController на рисунке ниже. Компоненты React работают как «представление» в MVC и ссылаются на «модель-контроллер» с помощью настраиваемых хуков. Таким образом, мы можем отделить чистые коды рендеринга («представление») от любых кодов обработки данных («модель» и «контроллер»).

Вот код для TicTacToeModelController.

export function useTicTacToeModelController(repository: Repository) {
  const [currentStep, setCurrentStep] = useState<Step | null>(null);

  useEffect(() => {
    async function init() {
      const initialStep = await repository.getCurrentStep();
      setCurrentStep(initialStep);
    }
    init();
  }, []);

  const handleClickOnBoard = async (indexOnBoard: number) => {
    await clickOnBoard(indexOnBoard, repository);
    const newStep = await repository.getCurrentStep();
    setCurrentStep(newStep);
  };

  const handleJumpToStep = async (stepNumber: number) => {
    await jumpToStep(stepNumber, repository);
    const newStep = await repository.getCurrentStep();
    setCurrentStep(newStep);
  };

  return {
    currentStep,
    handleClickOnBoard,
    handleJumpToStep,
  };
}

А вот и TicTacToeView.

type TicTacToeViewProps = {
  repository: Repository;
};

export function TicTacToeView({ repository }: TicTacToeViewProps) {
  const { currentStep, handleClickOnBoard, handleJumpToStep } =
    useTicTacToeModelController(repository);

  if (!currentStep) {
    return null;
  }

  const winner = calculateWinnerOnBoard(currentStep.board);
  const xIsNext = isNextTurnX(currentStep.stepNumber);
  return (
    <div className="game">
      <div className="game-board">
        <BoardView board={currentStep.board} onClick={handleClickOnBoard} />
      </div>
      <div className="game-info">
        <StatusView winner={winner} xIsNext={xIsNext} />
        <JumpToStepButtons
          numOfAllSteps={currentStep.numOfAllSteps}
          onClick={handleJumpToStep}
        />
      </div>
    </div>
  );
}

Слой проектных данных

Уровень данных имеет два подуровня. Уровень Data:Repository — это уровень, на котором вы реализуете поведение, определенное на уровне Domain:Repository. Уровень Data:DataSource реализует реальное хранилище данных, такое как хранилище в оперативной памяти или сетевое хранилище.

Как вы можете видеть на рисунке ниже, мы применяем принцип инверсии зависимостей между слоем Domain:Repository (верхний слой) и слоем Data:Repository (нижний слой). В то время как поток управления направлен вниз (например, домен использует данные), зависимость исходного кода направлена ​​вверх.

Вот RepositoryImpl:

export class RepositoryImpl implements Repository {
  dataSource: DataSource;

  constructor(dataSource: DataSource) {
    this.dataSource = dataSource;
  }

  async getCurrentStep(): Promise<Step> {
    const [history, stepNumber] = await Promise.all([
      this.dataSource.getHistory(),
      this.dataSource.getStepNumber(),
    ]);
    const board = history[stepNumber].board;
    const numOfAllSteps = history.length;

    return { board, stepNumber, numOfAllSteps };
  }

  async setCurrentStepNumber(stepNumber: number): Promise<void> {
    const history = await this.dataSource.getHistory();
    if (stepNumber < history.length) {
      await this.dataSource.setStepNumber(stepNumber);
    } else {
      throw Error(
        `Step number ${stepNumber} should be smaller than the history size (${history.length})`
      );
    }
  }

  async deleteStepsAfterCurrentStepNumber(): Promise<void> {
    const [history, stepNumber] = await Promise.all([
      this.dataSource.getHistory(),
      this.dataSource.getStepNumber(),
    ]);
    const trimmedHistory = history.slice(0, stepNumber + 1);
    await this.dataSource.setHistory(trimmedHistory);
  }

  async addStep(board: Board): Promise<void> {
    const history = await this.dataSource.getHistory();
    history.push({ board });
    await this.dataSource.setHistory(history);
  }
}

Дизайн Основной слой

Наконец, мы сплетаем все компоненты в несколько слоев в одно приложение в загрузочном коде в основном слое.

В этом коде начальной загрузки мы создаем реализацию репозитория и передаем ее в TicTacToeView. Затем репозиторий передается на слой UseCase через TicTacToeModelController.

// Dependency injection
const dataSource = new OnMemoryDataSourceImpl();
const repository = new RepositoryImpl(dataSource);

export function App() {
  return <TicTacToeView repository={repository} />;
}

Это техника под названием Внедрение зависимостей (DI). Как вы можете видеть на диаграмме ниже, слой UseCase использует (зависит от) слой Repository, но коды в слое UseCase не должны создавать фактические объекты в слое Repository, которые зависят от нижнего уровня (уровня данных).

Разделяя создание объекта (на уровне Main) и использование объекта (на уровне UseCase), мы можем избежать нарушения правила зависимости (все стрелки ссылок должны быть направлены вверх: от нижнего уровня к верхнему).

Вот и все! Вы можете увидеть окончательный исходный код здесь.

Заключение

Я показал, как преобразовать код вашего приложения React в чистую архитектуру. Как только вы познакомитесь с чистой архитектурой, вы сможете с самого начала разрабатывать свой код, совместимый с чистой архитектурой. Но даже в этом случае я надеюсь, что процесс проектирования, описанный в этой статье, даст вам хорошее руководство по рефакторингу.