spring boot项目,需要写一个接口吗?

工作十年接触过无数大大小小的springboot项目,我发现国内公司的项目喜欢把项目层级弄得很复杂,各种类不管是单一实现还是多实现,先写个接口再说。而我在外企同样是spring boot技术的大型项目,项目的层级结构很简单就4到5层,只要用不到接口的地方绝对不会写,能用实体内绝不用抽象类,孰优孰劣,仁者见仁智者见智。下面是国外大神对项目中使用接口的看法:

翻译:

使用 Spring boot 时,您经常使用服务(使用 注释的 bean @Service)。在 Internet 上的许多示例中,您会看到人们为这些服务创建接口。µ 例如,如果我们正在创建一个待办事项列表应用程序,您可能会创建一个TodoService带有TodoServiceImpl实现的接口。

在这篇博文中,我们将了解为什么我们经常这样做,以及是否有必要。

简短的回答

简短的回答很简单。,您不需要接口。如果您创建一个服务,您可以命名该类本身TodoService并在您的 bean 中自动装配它。例如:

@Service
public class TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;
    
    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

无论您使用字段注入还是构造函数注入,您在此处看到的示例都将起作用。@Autowired

那何必呢?

所以,如果我们不需要它……那为什么我们经常写一个?嗯,第一个原因是一个相当历史的原因。但在我们看之前,我们必须解释注解是如何与 Spring 一起工作的。

如果您使用诸如 之类的注释@Cacheable,您希望返回缓存中的结果。Spring 这样做的方式是为您的 bean 创建一个代理并向这些代理添加必要的逻辑。最初,Spring 使用 JDK 动态代理。这些动态代理只能为接口生成,这就是为什么您必须在过去编写接口的原因。

但是,从十多年前开始,Spring 还支持 CGLIB 代理。这些代理不需要单独的接口。从 Spring 3.2 开始,您甚至不必添加单独的库,因为 CGLIB 包含在 Spring 本身中。

松耦合

第二个原因可能是在两个类之间创建松散耦合。通过使用接口,依赖于您的服务的类不再依赖于它的实现。这使您可以独立使用它们。例如:

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class TodoServiceImpl {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;
    
    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

但是,在这个例子中,我认为TodoFacadeTodoServiceImpl属于一起。在此处添加接口会产生额外的复杂性。就个人而言,我认为不值得。

多种实现

松耦合可能有用的一个原因是如果您有多个实现。例如,假设您有两个 a 的实现TodoService,其中一个从内存中检索待办事项列表,另一个从某处的数据库中检索它。

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class InMemoryTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;
    
    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

在这种情况下,松散耦合非常有用,因为您TodoFacade不需要知道待办事项是存储在数据库中还是存储在内存中。那不是门面的责任,而是应用程序配置的责任。

您完成这项工作的方式取决于您要实现的目标。如果您TodoFacade必须调用所有实现,那么您应该注入一个集合:

@Component
public class TodoFacade {
    private List<TodoService> services;
    
    public TodoFacade(TodoService services) {
        this.services = services;
    }
}

如果其中一种实现应在 99% 的情况下使用,而另一种仅在非常特殊的情况下使用,则使用@Primary

@Primary
@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

使用@Primary,你告诉 Spring 容器,只要它必须注入一个TodoService如果必须使用另一个,则必须通过使用@Qualifier或注入特定实现本身来显式配置它。就个人而言,我会在一个单独的@Configuration类中执行此操作,因为否则,您会TodoFacade再次使用特定于实现的细节来污染您的。

例如:

@Configuration
public class TodoConfiguration {
    @Bean
    // Using @Qualifier
    public TodoFacade todoFacade(@Qualifier("inMemoryTodoService") TodoService service) {
        return new TodoFacade(service);
    }
    
    @Bean
    // Or by using the specific implementation
    public TodoFacade todoFacade(InMemoryTodoService service) {
        return new TodoFacade(service);
    }
}

控制反转

另一种松散耦合是控制反转或 IoC。对我来说,控制反转在处理多个相互依赖的模块时很有用。例如,假设我们有一个OrderService 和一个CustomerService客户应该能够删除其个人资料,在这种情况下,应取消所有挂单。如果我们在没有接口的情况下实现它,我们会得到这样的结果:

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Service
public class CustomerService {
    private OrderService orderService;
    
    public CustomerService(OrderService orderService) {
        this.orderService = orderService;
    }
    
    public void deleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
        // TODO: implement
    }
}

如果我们这样做,事情会很快变糟。您的应用程序中的所有域都将绑定在一起,最终您将得到一个高度耦合的应用程序。

CustomerDeletionListener我们可以创建一个接口,而不是这样做:

public interface CustomerDeletionListener {
    void onDeleteCustomer(ID customerId);
}

@Service
public class CustomerService {
    private List<CustomerDeletionListener> deletionListeners;
    
    public CustomerService(List<CustomerDeletionListener> deletionListeners) {
        this.deletionListeners = deletionListeners;
    }
    
    public void deleteCustomer(ID customerId) {
        deletionListeners.forEach(listener -> listener.onDeleteCustomer(customerId));
        // TODO: implement
    }
}

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Component
public class OrderCustomerDeletionListener implements CustomerDeletionListener {
    private OrderService orderService;
    
    public OrderCustomerDeletionListener(OrderService orderService) {
        this.orderService = orderService;
    }
    
    @Override
    public void onDeleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
    }
}

如果你看一下这个例子,你会看到控制反转在起作用。在第一个示例中,如果我们更改cancelOrdersForCustomer()内的方法OrderService,则CustomerService也必须更改。这意味着它OrderService处于控制之中。

在第二个示例中,OrderService不再受控制。当我们更改cancelOrdersForCustomer()模块时,只有OrderCustomerDeletionListener必须更改,这是订单模块的一部分。这意味着它CustomerService处于控制之中。此外,这两种服务都是松散耦合的,因为其中一个不直接依赖于另一个。

虽然第二种方法确实引入了更多的复杂性(一个新类和一个新接口),但它确实使得两个域都不会与另一个域高度耦合。这使它们更容易重构。此侦听器也可以重构为更受事件驱动的架构。这使得重构为领域驱动的模块化设计或微服务架构变得更加容易。

测试

我想谈的最后一件事是测试。有些人会争辩说您需要一个接口,以便您可以拥有一个虚拟实现(因此,有多个实现)。然而,像 Mockito 这样的模拟库解决了这个问题。

如果您正在编写单元测试,则可以使用MockitoExtension

@ExtendWith(MockitoExtension.class)
public class TodoFacadeTest {
    private TodoFacade facade;
    @Mock
    private TodoService service;
    
    @BeforeEach
    void setUp() {
        this.facade = new TodoFacade(service);
    }
    
    // TODO: implement tests
}

这种方法允许你在不知道服务做什么的情况下正确地测试外观。通过使用Mockito.when(),您可以控制服务模拟应该返回什么,并且通过使用,Mockito.verify()您可以验证是否调用了特定方法。例如:

@Test
void findAll_shouldUseServicefindAllTodos() {
    Todo todo = new Todo();
    when(service.findAllTodos()).thenReturn(todo);
    assertThat(facade.findAll()).containsOnly(todo);
    verify(service).findAllTodos();
}

即使您正在编写需要运行 Spring 容器的集成测试,您也可以使用@MockBean注解来模拟 bean。确保您不扫描包含实际实现的包。

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = TodoFacade.class)
public class TodoFacadeTest {
    @Autowired
    private TodoFacade facade;
    @MockBean
    private TodoService service;
}

所以在大多数情况下,您在测试时不需要接口。

结论

所以,如果你问我是否应该为你的服务使用接口,我的回答是否定的。唯一的例外是,如果您尝试使用控制反转,或者您有多个实现需要处理。

你可能会想,创建一个接口不是更好吗,以防万一?我也会对此说不。首先,我相信“你不需要它”(YAGNI)原则。这意味着您不应该为了“我可能需要它”而在代码中添加额外的复杂性,因为通常情况下您不需要。其次,即使事实证明你确实需要它,也没有问题。大多数 IDE 允许您从现有类中提取接口,并且它将重构所有代码以在眨眼之间使用该接口。

posted @ 2022-08-09 22:45  PickUpMemories  阅读(376)  评论(0编辑  收藏  举报