深入设计模式-适配器模式「Go版本」

写作背景

为什么会写"适配器"模式呢?主要有 2 个原因:1、使用"适配器"模式的场景比较多,比如"监听"系统内部事件适配数据、调用三方接口适配接口数据等。2、写"适配器"模式文章少或者说能写的深的文章少并且没有结合具体的业务场景。后文会举真实案例,"适配器"模式如何解决项目上的痛点。

名词解释

"适配器"模式(英语:adapter pattern)有时候也称包装样式或者包装(英语:wrapper)。将一个类的接口转接成用户所期待的。一个适配使得因接口不兼容而不能在一起工作的类能在一起工作,做法是将类自己的接口包裹在一个已存在的类中。

2 种实现方案

1、 类适配器:使用继承方式实现。

2、 对象适配器:使用组合方式实现。

3 个关键字

1、 ITarget:它是一个接口,表示要转换成接口的定义,适配器类需要实现这个接口。为什们需要它呢?主要还是为了扩展,我在某些业务场景其实也是没有定义接口的,个人觉得太繁琐了。

2、 Adaptee 是一组不兼容 ITarget 接口定义的类,简单理解需要我们适配的类(比如:三方平台接口、内部系统的事件...)。

3、 Adaptor:适配器类实现 ITarget 接口,将 Adaptee 转化成一组符合 ITarget 接口定义的接口。

注意:这 3 个关键字名称大家可以根据自己的业务场景定义不要按部就班,ITarget、Adaptee、Adaptor 是我举例用的。

适配器模式的实现

假设你收到产品需求根据订单(名称、状态)、标签(名称)、好友(名称)等给员工下发任务,经过需求分析你分别需要调用订单接口、标签接口、好友接口将接口返回的 VO 转换为内部的通用实体 EventEntity。简单点说你需要根据不同业务接口拉回数据做适配,保证业务逻辑的统一(适配器模式不仅可以适配接口也能适配数据)。

类适配器方式实现

类适配器使用继承的方式实现,但是 GO 没有继承的概念我用 Java 给大家写一个简单案例(ps: java 我只会一点基础):

java 复制代码
// 订单 VO
public class OrderVO {
    String ID;
    String OrderName;
    int Status;
}

/*
 * 抽象的事件实体,将订单、标签、商品部分字段适配到 EventEntity
 * */
public class EventEntity {
    String Name;
    String ID;
}

// 抽象接口定义
public interface ITarget {
    EventEntity Transfer(String id);
    void UpdateStatus(String id, int status);
}

// 待适配类
public class Adaptee {
    public OrderVO GetOrderByID(String id) {
        OrderVO out = new OrderVO();
        out.OrderName = "123";
        out.Status = 1;
        return out;
    }

    public void UpdateOrder(String id, int status) {
        // 更新订单逻辑
    }
}

// 适配器类
public class Adaptor extends Adaptee implements ITarget {
    @Override
    public EventEntity Transfer(String id) {
        OrderVO order = this.GetOrderByID(id);

        // 将外部事件适配成内部标准实体
        EventEntity out = new EventEntity();
        out.Name = order.OrderName;
        out.ID = order.ID;
        return out;
    }

    @Override
    public void UpdateStatus(String id, int status) {
        this.UpdateOrder(id, status);// 直接调用父类接口
    }
}

// main 函数输出即可
 public static void main(String[] args) {
        Adaptor adaptor = new Adaptor();
        EventEntity eventEntity = adaptor.Transfer("xx");
        System.out.println(eventEntity.Name);
}

类"适配器" 方式是不是很简单?定义一个抽象接口(ITarget),适配器类(Adaptor)实现抽象接口,继承待适配类(Adaptee)针对 Adaptee 类的函数做适配返回需要的模型即可。

UpdateStatus 函数如果不需要适配直接调用 UpdateOrder 即可。

对象适配器方式

对象适配器用组合的方式实现,也是同样的需求。我用 GO 实现案例如下:

go 复制代码
// EventEntity 事件实体
type EventEntity struct {
	Name string
	ID   string
}

// OrderVO 订单 vo
type OrderVO struct {
	OrderName string
	ID        string
	Status    int
}

// ITarget 接口定义
type ITarget interface {
	Transfer(id string) EventEntity
	UpdateStatus(id string, status int)
}

// Adaptee 待适配器类
type Adaptee struct {
	ctx context.Context
}

func NewAdaptee(ctx context.Context) *Adaptee {
	return &Adaptee{ctx: ctx}
}

// GetOrderByID 获取订单信息
func (a *Adaptee) GetOrderByID(id string) OrderVO {
	return OrderVO{
		OrderName: "a订单",
		ID:        id,
		Status:    2,
	}
}

// UpdateOrder 更新订单
func (a *Adaptee) UpdateOrder(id string, status int) {
	// 更新逻辑
}

// Adaptor 适配器类
type Adaptor struct {
	adaptee *Adaptee
	ctx     context.Context
}

func NewAdaptor(ctx context.Context) *Adaptor {
	return &Adaptor{adaptee: &Adaptee{}, ctx: ctx}
}

// Transfer 实体转换
func (a *Adaptor) Transfer(id string) EventEntity {
	orderVO := a.adaptee.GetOrderByID(id)
	return EventEntity{
		Name: orderVO.OrderName,
		ID:   orderVO.ID,
	}
}

// UpdateStatus 更新状态
func (a *Adaptor) UpdateStatus(id string, status int) {
	a.adaptee.UpdateOrder(id, status)
}

func TestAdaptor(t *testing.T) {
	adaptor := NewAdaptor(context.TODO())
	eventEntity := adaptor.Transfer("xx")
	fmt.Printf("%v", eventEntity)
}

日志打印如下:

css 复制代码
=== RUN   TestAdaptor
{a订单 xx}--- PASS: TestAdaptor (0.00s)
PASS

"对象适配器"类是不是也很简单?定义一个抽象接口(ITarget),适配器类(Adaptor)实现接口,以组合方式组合(Adaptee)针对 Adaptee 类的函数做适配返回需要的模型即可。

UpdateStatus 函数如果不需要适配直接调用 UpdateOrder 即可。

好了,两种方式都讲完了,有人会问了这两种方式分别在什么场景下使用呢?对于 GO 开发者只能用"对象适配器"因为没有继承的概念。对于 Java 开发者我也没有种标准方案给到你,如果真的需要一个标准那就看哪种方式代码更少、更灵活。

适配器模式应用的场景

封装缺陷接口

假设我们依赖的三方接口有缺陷或者说接口复杂(比如:接口参数多,或者说接口输出依靠你的输入)。为了给团队屏蔽一些复杂的逻辑和设计你不得不去做适配。

举一个案例:三方项目组有一个员工模型,这个模型有A、B、C...二十多个字段,给你提供一个 ByID 接口,由于模型字段多接口提供方需要你提供一些枚举输入(比如:提供一个数组字段需要你明确你需要的字段),根据输入确定字段的输出。经过我对业务的梳理我们这边依赖字段是有规律的我可以把字段进行分类,比如:A、B、C、D 分为 type_1 类,E、F、G、H 分为 type_2 类。。。类推即可,这个没有绝对,代码如下:

go 复制代码
type User2 struct {
	A string
	B string
	C string
	D string
	E string
	F string
	// ..... 还有十多20个字段
}

type User2Client struct {
	ctx context.Context
}

func NewUser2Client(ctx context.Context) *User2Client {
	return &User2Client{
		ctx: ctx,
	}
}

func (c *User2Client) GetUserByID(id string, needFields []string) (*User2, bool, error) {
	// 省略具体逻辑...
	return &User2{
		A: "1",
		B: "2",
		//  User2 字段根据 needFields 来设置值
	}, true, nil
}

调用方代码如下:

go 复制代码
func TestUser(t *testing.T) {
	NewUser2Client(context.TODO()).
		GetUserByID("xxx", []string{"A", "B", "C"})
}

不知道你是否发现一个问题 needFields 字段根据业务场景不同大家都自己传非常混乱,如果业务方模型不稳定大家就比较痛苦了。我们采用适配器模式优化下代码如下:

go 复制代码
type User2 struct {
	A string
	B string
	C string
	D string
	E string
	F string
	// ..... 还有十多20个字段
}

type User2Client struct {
	ctx context.Context
}

func NewUser2Client(ctx context.Context) *User2Client {
	return &User2Client{
		ctx: ctx,
	}
}

func (c *User2Client) GetUserByID(id string, needFields []string) (*User2, bool, error) {
	// 省略具体逻辑...
	return &User2{
		A: "1",
		B: "2",
		//  User2 字段根据 needFields 来设置值
	}, true, nil
}

var (
	mapping = map[string][]string{"type_1": []string{"A", "B", "C", "D"}, "type_2": []string{"E", "F", "G", "H"}}
)

type IUser2Query interface {
	GetUserByID(id string, tp string) (*User2, bool, error)
}

type User2QueryImpl struct {
	ctx context.Context
}

func NewUser2QueryImpl(ctx context.Context) IUser2Query {
	return &User2QueryImpl{
		ctx: ctx,
	}
}

func (q *User2QueryImpl) GetUserByID(id string, tp string) (*User2, bool, error) {
	needFields, exists := mapping[tp]
	if !exists {
		return nil, false, errors.New("tp 输入错误请重新输入")
	}

	return NewUser2Client(q.ctx).GetUserByID(id, needFields)
}

定义了适配器类后复杂查询和逻辑关系被封装到 User2QueryImpl 类,这段代码定义了 mapping 根据业务划分的类型分别映射员工模型的字段,业务调用代码如下:

scss 复制代码
func TestUserAdaptor(t *testing.T) {
	user, exists, err := NewUser2QueryImpl(context.TODO()).
		GetUserByID("xxx", "type_1") // 假设某一个业务需要很明确需要 type_1 对应的字段
	if err != nil {
		panic(err)
	}
	if !exists {
		fmt.Println("未找到 user 信息")
	}

	fmt.Printf("name=%v", user)
}

你可以思考下这么做是不是可以降低使用方的复杂度了?虽然降低了一些灵活性但我觉得可以的。

合并多个类的接口

你们回想下是在你们项目中是否有这类接口,数据来源于不同的团队。做过 Sass 系统应该比较了解联系人和员工模型,联系人模型存了员工 ID,一个联系人被多个员工跟进,现在你知道联系人 ID 需要查询员工名称。代码如下:

go 复制代码
// ContactVO 联系人 VO
type ContactVO struct {
	UserIDs []string
}

// ContactClient 联系人 Client
type ContactClient struct {
	ctx context.Context
}

// NewContactClient new client
func NewContactClient(ctx context.Context) *ContactClient {
	return &ContactClient{ctx: ctx}
}

// GetByID byid 查询
func (c *ContactClient) GetByID(id string) (ContactVO, bool, error) {
	// todo 获取联系数据
	return ContactVO{UserIDs: []string{"1", "2"}}, false, nil
}

// UserVO 联系人 VO
type UserVO struct {
	Name string
}

// UserClient 联系人 Client
type UserClient struct {
	ctx context.Context
}

// NewUserClient new client
func NewUserClient(ctx context.Context) *UserClient {
	return &UserClient{ctx: ctx}
}

// GetByID byid 查询
func (c *UserClient) GetByID(userIDs []string) ([]*UserVO, error) {
	// todo 通过 userIDs 获取员工信息,模拟数据返回

	return []*UserVO{&UserVO{Name: "1"}}, nil
}

未做适配器之前调用方的代码是这么写的,如果使用的场景特别的多并且在不同的业务组那每个组都需要了解细节,如果后续随着需求变动这个接口可能会更复杂。

scss 复制代码
func TestGetUserNoneAdaptor(t *testing.T) {
	ctx := context.Background()
	contact, exists, err := NewContactClient(ctx).GetByID("123")
	if err != nil {
		panic(err)
	}
	if !exists {
		return
	}

	users, err := NewUserClient(ctx).GetByID(contact.UserIDs)
	if err != nil {
		panic(err)
	}
	/*
		根据 users 做其他业务
	*/
	fmt.Printf("%s\n", users[0].Name)
}

定义了适配器类后复杂查询和逻辑关系被封装到 QueryImpl 类

go 复制代码
// IQuery 接口定义
type IQuery interface {
	Query(id string) ([]*UserVO, error)
}

// QueryImpl 定义适配器类
type QueryImpl struct {
	ctx context.Context
}

// NewQueryImpl new impl
func NewQueryImpl(ctx context.Context) *QueryImpl {
	return &QueryImpl{
		ctx: ctx,
	}
}

// Query 查询
func (q *QueryImpl) Query(id string) ([]*UserVO, error) {
	contact, exists, err := NewContactClient(q.ctx).GetByID(id)
	if err != nil {
		return nil, err
	}
	if !exists {
		return nil, errors.New("未找到联系人信息")
	}

	users, err := NewUserClient(q.ctx).GetByID(contact.UserIDs)
	if err != nil {
		return nil, err
	}
	return users, nil
}

做了适配器类之后调用方不需要关注复杂的逻辑,调用的代码如下:

scss 复制代码
func TestGetUserAdaptor(t *testing.T) {
	users, err := NewQueryImpl(context.Background()).Query("xxxyyy")
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s\n", users[0].Name)
}

我相信大家在自己项目中都是这么思考并且写的,不过按照我的习惯在上面这种情况下我一般不会定义接口,因为我很了解业务不会有有其他的适配器类再实现 IQuery 接口的。所以我会写成下面这样:

go 复制代码
// Query 定义适配器类
type Query struct {
	ctx context.Context
}

// NewQuery new impl
func NewQuery(ctx context.Context) *Query {
	return &Query{
		ctx: ctx,
	}
}

// Query 查询
func (q *Query) Query(id string) ([]*UserVO, error) {
	contact, exists, err := NewContactClient(q.ctx).GetByID(id)
	if err != nil {
		return nil, err
	}
	if !exists {
		return nil, errors.New("未找到联系人信息")
	}

	users, err := NewUserClient(q.ctx).GetByID(contact.UserIDs)
	if err != nil {
		return nil, err
	}
	return users, nil
}

升级兼容老版本接口

在版本迭代的时候对于一些要废弃的接口一般不直接将其删除,而是暂时保留,并且标注为 Deprecated。这种情况在我们项目中基本不会用,暂时不举例了。

适配多元数据

适配器模式不仅用于接口适配,还可以用于不同格式数据之间的适配。比如:从不同业务方拉取的数据统一为相同格式的数据,以便使用和存储。讲"类适配器"方式和"对象适配器"方式已经讲了。其实在我们业务场景会监听十多个事件,把这些事件的对应的业务方数据拉回来做数据适配,在业务下游做触发员工任务逻辑。

替换外部依赖接口或驱动

这种在大家的项目中应该非常常见,我给大家举一个例子数据库的 XXRepo,比如现在查询员工任务用的是 MYSQL,但是某一天需要替换数据库组件需要换成 Mongodb。案例如下:

用 MYSQL 场景

go 复制代码
// IUserRepo 员工任务数据库查询接口定义
type IUserRepo interface {
	Create(data []interface{}) (int64, error)
}

// UserRepoMysqlImpl mysql 实现类
type UserRepoMysqlImpl struct {
	ctx context.Context
}

// NewUserRepoMysqlImpl new impl
func NewUserRepoMysqlImpl(ctx context.Context) IUserRepo {
	return &UserRepoMysqlImpl{
		ctx: ctx,
	}
}

func (d *UserRepoMysqlImpl) Create(data []interface{}) (int64, error) {
	return 10, nil // todo 此处简单处理
}

type UserService struct {
	ctx      context.Context
	repoImpl IUserRepo
}

func NewUserService(ctx context.Context, repoImpl IUserRepo) *UserService {
	return &UserService{
		ctx:      ctx,
		repoImpl: repoImpl,
	}
}

func (s *UserService) Create(data []interface{}) error {
	_, err := s.repoImpl.Create(data)
	return err
}

上游使用方调用代码如下:

scss 复制代码
func TestMysql(t *testing.T) {
	ctx := context.TODO()
	NewUserService(ctx, NewUserRepoMysqlImpl(ctx)).
		Create([]interface{}{"123"})
}

如果某一天我们公司将 Mysql 换成 Mongodb 实现代码如下

go 复制代码
// IUserRepo 员工任务数据库查询接口定义
type IUserRepo interface {
	Create(data []interface{}) (int64, error)
}

// UserRepoMongodbImpl mysql 实现类
type UserRepoMongodbImpl struct {
	ctx context.Context
}

// NewUserRepoMongodbImpl new impl
func NewUserRepoMongodbImpl(ctx context.Context) IUserRepo {
	return &UserRepoMongodbImpl{
		ctx: ctx,
	}
}

func (d *UserRepoMongodbImpl) Create(data []interface{}) (int64, error) {
	return 10, nil // todo 此处简单处理
}

type UserService struct {
	ctx      context.Context
	repoImpl IUserRepo
}

func NewUserService(ctx context.Context, repoImpl IUserRepo) *UserService {
	return &UserService{
		ctx:      ctx,
		repoImpl: repoImpl,
	}
}

func (s *UserService) Create(data []interface{}) error {
	_, err := s.repoImpl.Create(data)
	return err
}

适配 Mongodb 组件后代码如下:

scss 复制代码
func TestMongodb(t *testing.T) {
	ctx := context.TODO()
	NewUserService(ctx, NewUserRepoMongodbImpl(ctx)).
		Create([]interface{}{"123"})
}

思考题

1、 你们项目里面有用适配器模式吗?用于哪些场景呢?

参考资料

zh.wikipedia.org/wiki/%E9%80...

最后

我的理解不一定是正确的,但一定是当时我能想到最好的方案。欢迎大家帮忙订正,有问题评论区留言。

相关推荐
liang89991 分钟前
设计模式之装饰器模式(Decorator)
设计模式·装饰器模式
Amagi.5 分钟前
Spring中Bean的作用域
java·后端·spring
CocoaAndYy5 分钟前
设计模式-适配器模式
设计模式·适配器模式
刷帅耍帅5 分钟前
设计模式-适配器模式
设计模式·适配器模式
2402_8575893628 分钟前
Spring Boot新闻推荐系统设计与实现
java·spring boot·后端
J老熊37 分钟前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
Benaso40 分钟前
Rust 快速入门(一)
开发语言·后端·rust
sco528240 分钟前
SpringBoot 集成 Ehcache 实现本地缓存
java·spring boot·后端
原机小子1 小时前
在线教育的未来:SpringBoot技术实现
java·spring boot·后端
吾日三省吾码1 小时前
详解JVM类加载机制
后端