L’architecture propre rend vos applications faciles à entretenir et extensibles. Mais nos codes ont tendance à être biaisés par divers styles de codage basés sur le framework. Dans cet article, je veux montrer comment convertir (transformer) votre code basé sur la réaction en une architecture propre.
Architecture épurée définit plusieurs couches empilées verticalement, chacune représentant différentes zones du logiciel. Les couches supérieures représentent les politiques de base de l’application tandis que les couches inférieures représentent les mécanismes.
La règle primordiale qui fait fonctionner cette architecture est la règle de dépendance. Cette règle dit que dépendances du code source ne peut pointer que vers le haut. Avec les couches et la règle de dépendance, vous pouvez concevoir des applications avec un couplage très faible et indépendantes des détails techniques d’implémentation, tels que les bases de données et les frameworks.
J’emploie des calques définis dans Cet article avec les définitions suivantes :
Le Domaine couche décrit ce que fait votre projet ou votre application. Les codes de la couche Domaine doivent être indépendants des plateformes et des frameworks.
- Des modèles représentent des objets du monde réel liés au problème.
- Dépôt fournit une interface pour accéder aux modèles.
- Cas d’utilisation inclure toute la logique métier de votre application.
Le Présentation couche décrit COMMENT votre application interagit avec le monde extérieur.
Données couche décrit comment votre application gère les données.
Principale couche (la couche la plus basse) fournit le code d’amorçage qui est chargé de tisser tous les composants logiciels des autres couches en une seule application.
Mais dans les applications réelles, le flux de contrôle n’est pas toujours ascendant. Par exemple, la logique métier dans le UseCase
La couche utilise une interface dans la couche de référentiel, et le référentiel (couche supérieure) doit accéder aux données gérées dans la couche de données (couche inférieure). Voir la figure ci-dessous.
Pour résoudre cette violation de la règle de dépendance, nous utilisons généralement le principe d’inversion de dépendance. Nous organisons la relation entre l’interface (par exemple RepositoryX) et son implémentation (par exemple RepositoryImpl
) afin que la dépendance du code source soit dirigée vers le haut. Avec cette technique, les couches supérieures peuvent appeler des implémentations définies dans les couches inférieures.
Lorsque vous démarrez votre code d’application à partir du modèle de l’application de réaction (par exemple, squelette créé par créer-réagir-app), tous les codes sont d’abord inclus dans la couche Présentation. En effet, React (et tous les frameworks d’interface utilisateur) se concentre sur la manière de présenter les données aux utilisateurs. Dans cette section, nous transformerons le code d’application basé sur la réaction et le rendrons conforme à l’architecture propre.
Voici le code original de TicTacToe utilisé dans le tutoriel officiel de réaction.
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;
}
Et voici la capture d’écran de TicTacToe.
Commençons par extraire les modèles de données dans la couche Model. Par définition, les modèles définis ici doivent être indépendants de la plate-forme et du framework, et ils doivent uniquement se concentrer sur les règles métier.
Même si le code d’origine n’a pas de définitions de type claires, vous devez absolument définir ces types pour utiliser le mécanisme d’inférence de type de TypeScript et le laisser vous aider lors du développement.
export type Square = null | "X" | "O";export type Board = Square[];
type HistoryStep = {
board: Board;
};
export type History = HistoryStep[];
L’étape suivante consiste à extraire les cas d’utilisation. Un cas d’utilisation peut être formalisé comme « quand X se produit, faites Y ».
Dans les applications React, les cas d’utilisation sont généralement implémentés sous la forme (1) d’une fonction de rendu appelée par le framework React, (2) de gestionnaires d’événements pour la saisie de l’utilisateur ou (3) d’effets autonomes. Dans le TicTacToe
Par exemple, nous avons trois cas d’utilisation.
render()
: Lorsqu’une donnée est mise à jour, cette fonction est appelée.handleClick(i)
: Lorsque vous appuyez sur une case du tableau, cette fonction est appelée.jumpTo(step)
: Lorsque vous appuyez sur un bouton « Aller au déplacement #x », cette fonction est appelée.
Mais vous remarquez les fonctions de cas d’utilisation d’origine (render()
, handleClick()
, jumpTo()
) incluent des codes de plusieurs couches (UseCase, Repository, Data, Presentation (react)). Nous devons en quelque sorte démêler ces spaghettis et répartir les codes dans les couches appropriées.
Je commence généralement ce démêlage en analysant les dépendances entre les variables. Ensuite, je trouve les sources de données primaires qui ne peuvent être déduites d’aucune autre variable. Dans le TicTacToe
exemple, vous pouvez facilement détecter deux sources de données history
et stepNumber
comme le montre la figure ci-dessous. Ces données primaires doivent être stockées dans un stockage de données persistant, et nous les plaçons dans la couche de données.
Concevoir la limite entre le UseCase
couche et Repository
couche est subjective, et en partie à vous. La couche Repository est définie comme l’emplacement central pour conserver toutes les opérations spécifiques au modèle. En plus de cela, j’ai ma politique pour définir les opérations dans la couche Repository comme suit :
- Les opérations de référentiel doivent être minimales. Exposer toutes les fonctions setter/getter brutes pour les sources de données primaires n’est pas une bonne idée car cela pourrait facilement conduire à des données invalides/incohérentes.
- Les opérations du référentiel doivent être neutres et indépendantes de la logique métier définie dans
UseCase
couche. - Chaque opération de référentiel doit accéder à plusieurs sources de données uniquement lorsque l’opération doit les modifier à la fois (comme une opération atomique) pour maintenir la cohérence entre les sources de données.
Sur la base de cette politique, je dissocie UseCase
couche et Repository
couche comme suit :
Définissons le Repository
interface. La mise en œuvre vient plus tard.
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>;
}
Vous pouvez définir UseCase
fonctions alors. Vous pouvez maintenant comprendre plus clairement la logique métier.
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);
}
Dans la couche de présentation, le conseil le plus important est de former MVC (Modèle-Vue-Contrôleur). Dans les applications React, nous fusionnons généralement « modèle » et « contrôleur » dans un seul objet qui agit comme un pont entre la couche Présentation et la couche UseCase.
Voir TicTacToeModelController
dans la figure ci-dessous. Les composants React fonctionnent comme une « vue » dans MVC et font référence à « modèle-contrôleur » avec des crochets personnalisés. De cette manière, nous pouvons découpler les codes de rendu pur (« vue ») de tous les codes de traitement de données (« modèle » et « contrôleur »).
Voici le code pour 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,
};
}
Et voici 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>
);
}
La couche de données comporte deux sous-couches. Data:Repository
couche est la couche où vous implémentez le comportement défini dans Domain:Repository
couche. Data:DataSource
couche implémente le stockage de données réel tel que le stockage en mémoire ou le stockage réseau.
Comme vous pouvez le voir sur la figure ci-dessous, nous appliquons le principe d’inversion de dépendance entre Domain:Repository
couche (couche supérieure) et Data:Repository
couche (couche inférieure). Alors que le flux de contrôle est descendant (par exemple, le domaine utilise des données), la dépendance du code source est ascendante.
Voici 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);
}
}
Enfin, nous intégrons tous les composants de plusieurs couches dans une seule application dans le code d’amorçage de la couche principale.
Dans ce code d’amorçage, nous créons une implémentation de référentiel et la transmettons à TicTacToeView
. Ensuite, le référentiel est passé à UseCase
couche à travers TicTacToeModelController
.
// Dependency injection
const dataSource = new OnMemoryDataSourceImpl();
const repository = new RepositoryImpl(dataSource);export function App() {
return <TicTacToeView repository={repository} />;
}
Il s’agit d’une technique appelée Injection de dépendance (DI). Comme vous pouvez le voir sur le schéma ci-dessous, UseCase
utilisations des calques (selon) Repository
couche, mais les codes dans UseCase
le calque ne doit pas créer d’objets réels dans Repository
couche qui dépendent de la couche inférieure (couche de données).
En découplant la création d’objet (en Main
couche) et l’utilisation des objets (dans UseCase
couche), nous pouvons éviter de violer la règle de dépendance (toutes les flèches de référence doivent être vers le haut : couche inférieure vers couche supérieure).
C’est ça! Vous pouvez voir le code source final ici.
J’ai montré comment transformer vos codes d’application réactifs en une architecture propre. Une fois familiarisé avec l’architecture propre, vous pourrez peut-être concevoir votre code conformément à l’architecture propre dès le début. Mais même dans ce cas, j’espère que le processus de conception décrit dans cet article vous donnera de bons conseils pour votre refactorisation.