PAYATHON 2026

将 API 拆分为类型与接口 DLL 和实现 DLL,是否合理?

技术老齐

可以。把共享类型和接口放在一个 DLL 中,把客户端实现放在另一个 DLL 中,是合理的设计。客户端接口也可以放进契约 DLL,供客户代码和单元测试使用。

这种拆分是否合适,主要取决于依赖方向是否清楚、公共契约是否稳定。拆成两个 DLL 本身不会让 API 设计变差,也不必为了减少文件数量,把服务端和客户端实现放在一起。

如何拆分项目

可以先按下面的结构拆分:

Device.Contracts.dll
    数据传输对象(DTO)
    WCF 服务契约
    客户端公共接口

Device.Client.dll
    客户端接口的实现
    WCF 通道创建、配置和通信处理

Device.Server.dll
    WCF 服务实现
    设备控制逻辑

客户端和服务端都引用契约项目:

Device.Client  → Device.Contracts
Device.Server  → Device.Contracts

Device.Contracts 不应反向引用客户端或服务端实现。这样双方可以使用共享定义,也不会形成循环依赖。

如果提取类型后仍然需要双向引用,就要检查这些类型是否混入了实现细节。例如,用来传输设备状态的 DTO 不应依赖服务端的设备驱动类。

区分 WCF 服务契约与客户端接口

这两类接口可以放在同一个 DLL 中,各自的用途是:

  • WCF 服务契约描述可以通过网络调用的操作,使用 [ServiceContract] 和 [OperationContract]。
  • 客户端接口描述客户程序需要的能力,供业务代码调用,也方便注入测试替身。

如果两者目前完全一致,可以先保持简单。以后客户端需要增加缓存、组合调用或错误转换时,独立的客户端接口可以让客户代码避免直接依赖 WCF 通信细节。

下面的示例只演示如何划分类型和实现,假设项目已经引用了所需的 WCF 和序列化程序集。

1. 在契约 DLL 中定义类型和接口

using System.Runtime.Serialization;
using System.ServiceModel;

namespace Device.Contracts
{
    [DataContract]
    public sealed class DeviceStatus
    {
        [DataMember(Order = 1)]
        public bool IsReady { get; set; }
    }

    [ServiceContract]
    public interface IDeviceService
    {
        [OperationContract]
        DeviceStatus GetStatus();
    }

    public interface IDeviceClient
    {
        DeviceStatus GetStatus();
    }
}

DeviceStatus 是传输数据的 DTO,IDeviceService 是 WCF 服务契约,客户业务代码则通过 IDeviceClient 使用客户端。

2. 在客户端 DLL 中实现接口

using System;
using Device.Contracts;

namespace Device.Client
{
    public sealed class DeviceClient : IDeviceClient
    {
        private readonly IDeviceService service;

        public DeviceClient(IDeviceService service)
        {
            if (service == null)
                throw new ArgumentNullException(nameof(service));

            this.service = service;
        }

        public DeviceStatus GetStatus()
        {
            return service.GetStatus();
        }
    }
}

实际运行时,传入的 IDeviceService 可以是 WCF 通道。示例省略了通道创建和生命周期管理。正式 SDK 需要明确由谁创建、关闭或中止通道,以及如何处理超时和通信异常。

如果不希望客户接触 WCF,可以由客户端 DLL 提供创建入口,在内部完成通道配置,再向客户返回 IDeviceClient。

3. 让业务代码通过接口接收客户端

客户程序应通过构造函数等方式接收 IDeviceClient:

using Device.Contracts;

public sealed class ProcessController
{
    private readonly IDeviceClient device;

    public ProcessController(IDeviceClient device)
    {
        this.device = device;
    }

    public bool CanStart()
    {
        return device.GetStatus().IsReady;
    }
}

单元测试中可以使用一个简单的替身:

using Device.Contracts;

public sealed class FakeDeviceClient : IDeviceClient
{
    public DeviceStatus Status { get; set; }

    public DeviceStatus GetStatus()
    {
        return Status;
    }
}

测试时,将状态设为 IsReady = true 或 false,再验证 CanStart() 的结果即可,无需启动 WCF 服务或连接设备。

业务代码必须通过接口接收依赖,才能在测试中替换通信实现。如果它在内部直接创建 DeviceClient,即使接口已经放进独立 DLL,测试时仍然难以替换。

客户需要引用两个 DLL,会不会增加负担?

客户代码使用 IDeviceClient、DeviceStatus 等公开类型时,需要在编译时引用契约程序集。运行客户端实现时,也需要能够加载它依赖的程序集。

交付时可以通过 NuGet 包声明这些依赖,让包管理工具带入所需程序集;一个包也可以包含多个 DLL。对客户而言,安装、配置和版本升级是否清楚,比 DLL 数量更影响使用体验。

契约 DLL 可以包含方法签名需要的公共类型,但不宜把各种工具类和内部实现也放进去,逐渐变成“公共杂物库”。

WCF 是否要求客户端和服务端共享同一个 DLL?

不要求。WCF 跨网络交换的是按契约序列化的消息,双方可以通过兼容的契约定义通信。客户端也可以根据服务元数据生成代理和相关类型。

如果客户端和服务端都由你维护,使用兼容的 .NET 环境,而且希望复用相同的 CLR 类型,就适合共享契约 DLL。若客户端采用生成代理的方式,需要明确是否复用共享类型,避免同时使用两套名称相近、实际却不同的 CLR 类型。

独立契约库用于处理项目引用和类型复用,并非 WCF 通信的必要条件。

拆分后需要注意什么

共享契约发布给客户后,就需要按公共 API 维护:

  • 修改方法签名或接口成员时,要考虑客户现有代码及其自定义实现是否仍然兼容。
  • 修改数据契约时,要检查序列化名称、命名空间、成员定义和必填要求。即使共享源码,也不能自动保证新旧客户端与服务端之间兼容。
  • DTO 应只携带通信所需的数据,避免暴露数据库实体、设备驱动对象等内部类型。
  • 使用替身的单元测试可以验证客户业务逻辑;WCF 序列化、绑定配置和真实通信仍需要集成测试。

当前规模下,把相关 DTO、WCF 服务契约和客户端接口放在一个职责明确的 DLL 中,已经足以解决循环依赖并支持单元测试。如果将来客户端接口与网络契约的变化频率明显不同,再把它们分开即可。

备注:内容仅供参考。