DDD领域驱动设计入门:1

1 篇文章 0 订阅

参考资料:

《【领域驱动设计】DDD五板斧》(视频教程)

《DDD 模式从天书到实践》

《领域驱动设计在互联网业务开发中的实践》

《DomainDrivenDesign》

前言

        由于最近接触的新项目采用了DDD领取驱动设计的方式,网上对DDD的讲解文章很多都是偏理论的且很多都倾向于和微服务的结合,对一些朋友不太友好,因此本文计划直接以代码示例的方式来进行讲解,将抽象的概念具体化,让初学者也能快速入门。

        首先需要明确的是DDD是一种设计思想,用来指导整个系统的设计实现高内聚低耦合的目标,简单来说就是将不同的功能模块划分为一个个的领域,每个领域内都有一些领域对象(可以暂且理解为实体类),这些领域对象通过聚合封装在一起,外界看起来就像是一个大的对象,聚合内只有一个领域对象能与外界交互且能将聚合内的对象串联起来,这个对象被叫做聚合根。

         DDD与我们常用的设计模式并不相同,可以将DDD理解为战略布局,而设计模式则是具体的战术打法,两者从不同的角度来优化我们系统的设计与开发。

目录

前言

一、从贫血模型到充血模型

二、防腐层与资源库

三、聚合与聚合根


一、从贫血模型到充血模型

        比如现在开发一个新客注册的功能:

        (1)输入新客的姓名与手机号,查询出手机号的归属地与运营商

        (2)根据归属地与运营商将客户分组

        (3)按组别将客户分配给对应的销售人员

public class User {
    Long userId;
    String name;
    String phone;
    Long repId;
    // set/get方法等
}

public class RegistrationServiceImpl implements RegistrationService {

    // 销售对象DAO
    private SalesRepRepository salesRepRepo;
    // 用户对象DAO
    private UserRepository userRepo;

    public User register(String name, String phone)
            throws ValidationException {
        // 参数校验
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }

        // 获取手机号归属地编号和运营商编号 然后通过编号找到区域内的销售组
        String areaCode = getAreaCode(phone);
        String operatorCode = getOperatorCode(phone);
        SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);

        // 创建用户并入库
        User user = new User();
        user.name = name;
        user.phone = phone;

        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
    
    private String getAreaCode(String phone) {
        //...
    }
    
    private String getOperatorCode(String phone) {
       //...
    }
}

        这是一个很简单的功能,几乎和我们平时写的代码如出一辙,定义一个user类,然后在service层实现我们的业务逻辑、与DAO的交互等,逻辑简洁明了,即使是第一次接触这段代码的人也能迅速的理解其中的业务行为。

        但是,这段示例是存在一些隐患的。首先来看实体类User,该类只有属性以及属性对应的set/get方法,在使用的时候也只是充当数据库的表结构映射。这看起来没什么问题,可我们回顾一下面向对象的定义会发现,对象应该是具备属性与行为的,简单来说就是这个类不单单要有属性,还要有与其相关行为的方法。

        示例中的这种情况被称为失/贫血模型,即只有属性而失去了与其属性相关行为的方法。

        然后再来看我们register方法的入参,这里传入的是2个String,我们在传入后还对这两个入参做了参数校验。这可能引发一些问题,首先是当我们调用方法时可能出现参数设置反了的情况,就像下面这样,虽然很低级但确实存在发生的可能(至少我本人和身边同事都遇到过,毕竟编译器只能帮我们校验参数类型)

    // register(name,phone)
    RegistrationServiceImpl.register("187xxx","CJ");

        假如后面业务拓展了,又需要实现按姓名和身份证或者其他方式等,这两个参数还是String类型,重载无法帮我们区分这两个方法,我们就只能通过修改方法名(专业术语叫方法签名)来实现区分。

    public User registerByPhone(String name, String phone)

    public User registerByIdCard(String name, String IdCard)

    public User registerByPhoneAndIdCard(String phone, String IdCard)

        在一个稳定运行的系统里修改已使用多时的方法名无疑给自己找麻烦,首先本系统中使用register方法的调用处全部需要修改,如果别的工程以jar包或者接口的形式引用了这个方法,将会产生蝴蝶效应,波及甚广。

        再就是参数校验,如果出现上文中的情况,出现了多种不同的注册模式,那么每个方法中都需要类似下面这样的校验逻辑,这必然会产生大量的重复代码,如果出现校验逻辑变动,又会导致多个方法的修改。

        // 参数校验
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }

        一种解决方案是将校验逻辑工具化,例如下文这样

class ValidationUtil{
    boolean isValidPhone(String phone){
        // ...
    }
    boolean isValidIdCard(String idCard){
        // ...
    }
}

public User registerByPhoneAndIdCard(String phone,String idCard){
    ValidationUtil.isValidPhone(phone);
    ValidationUtil.isValidIdCard(idCard);
}

        看起来似乎是个不错的选择,但却不是最佳实践,首先随着参数类型的增加,工具类中的校验逻辑也会不断膨胀,我们的业务方法中也需要显性的调用这些校验逻辑,又会造成register方法的膨胀。另外,业务方法中调用校验逻辑如果失败则需要抛出异常,这容易导致参数校验异常与业务异常混合起来,不太合理。

        整理一下我们目前为止可以预见到的一些问题,对我们的方法设计提出了以下要求:

        (1)明确方法中的参数类型,能区分每个参数,并且最好能自带校验逻辑

        (2)参数校验逻辑内聚,方便复用且能与业务异常解耦

        为了解决这个问题,我们可以将手机号定义为一个自定义的类PhoneNumber,这样方法中的2个参数就可以区分开来了,然后我们再将参数校验逻辑给封装到这个自定义的类中,在构建对象时便进行参数校验,这样我们的业务方法接收到的参数必然是已经参数校验过了的对象,自然而然地将参数校验异常与业务逻辑异常实现了区分

        经过改在后的代码如下,这样通过自定义类型既区分了每个方法中的参数,也使得该方法可以通过参数类型实现重载。另外不同的参数校验分别内聚到了对应自定义的类中,将修改范围最小化,且每个参数的校验逻辑在自定义对象创建时就完成了,业务方法完全不用担心参数校验的问题,内部可以专注于业务逻辑的实现。

public class PhoneNumber {
    private final String number;
    private final String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
    
    public String getNumber() {
        return number;
    }

    // 仅存在有参构造器,创建对象时即进行参数校验
    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    private boolean isValid(String number) {
        return number.matches(pattern);
    }

}

public class User {
    Long userId;
    String name;
    PhoneNumber phone; // 号码由String类型变为了自定义类型
    Long repId;
}


public class RegistrationServiceImpl implements RegistrationService {

    // 销售对象DAO
    private SalesRepRepository salesRepRepo;
    // 用户对象DAO
    private UserRepository userRepo;

    public User register(String name, PhoneNumber phone) {
    
        // 获取手机号归属地编号和运营商编号,然后通过编号找到区域内的SalesRep
        String areaCode = getAreaCode(phone);
        String operatorCode = getOperatorCode(phone);
        SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);

        // 创建用户并入库
        User user = new User();
        user.name = name;
        user.phone = phone;

        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }
    
    private String getAreaCode(PhoneNumber phone) {
        //...
    }
    
    private String getOperatorCode(PhoneNumber phone) {
       //...
    }
}

        PhoneNumber类不仅具备了属性,还拥有了与其属性相关联的行为,这被称为是充血模型,通过这个例子我们可以发现充血模型可以有效的将对象的行为内聚起来,当涉及到该对象的行为变动时我们也可以将修改范围控制在该类中。

        像PhoneNumber这种类型的自定义对象在DDD中被称为是值对象。值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性;如果值对象是属性集合,则把它设计为 Class 类,Class 将具有整体概念的多个属性归集到属性集合,这样的值对象没有 ID,会被实体整体引用。

        参数类型的问题说完了,我们再来看看业务方法的内部逻辑,这里有一块内容是获取归属地和运营商,其目的是为了查找出对应的销售组,用来构建User对象。但是我们思考一下如果将注册这个行为视为一个领域,那么对他最简单的描述就应该是“拿到用户信息并存储起来”,这里的获取归属地和运营商只是为了获取用于注册用户信息的销售的数据,重点是销售而不是获取归属地和运营商,换句话说只要能获取到销售那么这两个行为是可以被剔除出注册这个领域的。

       那么如何改造这个逻辑呢?

        第一种思路是调整方法入参,因为这两个参数都是根据手机号的附加信息查询的,这里可以绕过中间的介质简化为直接传入手机号来查询销售组。当然这是建立在我们可以修改findRep方法的基础上的,如果该方法是例如外部接口这样的无法修改的情况,我们可以在看第二种方案。

    // 改造前
    SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);

    // 改造后
    SalesRep rep = salesRepRepo.findRep(PhoneNumber);

        第二种思路是将查询销售组内聚到PhoneNumber中,之所以这样涉及是因为归属地和运营商都是手机号的相关属性,将该方法内聚到该类中在抽象上是合理的。

public class PhoneNumber {

    private final String number;
    private final String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
    
    public String getNumber() {
        return number;
    }

    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    private boolean isValid(String number) {
        return number.matches(pattern);
    }
    
    public String getAreaCode() {
        //...
    }
    
    public String getOperatorCode(PhoneNumber phone) {
       //...
    }
}

    public User register(String name, PhoneNumber phone) {
    
        // 获取用户信息
        SalesRep rep = salesRepRepo.findRep(phone.getAreaCode(), phone.getOperatorCode());

        // 存储用户信息
        User user = new User();
        user.name = name;
        user.phone = phone;

        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

        这样一来,我们的注册逻辑就变得非常清晰了,只留下了“获取用户信息并注册”这两个最基本的操作,其余的操作都被内聚到了PhoneNumber中,这就是充血模型带来的好处。

        当然本例中即使将获取归属地和运营商的逻辑保留在register方法中也是可以的,但是当我们遇到复杂的业务逻辑时可能获取归属地和运营商又要调用三四层其他的接口或者行为,将导致业务方法不再纯粹,如此多的行为耦合在一起必然对这段代码的可维护性造成极大的破坏,随着需求的迭代最后变成了谁都无法准确理清其中逻辑的“屎山”。

二、防腐层与资源库

        现在我们将上文中的需求升级一下

        1、对手机号进行实名校验,实名信息通过调用外部接口获得
        2、根据外部服务返回的实名信息,按照一定逻辑计算出用户标签,记录在用户账号中。
        3、根据用户标签为该用户开通相应等级的新客福利。

public class RegistrationServiceImpl implements RegistrationService {
    // DAO为数据库操作具体实现
    private SalesRepMapper salesRepDAO;
    private UserMapper userDAO;
    private RewardMapper rewardDAO;
    private TelecomRealnameService telecomService;
    private RiskControlService riskControlService;

    public UserDO register(String name, PhoneNumber phone) {
        // 参数合法性校验已在PhoneNumber中处理
        // 调用外部接口的具体实现类进行参数一致性校验
        TelecomInfoDTO rnInfoDTO = telecomService.getRealnameInfo(phone.getNumber());
        if (!name.equals(rnInfoDTO.getName())) {
            throw new InvalidRealnameException();
        }
       
        // 计算用户标签
        String label = getLabel(rnInfoDTO);
        // 计算销售组
        String salesRepId = getSalesRepId(phone);
        
        // 构造User对象和Reward对象
        String idCard = rnInfoDTO.getIdCard();
        // DO为数据表映射类
        UserDO userDO = new UserDO(idCard, name, phone.getNumber(), label, salesRepId);
        RewardDO rewardDO = RewardDO(idCard, label);
        
        // 检查风控
        if(!riskControlService.check(idCard, label)) {
            userDO.setNew(true);
            rewardDO.setAvailable(false);
        }else {
            userDO.setNew(false);
            rewardDO.setAvailable(true);
        }
        
        // 通过DAO直接与数据库交互
        rewardDAO.insert(rewardDO);
        return userDAO.insert(userDO);
    }
    
    private String getLabel(TelecomInfoDTO dto) {
        // 本地逻辑处理
    }
    
    private String getSalesRepId(PhoneNumber phone) {
        // 数据库直接查询销售信息
        SalesRepDO repDO = salesRepDAO.select(phone.getAreaCode(), phone.getOperatorCode());
        if (repDO != null) {
            return repDO.getRepId();
        }
        return null;
    }
}

        首先是对外部依赖的耦合,比如telecomService.getRealnameInfo这个具体实现调用外部接口,如果外部接口产生了变化,又或者我们需要替换接口对接方,那么这块就需要进行调整。为了缩小调整范围,我们可以使用接口来进行抽象。

public interface RealnameService {
    RealnameInfo get(PhoneNumber phone);
}

public class TelecomRealnameService implements RealnameService {
    @Override
    public RealnameInfo get(PhoneNumber phone){
        // 调用的具体的接口实现,可以对接不同的第三方
        // 返回结果封装为RealnameInfo
    }
}


// 改造前
TelecomInfoDTO rnInfoDTO = telecomService.getRealnameInfo(phone.getNumber());
if (!name.equals(rnInfoDTO.getName())) {
   throw new InvalidRealnameException();
}

// 改造后
RealnameInfo realnameInfo = realnameService.get(phone);
realnameInfo.check(name);

        通过调整,具体实现的方式我们可以通过配置来进行注入,完成了抽象与具体实现的分离,无论是参数调整还是替换实现都只需要调整具体实现或者配置文件即可。并且实名信息可以通过自定义类型RealnameInfo配合充血模型进一步的内聚。

        这里的RealnameService及其具体实现达到了将业务逻辑与外部隔离开来的效果,在DDD中的专业术语叫做防腐层,防止外部依赖(所有不属于当前领域的服务,包括数据库、外部接口等)的变化影响到我们的当前领域。

        再来看代码中显性的使用了DO(数据表映射类)与DAO(数据库操作的具体实现),但我们的业务逻辑应该只面向领域对象(领域对象不代表救赎数据库中表的映射,他可能是多张表的联合映射构建的对象),不用关心是否使用数据库和使用了哪种数据库,更不用关心表中的字段等。

        总结起来一句话,上层的业务实现不需要关注下层的具体实现。

        为了达到这个效果,我们首先定义实体User类,这个类用于描述系统中客户的信息与行为,并使用充血模型将行为内聚起来。其中我们还定义了一个数据访问的抽象层salesRepRepository,这是因为我们的实体同样不应该关心数据操作的具体实现,是从redis获取还是从mysql获取都不重要,我们关心的只有操作的结果。

// User Entity
public class User {
    // 用户id,DP
    private UserId userId;
    // 用户手机号,DP
    private PhoneNumber phone;
    // 用户标签,DP
    private Label label;
    // 绑定销售组ID,DP
    private SalesRepId salesRepId;
    
    private Boolean fresh = false;
    // 数据访问的抽象层
    private SalesRepRepository salesRepRepository;
    
    // 构造方法
    public User(RealnameInfo info, name, PhoneNumber phone) {
        // 参数一致性校验,若校验失败,则check内抛出异常(DP的优点)
        info.check(name);
        initId(info);
        labelledAs(info);
        // 查询
        SalesRep salesRep = salesRepRepository.find(phone);
        this.salesRepId = salesRep.getRepId();
    }
    
    // 对this.userId赋值
    private void initId(RealnameInfo info) {
    
    }
    
    // 对this.label赋值
    private void labelledAs(RealnameInfo info) {
        // 本地处理逻辑
    }
    
    public void fresh() {
        this.fresh = true;
    }
}

        通过UserRepository抽象出对User实体的数据操作,然后通过其实现类来完成具体的操作,这里就可以依赖与数据库相关操作的各种具体实现了,无论是与redis还是别的什么数据库操作的实现都可以在这里完成。


public interface UserRepository {
    User find(UserId id);
    User find(PhoneNumber phone);
    User save(User user);
}

public class UserRepositoryImpl implements UserRepository {

    private UserMapper userDAO;

    private UserBuilder userBuilder;
    
    @Override
    public User find(UserId id) {
        UserDO userDO = userDAO.selectById(id.value());
        return userBuilder.parseUser(userDO);
    }

    @Override
    public User find(PhoneNumber phone) {
        UserDO userDO = userDAO.selectByPhone(phone.getNumber());
        return userBuilder.parseUser(userDO);
    }

    @Override
    public User save(User user) {
        UserDO userDO = userBuilder.fromUser(user);
        if (userDO.getId() == null) {
            userDAO.insert(userDO);
        } else {
            userDAO.update(userDO);
        }
        return userBuilder.parseUser(userDO);
    }
}

        经过上面的调整,我们的注册方法被简化成了如下模样,此时已经与外部依赖完全解耦,当外部依赖产生变化我们只需要去修改具体实现类即可,从而保证了业务逻辑的文档。

public class RegistrationServiceImpl implements RegistrationService {

    private UserRepository userRepository;
    private RewardRepository rewardRepository;
    private RealnameService realnameService;
    private RiskControlService riskControlService;

    public UserDO register(String name, PhoneNumber phone) {
        // 查询实名信息
        RealnameInfo realnameInfo = realnameService.get(phone);

        // 构造对象
        User user = new User(realnameInfo, phone);
        Reward reward = Reward(user);
        
        // 检查风控
        if(!riskControlService.check(user)) {
            user.fresh();
            reward.inavailable();
        }
        
        // 存储信息
        rewardRepository.save(reward);
        return UserRepository.save(user);
    }
}

        上文中Repository在DDD中被称为是资源库,资源库可以理解成 DAO,但它比 DAO 更宽泛,存储的手段可以是多样化的,常见的无非是数据库、分布式缓存、本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。

        另外一个概念就是User类在DDD中被称为实体,注意和上文中介绍的值对象的区别,实体是有状态的,而值对象是无状态的,我个人的理解是值对象更多的是将我们原本的属性丰富为了类,而实体则是由多个值对象组成的属性和行为更丰富的类。

        最后我们再回到业务逻辑中,在检查完风控后存在这样一段逻辑,涉及到给新用户发奖的逻辑,回顾一下我们上文中的内容,业务逻辑应该越简洁越好,这两个操作实际上属于判断用户是否为新用户的衍生行为,放在这里有些不太合适。

        // 检查风控
        if(!riskControlService.check(user)) {
            user.fresh();
            reward.inavailable();
        }

        我们梳理下注册逻辑,可以简化为获取用户信息、检查并更新用户信息、存储用户信息这三个步骤,类似发奖这样的操作就可以被包含在检查并更新用户信息的衍生行为中。

public interface CheckUserService(User user) {
    void check(user);
}

public class CheckAndUpdateUserServiceImpl(User user) {
    private RiskControlService riskControlService;
    private RewardRepository rewardRepository;
    
    @Override
    public void check(User user) {
        rewardCheck(user);
        // ...
        // 可能存在的其他逻辑
    }
    
    private void rewardCheck(User user) {
        Reward reward = Reward(user);
        // 检查风控
        if(!riskControlService.check(user)) {
            user.fresh();
            reward.inavailable();
        }
        rewardRepository.save(reward);
    }
}

public class RegistrationServiceImpl implements RegistrationService {

    private UserRepository userRepository;
    private RealnameService realnameService;
    private CheckUserService checkUserService;

    public UserDO register(String name, PhoneNumber phone) {
        // 查询信息
        RealnameInfo realnameInfo = realnameService.get(phone);

        // 构造对象
        User user = new User(realnameInfo, phone);
        
        // 检查并更新对象
        checkUserService.check(user);
        
        // 存储信息
        return userRepository.save(user);
    }
}

        通过如上调整,我们的业务方法就只剩下了三个非常简明易懂的步骤,后续即使需求发生变化也可以把改动范围尽量虽小,最小化对业务方法的影响。

        像 rewardCheck可能修改user或reward这些实体的状态,这样的操作在DDD中被称为领域服务。

        到目前为止我们介绍了DDD中的值对象、实体、资源库与防腐层,下面我们换个例子来介绍下聚合与聚合根。

       

三、聚合与聚合根

        以通过手机号注销微信账号为例,这就涉及到是否要删除微信钱包,如果不删除,假如该号码被用户弃用,之后被运营商发给另一个用户,新用户再用这个手机号注册微信就会发现已经绑定了他人的微信钱包。如果需要解绑,又会涉及到是否要解绑银行卡,如果这张卡还作为亲属卡绑定了他人的钱包,此时解绑又是否会影响他人的使用?

         这个例子我们可以发现对一个对象的修改可能会涉及到大量其他关联对象的状态,如何保持这些对象的状态保持一致是个很复杂的问题。

        在DDD中使用聚合来描述这种存在引用关系的对象的集合,其目的便是屏蔽掉内部对象间复杂的关联关系,只对外暴露一个统一的接口。对于聚合我们只要关注根对象(聚合根)和边界这两个属性,根对象是聚合中唯一能被外部引用的对象,换句话说,聚合对外暴露的接口只允许操作根对象,边界则是用来判断哪个对象能放入当前聚合的条件。

        现在我们来看看上面注销账号的例子,账号关联了钱包,钱包关联了银行卡,因此这三个对象自然是可以聚合在注销账号这个业务领域中。此时的根对象很自然便是账号,而边界的定义则是当前对象在注销时是否被引用,例如聊天消息自然是不包括在内的,于是这样一个聚合便设计好了。

         到这里我们DDD种的聚合、聚合根与边界便有了初步的理解,篇幅原因这段的代码实现我们暂时留到下篇文章再做介绍。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
领域驱动设计(Domain-Driven Design,简称DDD)是一种软件开发方法论,旨在解决复杂业务领域的软件开发问题。它强调将业务领域的知识和概念直接融入到软件设计和开发中,以实现更好的业务价值和可维护性。 在C#中实施DDD时,可以采用以下几个关键概念和技术: 1. 领域模型(Domain Model):领域模型是DDD的核心概念,它是对业务领域的抽象和建模。在C#中,可以使用类和对象来表示领域模型,通过定义实体(Entity)、值对象(Value Object)、聚合根(Aggregate Root)等概念来描述业务领域中的实体和关系。 2. 领域驱动设计的分层架构:DDD通常采用分层架构来组织代码。常见的分层包括用户界面层(UI)、应用服务层(Application Service)、领域层(Domain Layer)、基础设施层(Infrastructure Layer)等。每一层都有不同的职责和关注点,通过良好的分层设计可以实现代码的可维护性和可测试性。 3. 聚合根和聚合:聚合根是DDD中的一个重要概念,它是一组相关对象的根实体,通过聚合根可以保证一致性和边界。在C#中,可以使用类来表示聚合根,通过定义聚合根的行为和关联关系来实现业务逻辑。 4. 领域事件(Domain Event):领域事件是DDD中用于描述领域中发生的重要事情的概念。在C#中,可以使用事件(Event)或委托(Delegate)来表示领域事件,并通过事件驱动的方式来处理领域事件。 5. 仓储(Repository):仓储是用于持久化和检索领域对象的接口或类。在C#中,可以使用接口和实现类来定义仓储,并通过依赖注入等方式将仓储注入到其他类中。 6. 领域服务(Domain Service):领域服务是一种用于处理领域逻辑的服务。在C#中,可以使用类和方法来表示领域服务,并将其注入到其他类中使用。 以上是DDD领域驱动设计在C#中的一些关键概念和技术。通过合理运用这些概念和技术,可以更好地实现复杂业务领域的软件开发。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值