设计模式之美笔记2

记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步

实战案例:虚拟钱包

1. 业务背景

很多有支付、购买功能的应用都支持钱包的功能,应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。

一般,每个虚拟钱包账户都会对应用户的一个真实的支付账户,可能是银行卡账户,或者支付宝、微信钱包等三方支付账户。暂时限定只支持充值、提现、支付、查询账户余额、查询交易流水五个核心功能。

具体业务流程

1. 充值

用户通过三方支付渠道,把自己银行卡账户的钱,充值到虚拟钱包账号中。可分解为三个主要的操作流程:

  1. 从用户的银行卡账户转账到应用的公共银行卡账户
  2. 将用户的充值金额加到虚拟钱包余额中
  3. 记录刚刚的这笔交易流水
    在这里插入图片描述
2. 支付

用户用钱包内的余额,支付购买应用内的商品。实际上,就是个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户,然后出发真正的银行账户转账操作,从应用的公共银行账户转钱到商家的银行账户。此外,也要记录这笔支付的交易流水信息。
在这里插入图片描述

3. 提现

除了充值、支付外,用户可将虚拟钱包的余额,提现到自己的银行卡。实际就是扣减用户虚拟钱包中的余额,并触发真正的银行转账操作,从应用的公共银行账户转账到用户的银行账户。同样也要记录这笔提现的交易流水信息。
在这里插入图片描述

4. 查询余额

查询余额比较简单,看下虚拟钱包中的余额数字即可

5. 查询交易流水

只支持三种类型的交易流水:充值、支付、提现。会记录相应的交易信息。在需要查询时,将之前记录的交易流水,按照时间、类型等条件过滤后,展示即可。

2. 钱包系统的设计思路

根据上述业务流程和数据流转图,将业务分为两部分,一部分单纯跟应用的虚拟钱包账户打交道,一部分跟银行账户打交道。将钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。
在这里插入图片描述

接下来只关注虚拟钱包系统的设计和实现

要支持钱包的五个核心功能,虚拟钱包系统需要对应实现哪些操作。交易流水的记录和查询较特殊,之后再看。
在这里插入图片描述

操作很简单,就是余额的加加减减。充值、提现、查询余额功能,只涉及一个账户余额的加减,而支付涉及两个账户的余额加减:一个账户减余额,一个账户加余额。

交易流水该如何记录和查询?

交易流水需要包含的信息:

  • 交易流水ID
  • 交易时间
  • 交易金额
  • 交易类型:充值、提现、支付
  • 入账钱包账号
  • 出账钱包账号

为什么两个账号?为了兼容支付场景。为了保证数据一致性。对于业务来说,只需要保证最终一致性即可。

对于支付这种类似转账的操作,操作两个钱包的账户余额之前,先记录交易流水,并标记为“待执行”,当两个钱包的加减金额都完成后,再将交易流水标记为“成功”。否则,只要任意一个失败,都将状态标记为“失败”。通过后台task,拉取状态为“失败”或长时间处于“待执行”的交易记录,重新执行或人工处理。

是否应该在虚拟钱包系统的交易流水中记录充值、提现、支付这三种类型

虚拟钱包只需支持余额的加加减减,不涉及复杂业务概念,职责单一、功能通用。如果耦合太多业务概念,影响通用性,导致系统越做越复杂。

那用户查流水时,如何展示每条交易流水的交易类型

系统设计角度,不应该在虚拟钱包系统的交易流水中记录交易类型;产品需求角度,必须记录交易类型,如何解决该矛盾?

通过记录两条交易流水信息的方式解决。整个钱包系统分为两个子系统,对于上层钱包系统,可感知充值、支付、提现等业务概念,所以在钱包系统上层额外记录一条包含交易类型的交易流水信息。底层虚拟钱包系统不记录交易类型。
在这里插入图片描述
在这里插入图片描述

如上图,通过查询上层钱包系统的交易流水信息,来满足用户查询交易流水的功能需求,而虚拟钱包中的交易流水只用来解决数据一致性问题。

3. 贫血模型MVC实现

controller层

public class VirtualWalletController{
	//通过构造函数或者IOC框架注入
	private VirtualWalletService virtualWalletService;

	public BigDecimal getBalance(Long walletId){...}//查询余额
	public void debit(Long walletId,BigDecimal amount){...}//出账
	public void credit(Long walletId,BigDecimal amount){...}//入账
	public void transfer(Long fromWalletId,Long toWalletId, BigDecimal amount){...}//转账
}

service层代码如下,省略了一些不重要的校验代码,如对amount是否小于0、钱包是否存在的校验等。

public class VirtualWalletBo{
	// 省略getter、setter和constructor
	private Long id;
	private Long createTime;
	private BigDecimal balance;
}

public class VirtualWalletService{
	//通过构造函数或IOC框架注入
	private VirtualWalletDao walletDao;
	private VirtualWalletTransactionDao transactionDao;

	public VirtualWalletBo getVirtualWallet(Long walletId){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		VirtualWalletBo walletBo = convert(walletEntity);
		return walletBo;
	}

	public BigDecimal getBalance(Long walletId){
		return virtualWalletDao.getBalance(walletId);
	}

	public void debit(Long walletId,BigDecimal amount){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		BigDecimal balance = walletEntity.getBalance();
		if(balance.compareTo(amount)<0){
			throw new NoSufficientBalanceException(...);
		}
		walletDao.updateBalance(walletId,balance.substract(amount));
	}

	public void credit(Long walletId,BigDecimal amount){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		BigDecimal balance = walletEntity.getBalance();
		walletDao.updateBalance(walletId,balance.add(amount));
	}

	public void transfer(Long fromWalletId,Long toWalletId,BigDecimal amount){
		VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
		transactionEntity.setAmount(amount);
		transactionEntity.setCreateTime(System.currentTimeMills);
		transactionEntity.setFromWalletId(fromWalletId);
		transactionEntity.setToWalletId(toWalletId);
		transactionEntity.setStatus(Status.TO_BE_EXECUTED);

		Long transactionId = transactionDao.saveTransaction(transactionEntity);
		try{
			debit(fromWalletId,amount);
			credit(toWalletId,amount);
		}catch(InsufficientBalanceException e){
			transactionDao.updateStatus(transactionId,Status.CLOSED);
			...throw exception e...
		}catch(Exception e){
			transactionDao.updateStatus(transactionId,Status.FAILED);
			...throw exception e...
		}
		transactionDao.updateStatus(transactionId,Status.EXECUTED);
	}
}

4. 充血模型的DDD开发模式实现

只有service层不同,把虚拟钱包VirtualWallet类设计成一个充血的Domain领域模型,并将原来在service类的部分业务逻辑移到VirtualWallet类中,让service类的实现依赖VirtualWallet类。

public class VirtualWallet{
	//Domain 领域模型
	private Long id;
	private Long createTime = System.currentTimeMills;
	private BigDecimal balance = BigDecimal.ZERO;

	public VirtualWallet(Long preAllocatedId){
		this.id = preAllocatedId;
	}

	public BigDecimal balance(){
		return this.balance;
	}

	public void debit(BigDecimal amount){
		if(this.balance.compareTo(amount)<0){
			throw new NoSufficientBalanceException(...);
		}
		this.balance.substract(amount);
	}

	public void credit(BigDecimal amount){
		if(amount.compareTo(BigDecimal.ZERO)<0){
			throw new InvalidAmountException(...);
		}
		this.balance.add(amount);
	}
}

public class VirtualWalletService{
	//通过构造函数或IOC框架注入
	private VirtualWalletDao walletDao;
	private VirtualWalletTransactionDao transactionDao;

	public VirtualWalletBo getVirtualWallet(Long walletId){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		VirtualWallet wallet = convert(walletEntity);
		return wallet;
	}

	public BigDecimal getBalance(Long walletId){
		return virtualWalletDao.getBalance(walletId);
	}

	public void debit(Long walletId,BigDecimal amount){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		VirtualWallet wallet = convert(walletEntity);
		wallet.debit(amount);
		walletDao.updateBalance(walletId,wallet.balance());
	}

	public void credit(Long walletId,BigDecimal amount){
		VirtualWalletEntity walletEntity = walletDao.getWalletEntity(walletId);
		VirtualWallet wallet = convert(walletEntity);
		wallet.credit(amount);
		walletDao.updateBalance(walletId,wallet.balance());
	}

	public void transfer(Long fromWalletId,Long toWalletId,BigDecimal amount){
		VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
		transactionEntity.setAmount(amount);
		transactionEntity.setCreateTime(System.currentTimeMills);
		transactionEntity.setFromWalletId(fromWalletId);
		transactionEntity.setToWalletId(toWalletId);
		transactionEntity.setStatus(Status.TO_BE_EXECUTED);

		Long transactionId = transactionDao.saveTransaction(transactionEntity);
		try{
			debit(fromWalletId,amount);
			credit(toWalletId,amount);
		}catch(InsufficientBalanceException e){
			transactionDao.updateStatus(transactionId,Status.CLOSED);
			...throw exception e...
		}catch(Exception e){
			transactionDao.updateStatus(transactionId,Status.FAILED);
			...throw exception e...
		}
		transactionDao.updateStatus(transactionId,Status.EXECUTED);
	}
}

如果业务逻辑更加复杂,充血模型的优势就显示出来了。如要支持透支一定额度和冻结部分余额的功能。

public class VirtualWallet{
	//Domain 领域模型
	private Long id;
	private Long createTime = System.currentTimeMills;
	private BigDecimal balance = BigDecimal.ZERO;
	private boolean isAllowedOverdraft = true;//透支
	private BigDecimal overdraftAmount = BigDecimal.ZERO;
	private BigDecimal frozenAmount = BigDecimal.ZERO;

	public VirtualWallet(Long preAllocatedId){
		this.id = preAllocatedId;
	}

	public void freeze(BigDecimal amount){...}
	public void unfreeze(BigDecimal amount){...}
	public void increaseOverdraftAmount(BigDecimal amount){...}
	public void decreaseOverdraftAmount(BigDecimal amount){...}
	public void closeOverdraft(){...}
	public void openOverdraft(){...}

	public BigDecimal balance(){
		return this.balance;
	}

	public BigDecimal getAvaliableBalance(){
		BigDecimal totalAvaliableBalance = this.balance.substract(this.frozenAmount);
		if(isAllowedOverdraft){
			totalAvaliableBalance += this.overdraftAmount;
		}
		return totalAvaliableBalance;
	}

	public void debit(BigDecimal amount){
		BigDecimal totalAvaliableBalance = this.balance.substract(this.frozenAmount);
		if(totalAvaliableBalance.compareTo(amount)<0){
			throw new NoSufficientBalanceException(...);
		}
		this.balance.substract(amount);
	}

	public void credit(BigDecimal amount){
		if(amount.compareTo(BigDecimal.ZERO)<0){
			throw new InvalidAmountException(...);
		}
		this.balance.add(amount);
	}
}

当然,随着功能的演进,可增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWalletId字段)自动生成的逻辑(不是通过构造函数经外部传入id,而是分布式id生成算法自动生成id)等。业务越复杂,就很值得设计为充血模型。

5. 两个问题

问题1

基于充血模型的DDD开发模式,将业务逻辑移到domain,service类变得很薄,能否直接将service类去掉?service类的职责是什么?哪些逻辑放到service中的?

service类主要有以下几个职责:

  1. service类与dao层交互。我们需要保持领域模型domain的独立性,不和其他层的代码耦合,让domain更加可复用。
  2. service类负责跨domain的业务逻辑聚合功能。如VirtualWalletService类的transfer()转账行数会涉及两个钱包的操作,无法放到VirtualWallet类中。将转账业务放到VIrtualWalletService类中,随着功能的演进,转账业务更复杂后,可以将转账业务抽取出来,设计为一个独立的domain。
  3. service类负责一些非功能性和与三方系统交互的工作。如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等,都可以放到service类中。
问题2

基于充血模型的DDD开发模式,service层被改造为充血模型,但controller和dao层都没改,是否有必要改造?

没必要,这两层包含的业务逻辑并不多。

controller的vo,实际上是一种DTO(Data Transfer Object,数据传输对象)。主要作为接口的数据传输承载体,将数据发送给其他系统,理应不包含业务逻辑,只包含数据。

实战2:如何对接口鉴权这样一个功能开发做面向对象分析

1. 案例介绍和难点分析

假设正在参与开发一个微服务,通过http协议暴露接口给其他系统调用,也就是其他系统通过URL调用微服务的接口。一天,leader给你说,“为保证接口调用的安全性,希望设计实现一个接口调用鉴权功能,只有经过认证后的系统才能调我们的接口,没有认证过的调用会被拒绝,希望你负责这个任务的开发,争取尽快上线”。

leader很忙,说完就走了,你该如何做?是否感觉无从下手?

原因:

  1. 需求不明确 需求过于笼统,不够具体,离落地编码还有一定距离。当然,真实的开发中,需求几乎都是不很明确。需求分析,首先做的就是将笼统的需求细化到可执行。通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些不用考虑的。
  2. 缺少锻炼 相比单纯的业务CRUD,鉴权更有难度。作为和具体业务无关的功能,完全可以把它开发为一个独立的框架,集成到多个业务系统,对代码质量要求更高。开发通用的框架,对需求分析能力、设计能力、编码能力额逻辑思维能力的要求,都较高,平时简单的业务开发,这种锻炼机会不多,因此无从下手,没有思路。

2. 对案例需求分析

先要想到个MVP,最简单的可实现的demo

1. 第一轮基础分析

最简单的就是通过用户名、密码认证。给每个调用方,派发一个应用名appId,和一个密码(或者叫密钥)。调用方每次对接口请求时,都携带自己的appId和密钥。微服务接收到请求后,解析appId和密码,跟存储在服务端的appId和密码对比,如果一致,说明认证成功,允许调用,否则拒绝请求。

2. 第二轮优化

这种每次都是明文传输密码,不安全。借助加密算法如SHA,加密后传到服务端验证,也不安全,如重放攻击。

如何解决?借助OAuth的验证思路。调用方将请求接口的URL跟appId和密码拼接到一起,再加密,生成token。调用方请求时,将token和appId跟随url一起传到服务端,服务端解析后,根据appId从数据库中获取密码,通过同样的token生成算法,生成新token,跟调用方传来的token对比。一致,允许请求,否则拒绝。

3. 第三轮优化

这种仍不太安全,url拼接appId、密码生成的token都是固定的,未认证系统截获url、token和appId后,还能重放攻击,调用这个url对应的接口。

我们需要引入一个随机变量,如时间戳,让每次生成的token都不一样。微服务端收到数据后,验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(如一分钟),如果超时,判定token过期,拒绝请求;没有超时,用同样的token生成算法,在服务端生成新token,对比是否一致。

4. 第四轮优化

攻防之间,本就没有绝对的安全,只是提高攻击的成本。该方案足够简单,也不过度影响接口本身的性能如响应时间。权衡安全性、开发成本、对系统性能的影响,该方案较合理。

其实还有个细节,就是如何在服务端存储每个授权调用方的appId和密码。鉴权这种非业务功能,最好不要和具体的第三方系统有过度的耦合,最好能灵活的支持不同存储方式,如zookeeper、本地配置文件、自研配置中心、mysql、redis等。最好留下扩展点,保证系统有足够的灵活性和扩展性。

5. 最终确定需求

跟leader描述清楚细化的需求。

调用方在进行接口请求的时候,将url、appId、密码、时间戳拼接在一起,通过加密算法生成token,并将token、appId、时间戳拼接到url中,一起发送到服务端。

服务端接收到调用方请求后,解析出token、appId和时间戳。首先检查时间戳跟当前时间,是否在token失效时间窗口内,如果超过失效时间,调用鉴权失败,拒绝调用请求。

如果没有过期,从自己的存储中取出appId对应的密码,用同样的token生成算法,生成另一个token,与调用方传的token匹配,一致允许调用,否则拒绝调用

ps:可以和10X程序员一起服用,效果更好

3. 如何进行面向对象设计

面向对象分析产出的是详细的需求描述,面向对象设计的产出就是类。把设计环节细化后:

  • 划分职责进而识别有哪些类
  • 定义类及其属性和方法
  • 定义类与类之间的交互关系
  • 将类组装并提供执行入口
1. 划分职责进而识别有哪些类

识别类的方法,把需求描述中的名词罗列出来,作为可能的候选项,再筛选。

另一种,根据需求描述,把其中涉及到的功能点,一个个罗列出来,再看哪些功能点职责相近,操作同样的属性,可否归为同一个类。

逐句拆解鉴权的需求描述后得到的功能点:

  1. 把url、appId、密码、时间戳拼接为一个字符串
  2. 对字符串通过加密算法加密生成token
  3. 将token、appId、时间戳拼接到url,形成新的url
  4. 解析url得到token、appId和时间戳
  5. 从存储中取出appId和对应密码
  6. 根据时间戳判断token是否过期
  7. 验证两个token是否匹配

其中,1、2、6、7都跟token有关,负责token的生成、验证;3、4都在处理url,负责url的拼接和解析;5操作appId和密码。粗略得到三个核心类:AuthToken、Url、CredentialStorage。

这是初步的划分,编程本身就是不断迭代优化的过程。先有个基础的设计方案。

真正的大型软件开发、复杂的需求开发,涉及到的功能点较多,对应类也较多,需要先进行模块划分,将需求简单划分为几个小的功能模块,再在模块内部,应用刚才的方法,进行面向对象设计。套路类似。

2. 定义类及其属性和方法

AuthToken类的功能点有4个

  • 把url、appId、密码、时间戳拼接为一个字符串
  • 对字符串通过加密算法加密生成token
  • 根据时间戳判断token是否过期
  • 验证两个token是否匹配

对于方法的识别,一般都是识别出需求描述的动词,作为候选的方法,再筛选。把功能点涉及到的名词,作为候选属性,同样筛选。

AuthToken类
属性:
private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1*60*1000;
private String token;
private long createTime;
private long expiredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;
构造函数:
public AuthToken(String token,long createTime);
public AuthToken(String token,long createTime,long expiredTimeInterval);
方法:
public static AuthToken create(String baseUrl,long createTime,Map<String,String> params);
public String getToken();
public boolean isExpired();
public boolean match(AuthToken authToken);

有三个细节:

  1. 并不是所有出现的名词都被定义为类的属性,如url、appId、密码、时间戳,我们把它作为方法的参数
  2. 还需要挖掘一些没有出现在描述中的属性,如createTime,expireTimeInterval,用在isExpired()方法中,判定token是否过期
  3. 还给AuthToken类添加了一个功能点描述中没提到的方法getToken()

第一个细节说明,从业务模型上说,不应该属于这个类的属性和方法,不要放到这个类里,如url、appId信息。

第二三个细节说明,不能简单的依赖当下的需求,还要从整个业务模型触发,看应该具备哪些属性和方法。一方面保证类定义的完整性,另一方面也为未来的需求做准备。

url类的功能点有两个

  • 将token、appId、时间戳拼接到url,形成新的url
  • 解析url得到token、appId和时间戳

虽然需求描述中是url来指代接口请求,但接口请求不一定是url的形式,也可能是dubbo rpc等其他形式。为了让该类更加通用,命名更贴切,起名为ApiRequest。

ApiRequest类
属性:
private String baseUrl;
private String token;
private String appId;
private long timestamp;
构造函数:
public ApiRequest(String baseUrl,String token,String appId,long timestamp);
方法:
public static ApiRequest createFromFullUrl(String url);
public String getBaseUrl();
public String getToken();
public String getAppId();
public long getTimestamp();

而CredentialStorage类的相关的功能点有一个

从存储中取出appId和对应密码

该类很简单,为了做到抽象封装具体的存储方式,将其设计为接口,基于接口而非具体的实现编程

CredentialStorage接口
接口方法:
String getPasswordByAppId(String appId);
3. 定义类与类之间的交互关系

UML统一建模语言定义了六种类之间的关系:泛化、实现、关联、聚合、组合、依赖。

泛化generalization可以简单理解为java的继承关系。

实现realization一般指接口和实现类之间的关系。

聚合aggregation是一种包含关系,A类对象包含B类对象,B类对象的生命周期可以不依赖A类对象的生命周期,如课程与学生之间的关系。

组合composition也是一种包含关系,只是B类对象的生命周期依赖A类对象的生命周期,B不能单独存在,如鸟跟翅膀之间的关系。

关联association是一种弱关系,只要B类对象是A类的成员变量,B类和A类就是关联关系。

依赖dependency是一种比关联关系更弱的关系,包含关联关系。只要B类对象和A类对象有任何使用关系,都称为依赖关系。

从更贴近编程的角度,对类与类之间的关系做调整,只保留4个关系:泛化、实现、组合、依赖。组合关系替代了UML的组合、聚合、关联三个概念,只要B类对象是A类对象的成员变量,就称A类跟B类是组合关系。

那刚定义的三个类之间有哪些关系?只用到了实现关系,也就是CredentialStorage和MysqlCredentialStorage之间的关系。

4. 将类组装起来并提供执行入口

这个入口可能是main函数,也可能是一组给外部调用的Api接口,通过该入口,触发代码跑起来。

接口鉴权不是一个独立运行的系统,而是一个集成在系统上运行的组件,封装所有实现细节,设计一个顶层的ApiAuthencator接口类,暴露给外部调用者使用的API接口,作为触发执行鉴权逻辑的入口。

ApiAuthencator接口
接口方法:
void auth(String url);
void auth(ApiRequest apiRequest);

DefaultApiAuthencatorImpl实现类
属性:
private CredentialStorage credentailStorage;
构造方法:
public ApiAuthencator();
public ApiAuthencator(CredentialStorage credentialStorage);
方法:
void auth(String url);
void auth(ApiRequest apiRequest);
4. 如何面向对象编程

只需要将类图翻译为代码,只给出较为复杂的ApiAuthencator的实现

public interface ApiAuthencator{
	void auth(String url);
	void auth(ApiRequest apiRequest);
}

public class DefaultApiAuthencatorImpl implements ApiAuthencator{
	private CredentialStorage credentialStorage;

	public ApiAuthencator(){
		this.credentialStorage = new MysqlCredentialStorage();
	}

	public ApiAuthencator(CredentialStorage credentialStorage){
		this.credentialStorage = credentialStorage;
	}

	@Override
	public void auth(String url){
		ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
		auth(apiRequest);
	}

	@Override
	public void auth(ApiRequest apiRequest){
		String appId = apiRequest.getAppId();
		String token = apiRequest.getToken();
		long timestamp = apiRequest.getTimestamp();
		String originalUrl = apiRequest.getOriginalUrl();

		AuthToken clientAuthToken = new AuthToken(token,timestamp);
		if(clientAuthToken.isExpired()){
			throw new RuntimeException("token is expired");
		}

		String password = credentialStorage.getPasswordByAppId(appId);
		AuthToken serverAuthToken = AuthToken.generate(originalUrl,appId,password);
		if(!serverAuthToken.match(clientAuthToken)){
			throw new RuntimeException("token verfication failed");
		}
	}
}
5. 辩证思考和灵活运用

其实代码一般都是边写边重构、迭代,就像学驾照,驾校的流程很正规,按照流程顺利倒库,实际开车熟练后,都是根据经验和感觉。

遵循这套SOP,方法单个不超过五十行,我们平时都是将各种东西都塞到方法里,导致面向过程编程,非常臃肿。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值