Làm thế nào một lớp nên giao tiếp với người dùng của nó tập hợp con các phương thức mà nó thực hiện?


12

Kịch bản

Một ứng dụng web xác định giao diện phụ trợ người dùng IUserBackendvới các phương thức

  • getUser (uid)
  • createdUser (uid)
  • xóa Người dùng (uid)
  • setPassword (uid, mật khẩu)
  • ...

Các phụ trợ người dùng khác nhau (ví dụ LDAP, SQL, ...) triển khai giao diện này nhưng không phải mọi phụ trợ đều có thể làm mọi thứ. Ví dụ: máy chủ LDAP cụ thể không cho phép ứng dụng web này xóa người dùng. Vì vậy, LdapUserBackendlớp thực hiện IUserBackendsẽ không thực hiện deleteUser(uid).

Lớp cụ thể cần truyền đạt tới ứng dụng web những gì ứng dụng web được phép làm với người dùng phụ trợ.

Giải pháp đã biết

Tôi đã thấy một giải pháp mà IUserInterfacecó một implementedActionsphương pháp mà trả về một số nguyên đó là kết quả của ORS Bitwise trong những hành động Bitwise anded với các hành động được yêu cầu:

function implementedActions(requestedActions) {
    return (bool)(
        ACTION_GET_USER
        | ACTION_CREATE_USER
        | ACTION_DELTE_USER
        | ACTION_SET_PASSWORD
        ) & requestedActions)
}

Ở đâu

  • ACTION_GET_USER = 1
  • ACTION_CREATE_USER = 2
  • ACTION_DELETE_USER = 4
  • ACTION_SET_PASSWORD = 8
  • .... = 16
  • .... = 32

Vân vân.

Vì vậy, ứng dụng web thiết lập một bitmask với những gì nó cần và implementedActions()trả lời với boolean cho dù nó hỗ trợ chúng.

Ý kiến

Các hoạt động bit này đối với tôi trông giống như các di tích từ thời C, không nhất thiết phải dễ hiểu về mã sạch.

Câu hỏi

Một mẫu hiện đại (tốt hơn?) Cho lớp để giao tiếp tập hợp con của các phương thức giao diện mà nó thực hiện là gì? Hay là "phương pháp hoạt động bit" từ phía trên vẫn là cách thực hành tốt nhất?

( Trong trường hợp có vấn đề: PHP, mặc dù tôi đang tìm kiếm một giải pháp chung cho các ngôn ngữ OO )


5
Giải pháp chung là chia nhỏ giao diện. Không IUserBackendnên chứa deleteUserphương thức nào cả. Đó phải là một phần của IUserDeleteBackend(hoặc bất cứ điều gì bạn muốn gọi nó). Mã cần xóa người dùng sẽ có các đối số IUserDeleteBackend, mã không cần chức năng đó sẽ sử dụng IUserBackendvà sẽ không có bất kỳ rắc rối nào với các phương thức chưa được thực hiện.
Bakuriu

3
Một xem xét thiết kế quan trọng là liệu sự sẵn có của một hành động phụ thuộc vào hoàn cảnh thời gian chạy. Có phải tất cả các máy chủ LDAP không hỗ trợ xóa? Hay đó là một thuộc tính cấu hình của một máy chủ và có thể thay đổi khi khởi động lại hệ thống? Trình kết nối LDAP của bạn có nên tự động phát hiện ra tình huống này không, hoặc có nên yêu cầu thay đổi cấu hình để cắm vào trình kết nối LDAP khác với các khả năng khác nhau không? Những điều này có tác động mạnh mẽ đến các giải pháp khả thi.
Sebastian Redl

@SebastianRedl Vâng, đó là điều mà tôi không tính đến. Tôi thực sự cần một giải pháp cho thời gian chạy. Vì tôi không muốn làm mất hiệu lực các câu trả lời rất hay, tôi đã mở một câu hỏi mới tập trung vào thời gian chạy
vấn đề

Câu trả lời:


24

Nói rộng ra, có hai cách tiếp cận bạn có thể thực hiện ở đây: kiểm tra & ném hoặc sáng tác thông qua đa hình.

Kiểm tra và ném

Đây là cách tiếp cận bạn đã mô tả. Thông qua một số phương tiện, bạn chỉ ra cho người dùng của lớp xem một số phương thức khác có được thực hiện hay không. Điều này có thể được thực hiện với một phương thức duy nhất và liệt kê bitwise (như bạn mô tả) hoặc thông qua một loạt các supportsDelete()phương thức vv.

Sau đó, nếu supportsDelete()trả về false, việc gọi deleteUser()có thể dẫn đến NotImplementedExeptionbị ném hoặc phương thức không làm gì cả.

Đây là một giải pháp phổ biến trong số một số người, vì nó đơn giản. Tuy nhiên, nhiều người - bao gồm cả tôi - cho rằng đó là vi phạm nguyên tắc thay thế của Liskov (chữ L trong RẮN) và do đó không phải là một giải pháp tốt.

Thành phần thông qua đa hình

Cách tiếp cận ở đây là xem IUserBackendquá xa một công cụ. Nếu các lớp không thể luôn luôn thực hiện tất cả các phương thức trong giao diện đó, thì hãy chia giao diện thành các phần tập trung hơn. Vì vậy, bạn có thể có: IGeneralUser IDeletableUser IRenamableUser ... Nói cách khác, tất cả các phương thức mà tất cả các phụ trợ của bạn có thể thực hiện đều đi vào IGeneralUservà bạn tạo một giao diện riêng cho từng hành động mà chỉ một số hành động có thể thực hiện.

Theo cách đó, LdapUserBackendkhông triển khai IDeletableUservà bạn kiểm tra bằng cách sử dụng một bài kiểm tra, chẳng hạn như (sử dụng cú pháp C #):

if (backend is IDeletableUser deletableUser)
{
    deletableUser.deleteUser(id);
}

(Tôi không chắc về cơ chế trong PHP để xác định xem một cá thể có thực hiện giao diện hay không và sau đó bạn chuyển sang giao diện đó như thế nào, nhưng tôi chắc chắn có ngôn ngữ tương đương trong ngôn ngữ đó)

Ưu điểm của phương pháp này là nó sử dụng tốt tính đa hình để cho phép mã của bạn tuân thủ các nguyên tắc RẮN và theo cách của tôi thanh lịch hơn.

Nhược điểm là nó có thể trở nên khó sử dụng quá dễ dàng. Ví dụ, nếu bạn cuối cùng phải thực hiện hàng tá giao diện vì mỗi phụ trợ cụ thể có các khả năng hơi khác nhau, thì đây không phải là một giải pháp tốt. Vì vậy, tôi chỉ đơn giản khuyên bạn sử dụng phán đoán của mình về việc phương pháp này có thiết thực với bạn trong dịp này hay không và sử dụng nó, nếu có.


4
+1 cho các cân nhắc thiết kế RẮN. Luôn luôn tốt đẹp để hiển thị câu trả lời với các cách tiếp cận khác nhau sẽ giữ cho mã sạch hơn về phía trước!
Caleb

2
trong PHP nó sẽ làif (backend instanceof IDelatableUser) {...}
Rad80

Bạn đã đề cập đến vi phạm LSP. Tôi đồng ý, nhưng muốn thêm một chút vào nó: Test & throw là hợp lệ nếu giá trị đầu vào khiến nó không thể thực hiện hành động, ví dụ: truyền 0 làm ước số trong một Divide(float,float)phương thức. Giá trị đầu vào là biến và ngoại lệ bao gồm một tập hợp con nhỏ của các thực thi có thể. Nhưng nếu bạn ném dựa trên loại triển khai của mình, thì khả năng thực thi của nó là một thực tế nhất định. Ngoại lệ bao gồm tất cả các đầu vào có thể , không chỉ là một tập hợp con của chúng. Điều đó giống như đặt một dấu hiệu "sàn ướt" trên mỗi sàn ướt trong một thế giới nơi mọi tầng luôn ẩm ướt.
Flater

Có một ngoại lệ (chơi chữ không có ý định) cho nguyên tắc không ném vào một loại. Đối với C #, đó là NotImplementedException. Ngoại lệ này được dành cho việc ngừng hoạt động tạm thời , tức là mã chưa được phát triển nhưng sẽ được phát triển. Điều đó không giống như quyết định dứt khoát rằng một lớp nhất định sẽ không bao giờ làm bất cứ điều gì với một phương thức nhất định, ngay cả sau khi quá trình phát triển hoàn tất.
Flater

Cảm ơn bạn đã trả lời. Tôi thực sự cần một giải pháp thời gian chạy nhưng không nhấn mạnh nó trong câu hỏi của tôi. Vì tôi không muốn xâm phạm câu trả lời của bạn, tôi quyết định tạo một câu hỏi mới .
vấn đề

5

Tình hình hiện nay

Thiết lập hiện tại vi phạm Nguyên tắc phân chia giao diện (I trong RẮN).

Tài liệu tham khảo

Theo Wikipedia, nguyên tắc phân tách giao diện (ISP) nói rằng không khách hàng nào bị buộc phải phụ thuộc vào các phương thức mà nó không sử dụng . Nguyên tắc phân tách giao diện được xây dựng bởi Robert Martin vào giữa những năm 1990.

Nói cách khác, nếu đây là giao diện của bạn:

public interface IUserBackend
{
    User getUser(int uid);
    User createUser(int uid);
    void deleteUser(int uid);
    void setPassword(int uid, string password);
}

Sau đó, mỗi lớp thực hiện giao diện này phải sử dụng mọi phương thức được liệt kê của giao diện. Không ngoại lệ.

Hãy tưởng tượng nếu có một phương pháp tổng quát:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     backendService.deleteUser(user.Uid);
}

Nếu bạn thực sự làm điều đó để chỉ một số lớp triển khai thực sự có thể xóa người dùng, thì phương thức này đôi khi sẽ nổ tung trong khuôn mặt của bạn (hoặc không làm gì cả). Đó không phải là thiết kế tốt.


Giải pháp đề xuất của bạn

Tôi đã thấy một giải pháp trong đó IUserInterface có phương thức triển khai thực hiện trả về một số nguyên là kết quả của các bit OR của các hành động theo bit bit ANDed với các hành động được yêu cầu.

Những gì bạn về cơ bản muốn làm là:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     if(backendService.canDeleteUser())
         backendService.deleteUser(user.Uid);
}

Tôi đang bỏ qua cách chính xác chúng tôi xác định liệu một lớp nhất định có thể xóa người dùng hay không. Cho dù đó là boolean, cờ bit, ... không thành vấn đề. Tất cả tập trung vào một câu trả lời nhị phân: nó có thể xóa người dùng, có hay không?

Điều đó sẽ giải quyết vấn đề, phải không? Vâng, về mặt kỹ thuật, nó làm. Nhưng bây giờ, bạn đang vi phạm Nguyên tắc thay thế Liskov (chữ L trong RẮN).

Để giải thích Wikipedia khá phức tạp, tôi đã tìm thấy một ví dụ điển hình trên StackOverflow . Lưu ý ví dụ "xấu":

void MakeDuckSwim(IDuck duck)
{
    if (duck is ElectricDuck)
        ((ElectricDuck)duck).TurnOn();

    duck.Swim();
}

Tôi giả sử bạn thấy sự tương đồng ở đây. Đây là một phương thức được cho là xử lý một đối tượng trừu tượng ( IDuck, IUserBackend), nhưng do thiết kế lớp bị xâm phạm, trước tiên, nó buộc phải xử lý các triển khai cụ thể ( ElectricDuck, đảm bảo đó không phải là một IUserBackendlớp không thể xóa người dùng).

Điều này đánh bại mục đích phát triển một cách tiếp cận trừu tượng.

Lưu ý: Ví dụ ở đây dễ sửa hơn trường hợp của bạn. Ví dụ, nó cũng đủ để có ElectricDucklượt cứ vào bên trong các Swim()phương pháp. Cả hai con vịt vẫn có thể bơi, vì vậy kết quả chức năng là như nhau.

Bạn có thể muốn làm một cái gì đó tương tự. Đừng . Bạn không thể giả vờ xóa người dùng nhưng thực tế có phần thân phương thức trống. Mặc dù điều này không hoạt động từ góc độ kỹ thuật, nhưng nó không thể biết liệu lớp triển khai của bạn có thực sự làm điều gì đó khi được yêu cầu làm điều gì đó hay không. Đó là một nơi sinh sản cho mã không thể nhầm lẫn.


Giải pháp đề xuất của tôi

Nhưng bạn đã nói rằng một lớp triển khai chỉ có thể xử lý một số phương thức này.

Vì lợi ích của ví dụ, giả sử rằng đối với mọi sự kết hợp có thể có của các phương thức này, có một lớp sẽ thực hiện nó. Nó bao gồm tất cả các căn cứ của chúng tôi.

Giải pháp ở đây là phân chia giao diện .

public interface IGetUserService
{
    User getUser(int uid);
}

public interface ICreateUserService
{
    User createUser(int uid);
}

public interface IDeleteUserService
{
    void deleteUser(int uid);
}

public interface ISetPasswordService
{
    void setPassword(int uid, string password);
}

Lưu ý rằng bạn có thể thấy điều này đến khi bắt đầu câu trả lời của tôi. Các giao diện Tách riêng Nguyên tắc tên đã tiết lộ rằng nguyên tắc này được thiết kế để làm cho bạn cách ly các giao diện đến một mức độ đủ.

Điều này cho phép bạn kết hợp các giao diện kết hợp theo ý muốn:

public class UserRetrievalService 
              : IGetUserService, ICreateUserService
{
    //getUser and createUser methods implemented here
}

public class UserDeleteService
              : IDeleteUserService
{
    //deleteUser method implemented here
}

public class DoesEverythingService 
              : IGetUserService, ICreateUserService, IDeleteUserService, ISetPasswordService
{
    //All methods implemented here
}

Mỗi lớp có thể quyết định những gì họ muốn làm, mà không cần phá vỡ hợp đồng giao diện của họ.

Điều này cũng có nghĩa là chúng ta không cần kiểm tra xem một lớp nhất định có thể xóa người dùng hay không. Mỗi lớp thực hiện IDeleteUserServicegiao diện sẽ có thể xóa người dùng = Không vi phạm Nguyên tắc thay thế Liskov .

public void HaveUserDeleted(IDeleteUserService backendService, User user)
{
     backendService.deleteUser(user.Uid); //guaranteed to work
}

Nếu bất cứ ai cố gắng vượt qua một đối tượng không thực hiện IDeleteUserService, chương trình sẽ từ chối biên dịch. Đây là lý do tại sao chúng tôi thích có loại an toàn.

HaveUserDeleted(new DoesEverythingService());    // No problem.
HaveUserDeleted(new UserDeleteService());        // No problem.
HaveUserDeleted(new UserRetrievalService());     // COMPILE ERROR

Chú thích

Tôi lấy ví dụ đến mức cực đoan, tách giao diện thành các phần nhỏ nhất có thể. Tuy nhiên, nếu tình huống của bạn khác đi, bạn có thể thoát khỏi những khối lớn hơn.

Ví dụ: nếu mọi dịch vụ có thể tạo người dùng luôn có khả năng xóa người dùng (và ngược lại), bạn có thể giữ các phương thức này như một phần của một giao diện:

public interface IManageUserService
{
    User createUser(int uid);
    void deleteUser(int uid);
}

Không có lợi ích kỹ thuật nào khi làm điều này thay vì tách ra thành các phần nhỏ hơn; nhưng nó sẽ làm cho sự phát triển dễ dàng hơn một chút vì nó đòi hỏi ít lò hơi hơn.


+1 để tách giao diện lên theo hành vi họ hỗ trợ, đó là toàn bộ mục đích của giao diện.
Greg Burghardt

Cảm ơn bạn đã trả lời. Tôi thực sự cần một giải pháp thời gian chạy nhưng không nhấn mạnh nó trong câu hỏi của tôi. Vì tôi không muốn xâm phạm câu trả lời của bạn, tôi quyết định tạo một câu hỏi mới .
vấn đề

@pro Hiệuofficer: Đánh giá thời gian chạy cho các trường hợp này hiếm khi là lựa chọn tốt nhất, nhưng thực sự có trường hợp để làm như vậy. Trong trường hợp như vậy, bạn hoặc tạo một phương thức có thể được gọi nhưng cuối cùng có thể không làm gì cả (gọi nó TryDeleteUserđể phản ánh điều đó); hoặc bạn có phương pháp cố tình ném Ngoại lệ nếu đó là một tình huống có thể xảy ra nhưng có vấn đề. Sử dụng phương pháp CanDoThing()DoThing()phương pháp hoạt động, nhưng nó sẽ yêu cầu người gọi bên ngoài của bạn sử dụng hai cuộc gọi (và bị phạt vì không làm như vậy), điều này ít trực quan và không thanh lịch.
Flater

0

Nếu bạn muốn sử dụng loại cấp cao hơn, bạn có thể sử dụng loại cài đặt theo ngôn ngữ bạn chọn. Hy vọng rằng nó cung cấp một số đường cú pháp để thực hiện xác định giao điểm và tập hợp con.

Về cơ bản, đây là những gì Java làm với Enumset (trừ đường cú pháp, nhưng này, đó là Java)


0

Trong thế giới .NET, bạn có thể trang trí các phương thức và các lớp với các thuộc tính tùy chỉnh. Điều này có thể không liên quan đến trường hợp của bạn.

Nghe có vẻ với tôi mặc dù vấn đề bạn có thể liên quan nhiều hơn với mức độ cao hơn của thiết kế.

Nếu đây là một tính năng UI, chẳng hạn như trang hoặc thành phần người dùng chỉnh sửa, thì các khả năng khác nhau được che dấu như thế nào? Trong trường hợp này, 'thử và ném' sẽ là một cách tiếp cận khá kém hiệu quả cho mục đích đó. Nó giả định rằng trước khi tải mỗi trang, bạn chạy một cuộc gọi giả đến từng chức năng để xác định xem tiện ích hoặc phần tử có bị ẩn hay được trình bày khác nhau không. Ngoài ra, bạn có một trang web về cơ bản buộc người dùng khám phá những gì có sẵn bằng cách 'kiểm tra và ném' thủ công, bất kể bạn sử dụng lộ trình mã hóa nào, bởi vì người dùng không phát hiện ra rằng có gì đó không khả dụng cho đến khi cảnh báo bật lên hiển thị.

Vì vậy, đối với một giao diện người dùng, bạn có thể muốn xem xét cách bạn quản lý tính năng và liên kết lựa chọn các triển khai có sẵn với điều đó, thay vì các triển khai được chọn sẽ điều khiển các tính năng nào có thể được quản lý. Bạn có thể muốn xem xét các khung để soạn các phụ thuộc tính năng và xác định rõ ràng các khả năng dưới dạng các thực thể trong mô hình miền của bạn. Điều này thậm chí có thể được gắn vào ủy quyền. Về cơ bản, việc quyết định xem một khả năng có sẵn hay không dựa trên cấp độ ủy quyền có thể được mở rộng để quyết định xem một khả năng có thực sự được thực hiện hay không, và các tính năng của UI cấp cao có thể có ánh xạ rõ ràng cho các bộ khả năng.

Nếu đây là API Web, thì lựa chọn thiết kế tổng thể có thể phức tạp bằng cách phải hỗ trợ nhiều phiên bản công khai của API 'Quản lý người dùng' hoặc 'Tài nguyên' của người dùng khi các khả năng mở rộng theo thời gian.

Vì vậy, để tóm tắt, trong thế giới .NET, bạn có thể khai thác các cách khác nhau để xác định trước các lớp xác định cái gì, nhưng trong mọi trường hợp, dường như các vấn đề thực sự sẽ xảy ra với những gì bạn làm với thông tin đó.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.