Robert C. Martin (Uncle Bob) によって導入された SOLID 原則は、優れたソフトウェア設計の基礎を形成します。これらの原則は、開発者が保守可能でスケーラブルで理解しやすいシステムを作成するための指針となります。このブログでは、SOLID 原則のそれぞれを深く掘り下げ、React Native と MERN スタック (MongoDB、Express.js、React、Node.js) のコンテキストでそれらをどのように適用できるかを探っていきます。
定義: クラスが変更する理由は 1 つだけである必要があります。つまり、クラスの仕事または責任は 1 つだけである必要があります。
説明:
単一責任原則 (SRP) により、クラスまたはモジュールがソフトウェアの機能の 1 つの側面に集中することが保証されます。クラスに複数の責任がある場合、1 つの責任に関連する変更が意図せずに他の責任に影響を及ぼし、バグやメンテナンスコストの増加につながる可能性があります。
ネイティブの反応例:
React Native アプリケーションの UserProfile コンポーネントを考えてみましょう。当初、このコンポーネントは、ユーザー インターフェイスのレンダリングと、ユーザー データを更新するための API リクエストの処理の両方を担当します。このコンポーネントは UI とビジネス ロジックの管理という 2 つの異なることを実行しているため、これは SRP に違反します。
違反:
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> ); };
この例では、UserProfile コンポーネントがデータの取得と UI のレンダリングの両方を担当します。データのフェッチ方法を変更する必要がある場合 (別の API エンドポイントの使用やキャッシュの導入など)、コンポーネントを変更する必要があります。これにより、UI で意図しない副作用が発生する可能性があります。
リファクタリング:
SRP に準拠するには、データ取得ロジックを UI レンダリング ロジックから分離します。
// 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> ); };
このリファクタリングでは、useUserData フックがデータのフェッチを処理し、UserProfile コンポーネントが UI のレンダリングのみに集中できるようにします。これで、データ取得ロジックを変更する必要がある場合、UI コードに影響を与えることなく変更できるようになりました。
Node.js の例:
Node.js アプリケーションでデータベース クエリとビジネス ロジックの両方を処理する UserController を想像してください。コントローラーには複数の責任があるため、これは SRP に違反します。
違反:
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); } }
ここで、UserController はデータベースとの対話と HTTP リクエストの処理の両方を担当します。データベース対話ロジックを変更すると、コントローラーの変更が必要となり、バグのリスクが増加します。
リファクタリング:
データベース対話ロジックをリポジトリ クラスに分離し、コントローラーが 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); } }
現在、UserController は HTTP リクエストとビジネス ロジックの処理に重点を置き、UserRepository はデータベースのやり取りを担当します。この分離により、コードの保守と拡張が容易になります。
定義: ソフトウェア エンティティは拡張に対してオープンである必要がありますが、変更に対してはクローズされている必要があります。
説明:
オープン/クローズ原則 (OCP) は、クラス、モジュール、関数が既存のコードを変更せずに簡単に拡張できる必要があることを強調しています。これにより、抽象化とインターフェイスの使用が促進され、開発者は既存のコードにバグが発生するリスクを最小限に抑えながら新しい機能を導入できるようになります。
ネイティブの反応例:
最初は固定スタイルを持つボタン コンポーネントを想像してください。アプリケーションが成長するにつれて、さまざまな使用例に合わせてさまざまなスタイルでボタンを拡張する必要があります。新しいスタイルが必要になるたびに既存のコンポーネントを変更し続けると、最終的には OCP に違反します。
違反:
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> ); };
この例では、新しいボタン タイプが追加されるたびに、Button コンポーネントを変更する必要があります。これは拡張性がなく、バグのリスクが増加します。
リファクタリング:
スタイルを小道具として渡せるようにすることで、Button コンポーネントをリファクタリングして拡張できるようにします。
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>
リファクタリングにより、Button コンポーネントは変更に対しては閉じられますが、拡張に対しては開かれ、コンポーネントの内部ロジックを変更せずに新しいボタン スタイルを追加できるようになります。
Node.js の例:
複数の支払い方法をサポートする Node.js アプリケーションの支払い処理システムを考えてみましょう。最初は、単一のクラス内で各支払い方法を処理したくなるかもしれません。
違反:
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`); } } }
この例では、新しい支払い方法を追加するには PaymentProcessor クラスを変更する必要があり、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!
以上がReact Native と MERN スタックで SOLID 原則をマスターするの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。