命令模式+责任链模式。

搬移UNIX的命令

在操作系统的世界里,有两大阵营一直在PK着:*nix(包括UNIX和Linux)和Windows。从目前的统计数据来看,*nix在应用服务器领域占据相对优势,不过Windows也不甘示弱,国内某些小型银行已经在使用PC Server(安装Windows操作系统的服务器)集群来进行银行业务运算,而且稳定性、性能各方面的效果不错;而在个人桌面方面,Windows是占绝对优势的,大家应该基本上都在用这个操作系统,他的诸多优点这里就不多说了,我们今天就来解决一个习惯问题。

是不是经常把UNIX上的命令敲到Windows系统了?为了避免这种情况发生,可以把UNIX上的命令移植到Windows上,也就是Windos下的shell工具,有很多类似的工具,比如cygwin、GUN Bash等,这些都是非常完美的工具,我们今天的任务就是自己写一个这样的工具。

我们先说说UNIX下的命令,一条命令分为命令名、选项和操作数,例如命令“ls –l /usr”,其中,ls是命令名,l是选项,/usr是操作数,后两项都是可选项,根据实际情况而定。UNIX命令一定遵守以下几个规则:

  • 命令名为小写字母。
  • 命令名、选项、操作数之间以空格分隔,空格数量不受限制。
  • 选项之间可以组合使用,也可以单独拆分使用。
  • 选项以横杠(-)开头。

在UNIX世界中,我们最常用的就是ls这个命令,他用于显示目录或文件信息,下面我们先来看看这个命令。常用的有以下几条组合命令:

  • ls:简单列出一个目录下的文件。
  • ls –l:详细列出目录下的文件。
  • ls –a:列出目录下包含的隐藏文件,主要是点号(.)开头的文件。
  • ls –s:列出文件的大小。

除此之外,还有一些非常常用的组合命令,如“ls -la”、“ls -ls”等。Ls命令名确定了,但是其后连接的选项和操作数是不确定的。操作数我们不用关心他,每个命令必然有一个操作数,若没有则是当前的目录。问题的关键是选项,用哪个选项以及什么时候使用都是由用户决定的,也就是从设计上考虑。设计者需要完全解析所有的参数,需要很多个类来处理如此多的选项,客户输入一个参数,立刻返回一个结果。针对一个ls命令族,要求如下:

  • 每一个ls命令都有操作数,默认操作数为当前目录。
  • 选项不可重复,例如对于“ls –l –l -s”,解析出的选项应该只有两个:l选项和s选项。
  • 每个选你选哪个返回不同的结果,也就是说每个选项应该由不同的业务逻辑来处理。
  • 为提高扩展性,ls命令族内的运算应该是对外封闭的,减少外界访问ls命令族内部细节的可能性。

针对一个命令族的分析结果,我们可以使用什么模式?责任链模式!对,只要把一个参数传递到链首,就可以立刻获得一个结果,中间是如何传递的以及由哪个逻辑解析都不需要外界(高层)模块关心。

UNIX的命令有上百个,我们定义一个CommandName抽象类,所有的命令都继承于该类,他就是责任链模式的handler类,负责链表控制;每个命令族都有一个独立的抽象类,因为每个命令族都有其独特的个性,比如ls命令和df命令,其后可加的参数是不一样的,这就可以在抽象类AbstractLS中定义,而且他还有标示作用,标示旗下的实现类都是实现ls命令的,只是命令的选项不同;Context负责建立一条命令的链表,比如ls命令族、df命令族等,他组装出一个处理一个命令族的责任链,并返回首节点供高层模块调用,这是非常典型的责任链模式。

分析完毕一个具体的命令族,已经确定可以采用责任链模式,我们继续往下分析。UNIX命令非常多,敲一个命令返回一个结果,每个具体的命令可以由相关的命令族(也就是责任链)来解析,但是如此多的命令还是需要有一个派发的角色,输入一个命令,不管后台谁来解析,返回一个结果就成,这就要用到命令模式。命令模式负责协调各个命令正确的传递到各个责任链的首节点,这就是他的任务。

Chain是一个标识符,表示的就是我们上面分析的责任链,每一个具体的命令负责调用责任链的首节点,获得返回值,结束命令的执行。我们来看一下各个类的职责。

  • ClassUtils

ClassUtils是工具类,其主要职责是根据一个接口、父类查找到所有的子类。在不考虑效率的应用中,使用该类可以带来非常好的扩展性。

  • CommandVO

CommandVO是命令的值对象,他把一个命令解析为命令名、选项、操作数,例如“ls –l /usr”命令分别解析为getCommandName、getParam、getData三个方法的返回值。

  • CommandEnum

CommandEnum是枚举类型,是主要的命令配置文件。为什么需要枚举类型?这是JDK 1.5提供的一个非常好的功能,我们在程序中再讲解如何使用他。

所有的分析都已经完成了,我们来看看程序。我们先来看CommandName抽象类,如下所示。

public abstract class CommandName {
	private CommandName nextOperator;

	/**
	 * 处理
	 * 
	 * @param vo
	 * @return
	 */
	public final String handleMessage(CommandVo vo) {
		// 处理结果
		String result = "";
		// 判断是否是自己处理的参数
		if (vo.getParam().size() == 0
				|| vo.getParam().contains(this.getOperateParam())) {
			result = this.echo(vo);
		} else {
			if (this.nextOperator != null) {
				result = this.nextOperator.handleMessage(vo);
			} else {
				result = "命令无法执行";
			}
		}
		return result;
	}

	/**
	 * 设置剩余参数由谁来处理
	 * 
	 * @param _operator
	 */
	public void setNext(CommandName _operator) {
		this.nextOperator = _operator;
	}

	/**
	 * 每个处理者都要处理一个后缀参数
	 * 
	 * @return
	 */
	protected abstract String getOperateParam();

	/**
	 * 每个处理者都必须实现处理任务
	 * 
	 * @param vo
	 * @return
	 */
	protected abstract String echo(CommandVo vo);
}

很简单,就是责任链模式中的handler,也就是中控程序,控制一个链应该如何建立。我们再来看3个ls命令族,先看AbstractLS抽象类,如下所示。

public abstract class AbstractLS extends CommandName {
	// 默认参数
	public final static String DEFAULT_PARAM = "";
	// 参数a
	public final static String A_PARAM = "a";
	// 参数l
	public final static String L_PARAM = "l";
}

很惊讶,是吗?怎么是个空的抽象类?是的,确实是一个空类,只定义了3个参数名称,他有两个职责:

  • 标记ls命令族。
  • 个性化处理。

因为现在还没有思考清楚ls有什么个性(可以把命令的选项也认为是其个性化数据),所以先写个空类放在这里,以后想清楚了再填写上去,留下一些可扩展的类也许会给未来带来不可估量的优点。

我们再来看ls不带任何参数的命令处理,如下所示。

public class LS extends AbstractLS {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.DEFAULT_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		// 由于在ls()方法中直接写出命令,所以该地方只是简要的参数,读者可以自行完善formatData()方法
		return FileManager.ls(vo.formatData());
	}

}

太简单了,首先定义了自己能处理什么样的参数,即只能处理不带参数的ls命令,getOperateParam返回一个长度为零的字符串,就是说该类作为链上的一个节点,只处理没有参数的ls命令。echo方法是执行ls命令,通过调用操作系统相关的命令返回结果。我们再来看ls –l命令,如下所示。

public class LS_L extends AbstractLS {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.L_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		// 由于在ls()方法中直接写出命令,所以该地方只是简要的参数,读者可以自行完善formatData()方法
		return FileManager.ls_l(vo.formatData());
	}

}

该类只处理选项为“l”的命令,也非常简单。ls –a命令的处理与此类似,如下所示。

public class LS_A extends AbstractLS {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.A_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		// 由于在ls()方法中直接写出命令,所以该地方只是简要的参数,读者可以自行完善formatData()方法
		return FileManager.ls_a(vo.formatData());
	}

}

这3个实现类都关联到了FileManager,这个类有什么用呢?他是负责与操作系统交互的。要把UNIX的命令迁移到Windows上运行,就需要调用Windows的低层函数,实现起来较复杂,而且和我们要讲的内容没有太大关系,所以这里采用示例性代码代替,如下所示。

public class FileManager {
	private FileManager() {
	}

	/**
	 * ls命令
	 * 
	 * @param path
	 * @return
	 */
	public static String ls(String path) {
		return "file1\nfile2\nfile3\nfile4";
	}

	/**
	 * ls -l 命令
	 * 
	 * @param path
	 * @return
	 */
	public static String ls_l(String path) {
		String str = "drw-rw-rw root system 1024 2009-8-20 10:23 file1\n";
		str = str + "drw-rw-rw root system 1024 2009-8-20 10:23 file2\n";
		str = str + "drw-rw-rw root system 1024 2009-8-20 10:23 file3\n";
		return str;
	}

	/**
	 * ls -a命令
	 * 
	 * @param path
	 * @return
	 */
	public static String ls_a(String path) {
		String str = ".\n..\nfile1\nfile2\nfile3";
		return str;
	}
}

以上都是比较简单的方法,大家有兴趣可以自己实现一下,以下提供3种思路:

  • 通过java.io.File类自己封装出类似UNIX的返回格式。
  • 通过java.lang.Runtime类的exec方法执行dos的dir命令,产生类似的ls结果。
  • 通过JNI(Java Native Interface)来调用与操作系统有关的动态链接库,当然前提是需要自己写一个动态链接库文件。

3个具体的命令都已经解析完毕,我们再来看看如何建立一条处理链,由于建链的任务已经移植到抽象命令类,我们就先来看抽象类Command,如下所示。

public abstract class Command {
	/**
	 * 执行命令
	 * 
	 * @param vo
	 * @return
	 */
	public abstract String execute(CommandVo vo);

	/**
	 * 建立链表
	 * 
	 * @param abstractClass
	 * @return
	 */
	protected final List<? extends CommandName> buildChain(
			Class<? extends CommandName> abstractClass) {
		// 取出所有的命令名下的子类
		@SuppressWarnings("rawtypes")
		List<Class> classes = ClassUtils.getSonClass(abstractClass);
		// 存放命令的实例,并建立链表关系
		List<CommandName> commandNameList = new ArrayList<CommandName>();
		for (@SuppressWarnings("rawtypes")
		Class c : classes) {
			CommandName commandName = null;
			try {
				// 产生实例
				commandName = (CommandName) Class.forName(c.getName()).newInstance();
			} catch (Exception e) {
				e.printStackTrace();
			}
			// 建立链表
			if (commandNameList.size() > 0) {
				commandNameList.get(commandNameList.size() - 1).setNext(commandName);
			}
			commandNameList.add(commandName);
		}
		return commandNameList;
	}
}

Command抽象类有两个作用:一是定义命令的执行方法,二是负责命令族(责任链)的建立。其中buildChain方法负责建立一个责任链,他通过接收一个抽象的命令族类就可以建立一条命令解析链,如传递AbstractLS类就可以建立一条解析ls命令族的责任链,请注意如下这句代码:

commandName = (CommandName) Class.forName(c.getName()).newInstance();

在一个遍历中,类中的每个元素都是一个类名,然后根据类名产生一个实例,他会抛出异常,例如类文件不存在、初始化失败等,在设计时要实现该部分的异常。我们再来想一下,每个实现类的类名是如何取得的呢?看下面这句代码:

List<Class> classes = ClassUtils.getSonClass(abstractClass);

根据一个父类取得所有子类,是一个非常好的工具类,其实现如下所示。

public class ClassUtils {
	private ClassUtils() {
	}

	/**
	 * 根据父类查找到所有的子类,默认情况是子类和父类都在同一个包名下
	 * 
	 * @param fatherClass
	 * @return
	 */
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public static List<Class> getSonClass(Class fatherClass) {
		// 定义一个返回值
		List<Class> returnClassList = new ArrayList<Class>();
		// 获得包名称
		String packageName = fatherClass.getPackage().getName();
		// 获得包中的所有类
		List<Class> packClasses = getClasses(packageName);
		// 判断是否是子类
		for (Class c : packClasses) {
			if (fatherClass.isAssignableFrom(c) && !fatherClass.equals(c)) {
				returnClassList.add(c);
			}
		}
		return returnClassList;
	}

	/**
	 * 从一个包中查找出所有的类,在jar包中不能查找
	 * 
	 * @param packageName
	 * @return
	 */
	@SuppressWarnings("rawtypes")
	private static List<Class> getClasses(String packageName) {
		ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
		String path = packageName.replace(".", "/");
		Enumeration<URL> resources = null;
		try {
			resources = classLoader.getResources(path);
		} catch (IOException e) {
			e.printStackTrace();
		}
		List<File> dirs = new ArrayList<File>();
		while (resources.hasMoreElements()) {
			URL resource = resources.nextElement();
			dirs.add(new File(resource.getFile()));
		}
		ArrayList<Class> classes = new ArrayList<Class>();
		for (File directory : dirs) {
			classes.addAll(findClasses(directory, packageName));
		}
		return classes;
	}

	@SuppressWarnings("rawtypes")
	private static List<Class> findClasses(File directory, String packageName) {
		List<Class> classes = new ArrayList<Class>();
		if (!directory.exists()) {
			return classes;
		}
		File[] files = directory.listFiles();
		for (File file : files) {
			if (file.isDirectory()) {
				assert !file.getName().contains(".");
				classes.addAll(findClasses(file, packageName + "." + file.getName()));
			} else if (file.getName().endsWith(".class")) {
				try {
					classes.add(Class.forName(packageName + '.'
							+ file.getName().substring(0,file.getName().length() - 6)));
				} catch (ClassNotFoundException e) {
					e.printStackTrace();
				}
			}
		}
		return classes;
	}
}

这个类请大家谨慎使用,在核心的应用中尽量不要使用该工具,他会严重影响性能。

再来看LSCommand类的实现,如下所示。

public class LSCommand extends Command {

	@Override
	public String execute(CommandVo vo) {
		// 返回链表的首节点
		CommandName firstNode = super.buildChain(AbstractLS.class).get(0);
		return firstNode.handleMessage(vo);
	}

}

很简单的方法,先建立一个命令族的责任链,然后找到首节点调用。在该类中我们使用CommandVO类,他是一个封装对象,其代码如下所示。

public class CommandVo {
	// 定义参数名为参数的分隔符号,一般是空格
	public final static String DIVIDE_FLAG = " ";
	// 定义参数前的符号,UNIX一般是一,如ls -la
	public final static String PREFIX = "-";
	// 命令名,如ls、du
	private String commandName = "";
	// 参数列表
	private ArrayList<String> paramList = new ArrayList<String>();
	// 操作数列表
	private ArrayList<String> dataList = new ArrayList<String>();

	/**
	 * 通过构造函数传递进来命令
	 * 
	 * @param commandStr
	 */
	public CommandVo(String commandStr) {
		// 常规判断
		if (commandStr != null && commandStr.length() != 0) {
			// 根据分隔符号拆分出执行符号
			String[] complexStr = commandStr.split(CommandVo.DIVIDE_FLAG);
			// 第一个参数是执行符号
			this.commandName = complexStr[0];
			// 把参数放到List中
			for (int i = 0; i < complexStr.length; i++) {
				String str = complexStr[i];
				// 包含前缀符号,认为是参数
				if (str.indexOf(CommandVo.PREFIX) == 0) {
					this.paramList
							.add(str.replace(CommandVo.PREFIX, "").trim());
				} else {
					this.dataList.add(str.trim());
				}
			}
		} else {
			// 传递的命令错误
			System.out.println("命令解析失败,必须传递一个命令才能执行!");
		}
	}

	/**
	 * 得到命令名
	 * 
	 * @return
	 */
	public String getCommandName() {
		return this.commandName;
	}

	/**
	 * 获得参数
	 * 
	 * @return
	 */
	public ArrayList<String> getParam() {
		// 为了方便处理空参数
		if (this.paramList.size() == 0) {
			this.paramList.add("");
		}
		return new ArrayList<String>(new HashSet<String>(this.paramList));
	}

	/**
	 * 获得操作数
	 * 
	 * @return
	 */
	public ArrayList<String> getData() {
		return this.dataList;
	}

	/**
	 * 格式化数据
	 * 
	 * @return
	 */
	public String formatData() {
		return "格式化数据";
	}
}

CommandVO解析一个命令,规定一个命令必须有3项:命令名、选项、操作数。如果没有呢?那就以长度为零的字符串代替,通过这样的一个约定可以大大降低命令解析的开发工作。注意getParam参数中的返回值:

new ArrayList<String>(new HashSet<String>(this.paramList));

为什么要这么处理?HashSet具有值唯一的优点,这样处理就是为了避免出现两个相同的参数,比如对于“ls –l –l -s”这样的命令,通过getParam返回的参数是几个呢?回答是两个:l选项和s选项。

我们再来看Invoker类,他是负责命令分发的类,如下所示。

public class Invoker {
	/**
	 * 执行命令
	 * 
	 * @param _commandStr
	 * @return
	 */
	public String exec(String _commandStr) {
		// 定义返回值
		String result = "";
		// 首先解决命令
		CommandVo vo = new CommandVo(_commandStr);
		// 检查是否支持支持该命令
		if (CommandEnum.getNames().contains(vo.getCommandName())) {
			// 产生命令对象
			String className = CommandEnum.valueOf(vo.getCommandName()).getValue();
			Command command;
			try {
				command = (Command) Class.forName(className).newInstance();
				result = command.execute(vo);
			} catch (InstantiationException | IllegalAccessException
					| ClassNotFoundException e) {
				e.printStackTrace();
			}
		} else {
			result = "无法执行命令,请检查命令格式";
		}
		return result;
	}
}

实现也是比较简单的,从CommandEnum中获得命令与命令类的配置信息,然后建立一个命令实例,调用其execute方法,完成命令的执行操作。CommandEnum类是一个枚举类型,如下所示。

public enum CommandEnum {
	ls("com.test.LSCommand"), 
	df("com.test.DFCommand");
	private String value = "";

	/**
	 * 定义构造函数,目的是Data(value)类型的相匹配
	 * 
	 * @param value
	 */
	private CommandEnum(String value) {
		this.value = value;
	}

	public String getValue() {
		return value;
	}

	/**
	 * 返回所有的enum对象
	 * 
	 * @return
	 */
	public static List<String> getNames() {
		CommandEnum[] commandEnum = CommandEnum.values();
		List<String> names = new ArrayList<String>();
		for (CommandEnum c : commandEnum) {
			names.add(c.name());
		}
		return names;
	}
}

为什么要用枚举类型?用一个接口来管理也是很容易实现的。注意CommandEnum中构造函数CommandEnum(String value)和getValue类,没有新建一个Enum对象,但是可以直接使用CommandEnum.ls.getValue方法获得值,这就是Enum类型的独特地方。再看下面:

ls("com.test.LSCommand");

是不是很特别?是的,枚举的基本功能就是定义默认可选值,但是Java中的枚举功能又增强了很多,可以添加方法和属性,基本上就是一个特殊的类。

现在剩下的工作就是写一个Client类,然后看看运行情况如何,如下所示。

public class Client {
	public static void main(String[] args) throws IOException {
		Invoker invoker = new Invoker();
		while (true) {
			// UNIX下的默认提示符号
			System.out.print("#");
			// 捕获输出
			String input = (new BufferedReader(new InputStreamReader(System.in))).readLine();
			// 输入quit或exit则退出
			if ("quit".equals(input) || "exit".equals(input)) {
				return;
			}
			System.out.println(invoker.exec(input));
		}
	}
}

我们已经实现了在Windows下操作UNIX命令的功能,但是仅仅一个ls命令族是不够的,我们要扩展,把一百多个命令都扩展出来,怎么扩展呢?现在增加一个df命令族,显示磁盘的大小。

仅仅增加DFCommand、AbstractDF以及实现类就可以完成扩展功能。先看AbstractDF代码,如下所示。

public abstract class AbstractDF extends CommandName {
	// 默认参数
	public final static String DEFAULT_PARAM = "";
	// 参数k
	public final static String K_PARAM = "k";
	// 参数
	public final static String G_PARAM = "g";
}

与前面一样的功能,定义选型名称。接下来是三个实现类,都非常简单,如下所示。

public class DF extends AbstractDF {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.DEFAULT_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		return DiskManager.df();
	}

}
public class DF_K extends AbstractDF {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.K_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		return DiskManager.df_k();
	}

}
public class DF_G extends AbstractDF {

	@SuppressWarnings("static-access")
	@Override
	protected String getOperateParam() {
		return super.G_PARAM;
	}

	@Override
	protected String echo(CommandVo vo) {
		return DiskManager.df_g();
	}

}

每个选项的实现类都定义了自己能解析什么命令,然后通过echo方法返回执行结果。在三个实现类中都与DiskMananger类有关联关系,该类负责与操作系统有关的功能,是 必须要实现的,其示例代码如下所示。

public class DiskManager {
	private DiskManager() {
	}

	/**
	 * 默认的计算大小
	 * 
	 * @return
	 */
	public static String df() {
		return "/\t10485760\n/usr\t104857600\n/home\t1048576000\n";
	}

	/**
	 * 按照kb来计算
	 * 
	 * @return
	 */
	public static String df_k() {
		return "/\t10240\n/usr\t102400\n/home\tt10240000\n";
	}

	/**
	 * 按照gb来计算
	 * 
	 * @return
	 */
	public static String df_g() {
		return "/\t10\n/usr\t100\n/home\tt10000\n";
	}
}

以上为示例代码,若要实际计算磁盘大小,可以使用JNI的方式或者执行操作系统的命令的方式获得,特别是JDK 1.6提供了获得一个root目录大小的方法。

然后再增加一个DFCommand命令,负责执行命令,如下所示。

public class DFCommand extends Command {

	@Override
	public String execute(CommandVo vo) {
		return super.buildChain(AbstractDF.class).get(0).handleMessage(vo);
	}

}

最后一步,修改一下CommandEnum配置,增加一个枚举项。

仅仅增加类就完成了变更,这才是我们要的结果:对修改关闭,对扩展开放。

小结

在这里的例子中用到了以下模式。

  • 责任链模式

负责对命令的参数进行解析,而且所有的扩展都是增加链数量和节点,不涉及原有的代码变更。

  • 命令模式

负责命令的分发,把适当的命令分发到指定的链上。

  • 模板方法模式

在Command类以及子类中,buildChain方法是模板方法,只是没有基本方法而已;在责任链模式的CommandName类中,用了一个典型的模板方法handlerMessage,他调用了基本方法,基本方法由各个实现类实现,非常有利于扩展。

  • 迭代器模式

在for循环终我们多次用到类似for(Class c:classes)的结构,是谁来支撑该方法运行?当然是迭代器模式,只是JDK已经把他融入到了API中,更方便使用了。

可能大家已经注意到了,“ls –l-a”这样的组合选项还没有处理。确实没有处理,以下提供两个思路来处理。

  • 独立处理

“ls –l-a”等同于“ls -la”,也等同于“ls -al”命令,可以把”ls -la”中的选项“la”作为一个参数来进行处理,扩展一个类就可以了。该方法的缺点是类膨胀得太大,但是简单。

  • 混合处理

修正命令族处理链,每个命令处理节点运行完毕后,继续由后续节点处理,最终由Command类组装结果,根据每个节点的处理结果,组合后生成完整的返回信息,如“ls –l -a”就应该是LS_L类与LS_A类两者返回值组装的结果,当然链上的节点返回值就要放在Collection类型中了。

该框架还有一个名称,叫做命令链(Chain of Command)模式,具体来说就是命令模式作为责任链模式的排头兵,由命令模式分发具体的消息到责任链模式。对于该框架,可以继续扩展下取。当然,上面的程序还可以优化,优化的结果就是Command类缩为一个类,通过CommandEnum配置文件类传递命令,这比较容易实现。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值