Les principes SOLID, introduits par Robert C. Martin (Oncle Bob), constituent la base d'une bonne conception logicielle. Ces principes guident les développeurs dans la création de systèmes maintenables, évolutifs et faciles à comprendre. Dans ce blog, nous approfondirons chacun des principes SOLID et explorerons comment ils peuvent être appliqués dans le contexte de React Native et de la pile MERN (MongoDB, Express.js, React, Node.js).
Définition : Une classe ne devrait avoir qu'une seule raison de changer, ce qui signifie qu'elle ne devrait avoir qu'un seul travail ou responsabilité.
Explication :
Le principe de responsabilité unique (SRP) garantit qu'une classe ou un module se concentre sur un aspect de la fonctionnalité du logiciel. Lorsqu'une classe a plus d'une responsabilité, les modifications liées à une responsabilité pourraient involontairement avoir un impact sur l'autre, entraînant des bugs et un coût de maintenance plus élevé.
Exemple React Native :
Considérez un composant UserProfile dans une application React Native. Initialement, ce composant peut être responsable à la fois du rendu de l'interface utilisateur et de la gestion des requêtes API pour mettre à jour les données utilisateur. Cela viole le SRP car le composant fait deux choses différentes : gérer l'interface utilisateur et la logique métier.
Violation :
const UserProfile = ({ userId }) => { const [userData, setUserData] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(response => response.json()) .then(data => setUserData(data)); }, [userId]); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
Dans cet exemple, le composant UserProfile est responsable à la fois de la récupération des données et du rendu de l'interface utilisateur. Si vous devez modifier la façon dont les données sont récupérées (par exemple, en utilisant un point de terminaison d'API différent ou en introduisant la mise en cache), vous devrez modifier le composant, ce qui pourrait entraîner des effets secondaires involontaires dans l'interface utilisateur.
Refactor :
Pour adhérer au SRP, séparez la logique de récupération de données de la logique de rendu de l'interface utilisateur.
// Custom hook for fetching user data const useUserData = (userId) => { const [userData, setUserData] = useState(null); useEffect(() => { const fetchUserData = async () => { const response = await fetch(`/api/users/${userId}`); const data = await response.json(); setUserData(data); }; fetchUserData(); }, [userId]); return userData; }; // UserProfile component focuses only on rendering the UI const UserProfile = ({ userId }) => { const userData = useUserData(userId); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
Dans ce refactor, le hook useUserData gère la récupération des données, permettant au composant UserProfile de se concentrer uniquement sur le rendu de l'interface utilisateur. Désormais, si vous devez modifier la logique de récupération des données, vous pouvez le faire sans affecter le code de l'interface utilisateur.
Exemple Node.js :
Imaginez un UserController qui gère à la fois les requêtes de base de données et la logique métier dans une application Node.js. Cela viole le SRP car le contrôleur a de multiples responsabilités.
Violation :
class UserController { async getUserProfile(req, res) { const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]); res.json(user); } async updateUserProfile(req, res) { const result = await db.query('UPDATE users SET name = ? WHERE id = ?', [req.body.name, req.params.id]); res.json(result); } }
Ici, le UserController est responsable à la fois de l'interaction avec la base de données et du traitement des requêtes HTTP. Tout changement dans la logique d'interaction de la base de données nécessiterait des modifications dans le contrôleur, augmentant ainsi le risque de bugs.
Refactor :
Séparez la logique d'interaction de la base de données dans une classe de référentiel, permettant au contrôleur de se concentrer sur la gestion des requêtes HTTP.
// UserRepository class handles data access logic class UserRepository { async getUserById(id) { return db.query('SELECT * FROM users WHERE id = ?', [id]); } async updateUser(id, name) { return db.query('UPDATE users SET name = ? WHERE id = ?', [name, id]); } } // UserController focuses on business logic and HTTP request handling class UserController { constructor(userRepository) { this.userRepository = userRepository; } async getUserProfile(req, res) { const user = await this.userRepository.getUserById(req.params.id); res.json(user); } async updateUserProfile(req, res) { const result = await this.userRepository.updateUser(req.params.id, req.body.name); res.json(result); } }
Désormais, le UserController se concentre sur la gestion des requêtes HTTP et de la logique métier, tandis que le UserRepository est responsable des interactions avec la base de données. Cette séparation rend le code plus facile à maintenir et à étendre.
Définition : Les entités logicielles doivent être ouvertes pour extension mais fermées pour modification.
Explication :
Le principe ouvert/fermé (OCP) souligne que les classes, modules et fonctions doivent être facilement extensibles sans modifier leur code existant. Cela favorise l'utilisation d'abstractions et d'interfaces, permettant aux développeurs d'introduire de nouvelles fonctionnalités avec un risque minimal d'introduire des bogues dans le code existant.
Exemple React Native :
Imaginez un composant de bouton qui a initialement un style fixe. Au fur et à mesure que l'application se développe, vous devez étendre le bouton avec différents styles pour différents cas d'utilisation. Si vous continuez à modifier le composant existant à chaque fois que vous avez besoin d'un nouveau style, vous finirez par violer OCP.
Violation :
const Button = ({ onPress, type, children }) => { let style = {}; if (type === 'primary') { style = { backgroundColor: 'blue', color: 'white' }; } else if (type === 'secondary') { style = { backgroundColor: 'gray', color: 'black' }; } return ( <TouchableOpacity onPress={onPress} style={style}> <Text>{children}</Text> </TouchableOpacity> ); };
Dans cet exemple, le composant Button doit être modifié à chaque fois qu'un nouveau type de bouton est ajouté. Ceci n'est pas évolutif et augmente le risque de bugs.
Refactor :
Refactorisez le composant Button pour qu'il soit ouvert à l'extension en autorisant la transmission des styles en tant qu'accessoires.
const Button = ({ onPress, style, children }) => { const defaultStyle = { padding: 10, borderRadius: 5, }; return ( <TouchableOpacity onPress={onPress} style={[defaultStyle, style]}> <Text>{children}</Text> </TouchableOpacity> ); }; // Now, you can extend the button's style without modifying the component itself <Button style={{ backgroundColor: 'blue', color: 'white' }} onPress={handlePress}> Primary Button </Button> <Button style={{ backgroundColor: 'gray', color: 'black' }} onPress={handlePress}> Secondary Button </Button>
En refactorisant, le composant Button est fermé pour modification mais ouvert pour extension, permettant d'ajouter de nouveaux styles de boutons sans altérer la logique interne du composant.
Exemple Node.js :
Considérez un système de traitement des paiements dans une application Node.js qui prend en charge plusieurs méthodes de paiement. Au départ, vous pourriez être tenté de gérer chaque mode de paiement au sein d’une seule classe.
Violation :
class PaymentProcessor { processPayment(amount, method) { if (method === 'paypal') { console.log(`Paid ${amount} using PayPal`); } else if (method === 'stripe') { console.log(`Paid ${amount} using Stripe`); } else if (method === 'creditcard') { console.log(`Paid ${amount} using Credit Card`); } } }
Dans cet exemple, l'ajout d'un nouveau mode de paiement nécessite de modifier la classe PaymentProcessor, violant OCP.
Refactor:
Introduce a strategy pattern to encapsulate each payment method in its own class, making the PaymentProcessor open for extension but closed for modification.
class PaymentProcessor { constructor(paymentMethod) { this.paymentMethod = paymentMethod; } processPayment(amount) { return this.paymentMethod.pay(amount); } } // Payment methods encapsulated in their own classes class PayPalPayment { pay(amount) { console.log(`Paid ${amount} using PayPal`); } } class StripePayment { pay(amount) { console.log(`Paid ${amount} using Stripe`); } } class CreditCardPayment { pay(amount) { console.log(`Paid ${amount} using Credit Card`); } } // Usage const paypalProcessor = new PaymentProcessor(new PayPalPayment()); paypalProcessor.processPayment(100); const stripeProcessor = new PaymentProcessor(new StripePayment()); stripeProcessor.processPayment(200);
Now, to add a new payment method, you simply create a new class without modifying the existing PaymentProcessor. This adheres to OCP and makes the system more scalable and maintainable.
Definition: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
Explanation:
The Liskov Substitution Principle (LSP) ensures that subclasses can stand in for their parent classes without causing errors or altering the
expected behavior. This principle helps maintain the integrity of a system's design and ensures that inheritance is used appropriately.
React Native Example:
Imagine a base Shape class with a method draw. You might have several subclasses like Circle and Square, each with its own implementation of the draw method. These subclasses should be able to replace Shape without causing issues.
Correct Implementation:
class Shape { draw() { // Default drawing logic } } class Circle extends Shape { draw() { super.draw(); // Circle-specific drawing logic } } class Square extends Shape { draw() { super.draw(); // Square-specific drawing logic } } function renderShape(shape) { shape.draw(); } // Both Circle and Square can replace Shape without issues const circle = new Circle(); renderShape(circle); const square = new Square(); renderShape(square);
In this example, both Circle and Square classes can replace the Shape class without causing any problems, adhering to LSP.
Node.js Example:
Consider a base Bird class with a method fly. You might have a subclass Sparrow that extends Bird and provides its own implementation of fly. However, if you introduce a subclass like Penguin that cannot fly, it violates LSP.
Violation:
class Bird { fly() { console.log('Flying'); } } class Sparrow extends Bird { fly() { super.fly(); console.log('Sparrow flying'); } } class Penguin extends Bird { fly() { throw new Error("Penguins can't fly"); } }
In this example, substituting a Penguin for a Bird will cause errors, violating LSP.
Refactor:
Instead of extending Bird, you can create a different hierarchy or use composition to avoid violating LSP.
class Bird { layEggs() { console.log('Laying eggs'); } } class FlyingBird extends Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { swim() { console.log('Swimming'); } } // Now, Penguin does not extend Bird in a way that violates LSP
In this refactor, Penguin no longer extends Bird in a way that requires it to support flying, adhering to LSP.
Definition: A client should not be forced to implement interfaces it doesn't use.
Explanation:
The Interface Segregation Principle (ISP) suggests that instead of having large, monolithic interfaces, it's better to have smaller, more specific interfaces. This way, classes implementing the interfaces are only required to implement the methods they actually use, making the system more flexible and easier to maintain.
React Native Example:
Suppose you have a UserActions interface that includes methods for both regular users and admins. This forces regular users to implement admin-specific methods, which they don't need, violating ISP.
Violation:
interface UserActions { viewProfile(): void; deleteUser(): void; } class RegularUser implements UserActions { viewProfile() { console.log('Viewing profile'); } deleteUser() { throw new Error("Regular users can't delete users"); } }
In this example, the RegularUser class is forced to implement a method (deleteUser) it doesn't need, violating ISP.
Refactor:
Split the UserActions interface into more specific interfaces for regular users and admins.
interface RegularUserActions { viewProfile(): void; } interface AdminUserActions extends RegularUserActions { deleteUser(): void; } class RegularUser implements RegularUserActions { viewProfile() { console.log('Viewing profile'); } } class AdminUser implements AdminUserActions { viewProfile() { console.log('Viewing profile'); } deleteUser() { console.log('User deleted'); } }
Now, RegularUser only implements the methods it needs, adhering to ISP. Admin users implement the AdminUserActions interface, which extends RegularUserActions, ensuring that they have access to both sets of methods.
Node.js Example:
Consider a logger interface that forces implementing methods for various log levels, even if they are not required.
Violation:
class Logger { logError(message) { console.error(message); } logInfo(message) { console.log(message); } logDebug(message) { console.debug(message); } } class ErrorLogger extends Logger { logError(message) { console.error(message); } logInfo(message) { // Not needed, but must be implemented } logDebug(message) { // Not needed, but must be implemented } }
In this example, ErrorLogger is forced to implement methods (logInfo, logDebug) it doesn't need, violating ISP.
Refactor:
Create smaller, more specific interfaces to allow classes to implement only what they need.
class ErrorLogger { logError(message) { console.error(message); } } class InfoLogger { logInfo(message) { console.log(message); } } class DebugLogger { logDebug(message) { console.debug(message); } }
Now, classes can implement only the logging methods they need, adhering to ISP and making the system more modular.
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Explanation:
The Dependency Inversion Principle (DIP) emphasizes that high-level modules (business logic) should not be directly dependent on low-level modules (e.g., database access, external services). Instead, both should depend on abstractions, such as interfaces. This makes the system more flexible and easier to modify or extend.
React Native Example:
In a React Native application, you might have a UserProfile component that directly fetches data from an API service. This creates a tight coupling between the component and the specific API implementation, violating DIP.
Violation:
const UserProfile = ({ userId }) => { const [userData, setUserData] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(response => response.json()) .then(data => setUserData(data)); }, [userId]); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
In this example, the UserProfile component is tightly coupled with a specific API implementation. If the API changes, the component must be modified, violating DIP.
Refactor:
Introduce an abstraction layer (such as a service) that handles data fetching. The UserProfile component will depend on this abstraction, not the concrete implementation.
// Define an abstraction (interface) const useUserData = (userId, apiService) => { const [userData, setUserData] = useState(null); useEffect(() => { apiService.getUserById(userId).then(setUserData); }, [userId]); return userData; }; // UserProfile depends on an abstraction, not a specific API implementation const UserProfile = ({ userId, apiService }) => { const userData = useUserData(userId, apiService); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
Now, UserProfile can work with any service that conforms to the apiService interface, adhering to DIP and making the code more flexible.
Node.js Example:
In a Node.js application, you might have a service that directly uses a specific database implementation. This creates a tight coupling between the service and the database, violating DIP.
Violation:
class UserService { getUserById(id) { return db.query('SELECT * FROM users WHERE id = ?', [id]); } }
In this example, UserService is tightly coupled with the specific database implementation (db.query). If you want to switch databases, you must modify UserService, violating DIP.
Refactor:
Introduce an abstraction (interface) for database access, and have UserService depend on this abstraction instead of the concrete implementation.
// Define an abstraction (interface) class UserRepository { constructor(database) { this.database = database; } getUserById(id) { return this.database.findById(id); } } // Now, UserService depends on an abstraction, not a specific database implementation class UserService { constructor(userRepository) { this.userRepository = userRepository; } async getUserById(id) { return this.userRepository.getUserById(id); } } // You can easily switch database implementations without modifying UserService const mongoDatabase = new MongoDatabase(); const userRepository = new UserRepository(mongoDatabase); const userService = new UserService(userRepository);
By depending on an abstraction (UserRepository), UserService is no longer tied to a specific database implementation. This adheres to DIP, making the system more flexible and easier to maintain.
The SOLID principles are powerful guidelines that help developers create more maintainable, scalable, and robust software systems. By applying these principles in your React
Native and MERN stack projects, you can write cleaner code that's easier to understand, extend, and modify.
Understanding and implementing SOLID principles might require a bit of effort initially, but the long-term benefits—such as reduced technical debt, easier code maintenance, and more flexible systems—are well worth it. Start applying these principles in your projects today, and you'll soon see the difference they can make!
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!