Чистая архитектура делает ваши приложения простыми в обслуживании и расширяемыми. Но наши коды имеют тенденцию быть предвзятыми из-за различных стилей кодирования, управляемых фреймворком. В этой статье я хочу показать, как преобразовать ваш код, основанный на реакции, в чистую архитектуру.
Чистая архитектура определяет несколько слоев, расположенных вертикально друг над другом, каждый из которых представляет разные области программного обеспечения. Верхние уровни представляют базовые политики приложения, а нижние уровни представляют механизмы.
Главное правило, благодаря которому эта архитектура работает, – правило зависимостей. Это правило гласит, что зависимости исходного кода могут указывать только вверх. Благодаря слоям и правилу зависимости вы можете разрабатывать приложения с очень низкой степенью связанности и независимыми от деталей технической реализации, таких как базы данных и фреймворки.
Я использую слои, определенные в этой статье, со следующими определениями:
Слой 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 в чистую архитектуру. Как только вы познакомитесь с чистой архитектурой, вы сможете с самого начала разрабатывать свой код, совместимый с чистой архитектурой. Но даже в этом случае я надеюсь, что процесс проектирования, описанный в этой статье, даст вам хорошее руководство по рефакторингу.