可爱的javaee:非框架架构漫谈

概述

你可以说可爱的php ,可爱的ror ,可爱的python ,甚至可爱的.net ,但是javaee ?他太复杂了。相比前三种技术,javaee 的技术体系更全面、更规整也更复杂,他的复杂性也让很多厂商望而止步,宁可选择简单甚至简陋的php ,这充分说明快速开发是这个时代最迫切的需求。

javaeeservletjavabeanjdbc 规范给了我们组件和容器的唯一标准,而更高级的支持,jsfjdo 规范却没能给予我们唯一的框架级标准,他们被认可的程度远低于相同领域的开源框架。尽管开源社区给了我们最丰富的选择,但是相比.netphpror 的全栈式服务,javaee 开发者必须DIYDIY 不但需要时间而且需要冒险,这种发烧友做的事情是企业所不愿意做的。一段时间以来,公司javaee 方向的招聘几乎清一色的要求strutsspringhibernate 这几种主流框架的能力就是一种证明。

javaee 的开发往往避免不了配置之旅,尽管很多框架都有自动生成工具,但是,面对一个中型项目,仍然容易造成混乱。配置使你无论在开发、测试、集成还是维护的时都要配置代码两头看。配置给了框架一个注入服务的切入点,但是对人并无优雅可言。ror 给了我们启发,尽管企业开发是复杂的,但是大多数的需求都是通用的,事实证明,ror 把这部分通用性用约定的方式抽象得很好。其实javaee 并不缺乏约定,因为他本身就是建立于一系列规范的基础之上,而规范就是约定。所以,javaee 实际上有机会成为相对简洁的开发技术,只不过由于种种原因,这种规范并未出现。

在众多的javaee 开发框架中,struts+spring+hibernate 有着黄金组合的美誉,用的人多,会的人多,就算是没出校门的学生,也知道学会ssh 的重要性。但是学会和学懂是两码事,对于一个中型项目,ssh 就成了一柄双刃剑,需要由高水平的设计师引路,才能披荆斩棘。spring+hibernate 给了设计者广阔的空间,而设计需要因项目的前进而演进,如果你的项目进度紧张,人手不足,设计质量就难以保障,给系统带来隐患。

“任何优秀的语言,都可以帮助开发者写出优秀的代码,但不能阻止开发者写出糟糕的代码”。在这一点上,无论是javaee.netrorphp 都不会例外。而开发框架就像是“一间有很多屋梁的房子”,“框架的强大之处不是他能让你做什么,而是他不能让你做什么”,其实如同语言一样,框架虽然可以给予开发一定程度的规范指导,但是这种指导仍然是有限的,这真应了那句老话:事在人为。

本文试图探讨如何简化javaee 开发中不必要的复杂,并给出的是一个不使用任何框架的架构模型,让我们看看仅仅通过用编码约定,结构设计和使用方式的组合能不能满足项目开发的主要需求 短期培训,降低隐患和快速开发。

问题的源头

应用软件开发是复杂的,但其基本模型至为简单,请求-处理-响应。对应于软件的层次结构就是:请求-CortrolC );处理-ModelM );响应-ViewV )。在早期的javaee 应用中,servlet 负责CjavabeanjdbcMjspV 。这些就是javaee 的基础设施,他们职责划分的方式被称为JSP Model2 ,已经可以满足web 开发的基本需要,javaee 的开发始终都围绕着这几项主要技术,框架也不例外。以下的内容,将从这些技术的应用与不足说起,然后介绍主流框架的解决方案,之后再介绍我们不用框架的处理方式。

(C) 选择控制器

基础规范的不足

任何web 应用,处理请求之后返回响应是必须的环节,如果编码规范,传统的响应就是转向到某个页面,servlet 处理转向有两种方式,其中request 转向隐藏着重复提交的问题,response 重定向带来参数传递的编码解码的问题,同时众多的转向地址直接写在servlet 中也十分不雅,另外,jspjavabean 有一种出色的关联技术,就是在jsp 里可以把来自请求表单的数据通过标签自动拼装到javabean 中。糟糕的是,这么有用的技术却无法在servlet 中使用,所以Model2 缺乏对表单数据的自动转换处理。servlet 有这些不足很好理解,因为servlet 毕竟是较早出现的技术,他的职责只是将(http )请求转化为面向对象的视图和输出响应而已,由于他是低阶组件,所以部分功能的缺失是正常的。不过这就让servlet 成为了Model2 最薄弱的一环。

开发框架的解决方案

由于以上需求是共性的,所以编写一个通用框架就成为了很多人努力的事情,struts 很快推出并且很快流行。我们先来看一看struts 的特性:

前端控制器: struts 使用一个servlet 作为前端控制器,所有请求先经过这里,再分派给配置指定的action (这里是指行为,而不是具体的Action ),意图是以一个中间层将视图层和控制层解耦,这种思路带来了三种可能的好处:1 视图和控制分离,所以可以选择不同的视图技术,比如视图模板既可以用jsp ,也可以用VolecityFreeMarker2 可以对所有请求预处理,和后处理(webwork );3 可以将响应的转向地址管理起来。前端控制器也存在一种直接的不足:配置繁琐。

ActionForm struts 主要是一个控制层框架,所以他并不意图深入到模型层,ActionForm 是一种无奈的选择,尽管提供了表单数据到javabean 的转换,但是遗憾的是这个javabean 并不能直接使用,还要手工的转换为模型javabean ,使得ActionForm 的位置有些尴尬。

国际化支持、标签库和全局异常: 国际化和标签库都是struts 的亮点,不过全局异常作用有限。

我们的选择

javaee 的控制器必然是一个servlet ,我们也不能例外,因为我们必须要运行在servlet 容器之中。不过,我们选择的是servlet 的演进版本-jsp 。别误会,我们并不是要退回到JSP Model1 。一个典型的示例是,如果我有一个员工信息录入的功能点,那么为了实现这个功能,我可以建立下面两个文件:

worker_input.jsp

worker_input_st.jsp

worker_input.jsp 里不写控制代码,worker_inupt_st.jsp 里也不写视图代码,这种用法实际是JSP Model1JSP Model2 的综合体。这样做最大的好处就是,免去配置的烦恼,但是等等.. 前端控制器呢?我们的中间层呢?

考虑一下,你有一个企业信息的表单,表单中有一个企业名称域,对这个域的要求是不能在已有企业中重名,域旁边有一个按钮,业务员可以通过点击这个按钮获得录入企业名称是否重复的提示。如果是传统方式,点击按钮将导致一个页面提交,如果用struts ,将要配置这个action 处理之后转向的URL 地址,这就是传统web 应用的响应方式- 基于URL 地址的页面导航。

web2.0 来了,ajax 来了,异步请求的出现彻底颠覆了传统的web 交互模型。对于ajax 应用而言,服务器端返回响应只需要out.print ,请求从哪来,回哪去,转向(如果需要)和更新视图的任务都交给了客户端脚本,也就是说,基于异步交互模式的web 应用,根本就没有需要配置的result URL 路径。这样,页面导航的问题就自动解决了。而对于预处理,我们可以用filter 替代。所以,我们完全可以和前端控制器说:再见。

由于客户端技术的趋势,在webappdemo 中我们将全面使用ajax 。也许你会说,如果客户端浏览器禁用脚本呢?这个担心在如今已经没有必要,你可以访问开心或者当当,看看禁用脚本他们能不能工作。时代在进步,富客户RIA 是必然的选择。

使用jsp 作为控制器,还使我们得到了另一个关键的特性,那就是从form 表单数据到javabean 的自动创建和输入,使javabean 本身既是模型也是DTO ,再也不必象ActionForm 那样还要手工转换。这里还有一个隐含的好处,就是强制统一了表单域名和模型属性名,不然,有可能出现这样的情况:表单域:child_center ;模型属性:branch 。以下是worker_input_st.jsp 的写法:

<jsp:useBean id="worker" class="webappdemo.worker.entity.Worker" scope="page"/>
<jsp:setProperty name="worker" property="*"/>

<%

	response.setContentType("text/x-json;charset=UTF-8");
	response.setHeader("Cache-Control", "no-cache");

	String method = request.getParameter("method");

	if("save".equals(method)){
		EntityService es = new EntityService();
		Message m = es.add(worker);
		out.print(new JSONObject().put(m.isSucceed()?"succeed":"error", m.getMessage()));
		return;
	}

%>

 

可以看出,只需将实体类名引入标签,我们就可以获得自动拼装的Worker 对象。对于复杂对象或复合对象,由于request 里同样有我们需要的所有请求参数,所以你可以在自动创建的javabean 基础上修改部分属性,以符合业务需要。

代码还展示了基于“method ”的用法,这只是一个字符串,用来告诉 jsp 要用哪个方法来处理请求,这类似于ror 控制器内部定义的方法以及strutsDispatchAction 但比他更灵活,变通的解决了jsp 的请求不能直接面向方法的不足。

在调用服务处理请求之后,worker_input_st.jsp 将处理结果out.print 回客户端,这句代码的意思是新建一个JSON 对象,将处理结果添加进去,然后输出这个对象,方便客户端js 脚本解析。JSON 对象可以增加多个处理结果,只要他们的key 不同就可以。在实际应用中,往往返回处理消息,或者html 视图的字符串。最后别忘了return; 否则程序仍然会向下进行。

如果你的项目需要国际化,我们可以使用fmt 标签,而对于反馈消息的国际化,我们也许就需要建立一个全局MessageSource 对象了,这个问题在webappdemo 中没有涉及,因为笔者认为这不是普遍需求。

对于异常处理,其实jsp 已经提供了简单的机制,我们可以在web.xml 中配置:

<error-page>
	<error-code>404</error-code>
	<location>/404.jsp</location>
</error-page>
<error-page>
	<error-code>500</error-code>
	<location>/500.jsp</location>
</error-page>

这种简单的处理其实正是我们需要的全部,因为笔者认为,web 应用的系统错误和java 的异常没有区别,即检测错误和运行时错误。在web2.0 时代,所有的错误都应该被捕获,并且把内容经处理后在用户输入位置反馈给用户,而不应该重新定向。运行时错误属于系统bug ,是需要修复的代码级错误,这种错误是真正的“意外”,所以我们用定制的错误页面反馈给用户就可以了。

    综上所述,我们用ajax+jsp+ 控制jsp 的方式代替了servlet 或者action ,摆脱了前端控制器,用模型javabean 代替了过程javabean ActionForm ,这些使用方式使我们不需要配置即可以开发应用程序,除了ajax 是相对新概念外不需要额外学习框架技术也是他的优点。

(M)ORM 可以做什么

基础规范的不足

jdbcjava 应用程序数据持久化的基础,也是众多数据库厂商与java 的接口。直接使用jdbc 编写代码非常繁琐,比如数据库资源的获得和释放,异常捕获和事务处理等等,重复代码多是他的一个特点。另外,不同的数据库,在数据类型,主键类型,sql 语句方面和SQL 标准都小有出入,所以如何使应用程序可以在不同数据库平台方便的迁移,也是个问题。

开发框架的解决方案

springhibernate 的出现使情况大为好转,spring 面向切面管理事务, hibernate 自动ORM 可以大大简化开发,springhibernate 都有.net 的版本,这证明了他们的成功。但是“用好hibernate ,需要扎实的掌握关系模型和SQL ”,同时对面向对象设计和hibernate 自身的运行机制也要有非常清晰的认识,只有这种水平才能发挥hibernate 的威力,降低hibernate 带来的风险。所以,在合适的层面上配置好spring 的事务管理,设计好对象模型,把对hibernate 的直接使用控制在一定范围内是设计者要解决的基本问题。如果设计不佳,或者直接交给初出校门的开发者去用,那这种组合就会变成洪水猛兽,同时也不利于团队成员的成长。

我们的选择

如果只有jdbc ,我们的系统也可以工作,只不过要写很多重复和机械的代码,通过框架的ORM 的映射,可以将数据库表的数据自动填入javabean ,这节省了劳动力,也使系统结构自然清晰。如果不用ORM 工具,我们能不能通过某种形式来模拟他的行为呢?我们可以创建这样一个接口:

public interface IOperate {

	boolean load(Connection connect) throws SQLException;

	boolean add(Connection connect) throws SQLException;

	boolean update(Connection connect) throws SQLException;

	boolean delete(Connection connect) throws SQLException;

}

在接口中定义 CRUD 方法。返回类型为 boolean 而非影响的行数,意图是对象内部的操作可能是复杂的多步骤的,所以对他的上层应用来说,知道结果成功与否就可以了。接下来在他的实现类里可以这么写:

public class Worker implements IOperate {

	// Fields
	private Integer workerId;
	private String workerName;
	private String logonName;
	private String logonPwd;
	private String mobile;
	private String email;
	private String remark;
	private String isFreeze;
	// Constructors
	/** default constructor */
	// Property accessors
    // getter and setter ...

	public boolean add(Connection connect) throws SQLException {
		SQLBuffer sql = new SQLBuffer();
		sql.segment("insert into worker (worker_name,logon_name,logon_pwd,");
		sql.segment("mobile,email,remark,is_freeze) values (");
		sql.value(this.workerName);
		sql.comma();
		sql.value(this.logonName);
		sql.comma();
		sql.value(this.logonPwd);
		sql.comma();
		sql.value(this.mobile);
		sql.comma();
		sql.value(this.email);
		sql.comma();
		sql.value(this.remark);
		sql.comma();
		sql.value(this.isFreeze);
		sql.segment(")");
		return Proxy.update(connect, sql) == 1;

	}

	public boolean delete(Connection connect) throws SQLException {
		// 冻结用户
		SQLBuffer sql = new SQLBuffer();
		sql.segment("update worker set is_isfreeze = ");
		this.isFreeze = "1";
		sql.value(this.isFreeze);
		sql.segment(" where worker_id = ");
		sql.value(this.workerId);
		return Proxy.update(connect, sql) == 1;
	}

	public boolean load(Connection connect) throws SQLException {
		SQLBuffer sql = new SQLBuffer(
				"select worker_name,logon_name,logon_pwd,mobile,email,remark,is_freeze from worker");
		sql.segment(" where worker_id = ");
		sql.value(this.workerId);

		MapRow mr = Proxy.getMapRow(connect, sql);

		if (mr == null) {
			return false;
		}
		this.workerName = mr.getString("worker_name");
		this.logonName = mr.getString("logon_name");
		this.logonPwd = mr.getString("logon_pwd");
		this.mobile = mr.getString("mobile");
		if(this.mobile == null) this.mobile = "";
		this.email = mr.getString("email");
		if(this.email == null) this.email = "";
		this.remark = mr.getString("remark");
		if(this.remark == null) this.remark = "";
		this.isFreeze = mr.getString("is_freeze");

		return true;
	}

	public boolean update(Connection connect) throws SQLException {
		SQLBuffer sql = new SQLBuffer();
		sql.segment("update worker set worker_name = ");
		sql.value(this.workerName);
		sql.segment(", logon_name = ");
		sql.value(this.logonName);
		sql.segment(", logon_pwd = ");
		sql.value(this.logonPwd);
		sql.segment(", mobile = ");
		sql.value(this.mobile);
		sql.segment(", email = ");
		sql.value(this.email);
		sql.segment(", remark = ");
		sql.value(this.remark);
		sql.segment(", is_freeze = ");
		sql.value(this.isFreeze);
		sql.segment(" where worker_id = ");
		sql.value(this.workerId);
		return Proxy.update(connect, sql) == 1;
	}

}

实体 javabean 通过实现 IOperate 接口,负责对自身数据的操作。尽管这种实现方式等于是使模型成为了富血模型,但其实我们仍然可以把这种模型认为是贫血的,因为他没有业务逻辑,只是模拟了 ORM 的行为。 如果对象关系有包含和聚合,我们同样也可以通过类似 hibernate 的行为方式来实现,比如懒加载。以上的代码使用了笔者所用的 API ,由于操作都在内部,所以换成直接用 jdbc 也是一样的。在实际应用中, load 方法有些单薄,因为有的查询需要一些附加条件,我们可以通过增加一个类属性来达到这个目的:

String condition;

如果设置了条件,我们就拼接给定的查询条件取得结果,不过结果只能是一条,这是模型结构所决定的。另外Connection 对象是每个方法的必须的参数,意图是在实际业务操作中,单一的操作往往是不存在的,所以,总是由外部传递资源和释放资源。但是,在每个调用的地方都要写重复的try catch 以及获得和释放 连接的代码岂不是很无聊?那么,我们可以用一个服务类包装他,这就体现了IOperate 接口的用处了:

public class EntityService {

	public Message add(IOperate io) {

		try {
			Connection connect = Proxy.getConnect();
			connect.setAutoCommit(false);

			// 增加
			if (!io.add(connect)) {
				throw new Exception("增加操作失败");
			}

			// 其他操作

			connect.commit();
			return new Message(true);

		} catch (Exception e) {
			Proxy.rollbackConnects();
			e.printStackTrace();
			return new Message(e);
		} finally {
			Proxy.closeConnects();
		}

	}

	public Message update(IOperate io) {

		try {
			Connection connect = Proxy.getConnect();
			connect.setAutoCommit(false);

			// 修改
			if (!io.update(connect)) {
				throw new Exception("修改操作失败");
			}

			// 其他操作

			connect.commit();
			return new Message(true);

		} catch (Exception e) {
			Proxy.rollbackConnects();
			e.printStackTrace();
			return new Message(e);
		} finally {
			Proxy.closeConnects();
		}
	}

	public Message delete(IOperate io) {

		try {
			Connection connect = Proxy.getConnect();
			connect.setAutoCommit(false);

			// 删除
			if (!io.delete(connect)) {
				throw new Exception("删除操作失败");
			}

			// 其他操作

			connect.commit();
			return new Message(true);

		} catch (Exception e) {
			Proxy.rollbackConnects();
			e.printStackTrace();
			return new Message(e);
		} finally {
			Proxy.closeConnects();
		}
	}

	public Message load(IOperate io) {

		try {
			Connection connect = Proxy.getConnect();
			connect.setAutoCommit(false);

			// 载入
			if (!io.load(connect)) {
				throw new Exception("载入操作失败");
			}

			// 其他操作
			connect.commit();
			return new Message(true);

		} catch (Exception e) {
			Proxy.rollbackConnects();
			e.printStackTrace();
			return new Message(e);
		} finally {
			Proxy.closeConnects();
		}
	}
}

EntityService 的代码中看到,try catch 应该总是出现在服务层,只有一个独立的服务才有必要保证一个完整的事务。如果你要在CUD 操作后记录业务日志,那么你可以写在“其他操作”的位置。由于对外界信息的依赖,比如用户信息,功能ID 等等,在实际应用中需要更复杂的设计。至此,我们完成了对ORM 行为的模拟,在调用的地方,我们只需要:

EntityService es = new EntityService();
Message m = es.add(worker);

尽管方法内部的代码仍然是繁琐的,但是我们通过对职责的分派以及把事务控制代码聚焦于较少的地方而降低了繁琐程度,同时也获得了良好的结构。在实际开发中,我们还可以在实体内部增加服务器端验证,在通往持久化的入口把关。对数据库的操作更为直观是“手写”代码的优点,这对在必要的时候进行sql调优非常重要。当然,我们还可以选择ibatis,不过结构就不会是这样的了。同时笔者认为sql代码总是和程序内部息息相关,配置在外部看起来并不一定方便吧。

    对于数据库迁移问题,hibernate 很大程度的解决了这个问题,不过企业开发中迁移数据的需求并不多,而且数据库迁移不是使用某个工具或者规范(jdbcjdo )就可以完全解决的,对于大型复杂应用,迁移数据库需要付出巨大的努力,所以实际上也没有人这么做。

(V) 客户端MVC

    关于客户端应用的说明没有内部分段,因为javaee 技术(暂不谈javafx )本身就和客户端没有太大关系,所以也不存在不足的问题,这里只是意图纠正一些系统中不良的编码习惯。

    jsfjavaee UI 框架规范,前端控制器、事件模型和UI 组件是他的主要组成部分,但是实际开发中jsf 标签非常繁琐,并且只有客户端明确禁用脚本或者其他组件(flex-flashjavafx 甚至silverlight )时,jsf 才是最有价值的,糟糕(幸运)的是目前几乎没有客户端浏览器不支持以上脚本或插件,所以jsf 的价值大大降低了。

jspjavaee 视图组件,也是jsf UI 框架的基础之一,他实际上就是一个环境宽松的视图模板,尽管这种宽松貌似不利于软件分层,但是比起其他模板技术,他至少有一个优点,就是非常象asp ,这使他更容易为开发者所接受。在web2.0 的时代,如果你的项目不打算用flex-flash 或者javafx ,那么jsp 就是你最佳的视图载体,你可以把javacriptcsshtmlxmljson 还有java 代码都放在这里,为了实现ajax 的交互逻辑,你可能要写大段的javascript ,没关系,jsp 都能承受,尽管,这些东西放在一起看上去就像是一个老鼠窝。

Ajax in action 的作者提出了客户端MVC 的概念,不过别误会,这并不是ext 或者flex 的开发模式,而只是一种观念。html 是由dom 树建立的视图结构,所以是Mcsshtml 结构添加效果,所以是Vjavascript 控制显示逻辑,所是C ,这里指的客户端MVC 就是把这三种语言代码分别建立单独的文件,在html 页面中引用外部的cssjs 。同样拿员工信息录入举例,我们可以建立如下几个文件:

worker_input.jsp

worker_inptu.js

style.css

可以看到,css 文件我们并没有用功能方式命名,因为样式表往往可以被整个系统共用,也就是说,我们只要记得不在html 里写cssjs 代码,并单独建立js 文件就可以了。不同种类代码分开建立文件是提高代码可读性的关键步骤,尤其是在客户端程序愈发复杂的今天。worker_input.js 是这样写的:

// 保存
function save(){
	if(check()){
		AjaxUtil.sendForm(document.forms[0], "method=save", function () {
			if (this.result.succeed) {
				document.forms[0].reset();
				messageapp.printMessage(this.result.succeed);
			} else if (this.result.error) {
				messageapp.printMessage(this.result.error);
			}
		}, true);
	}
}

这个文件只定义了一个save 方法,这个方法内部发起了ajax 方式的异步请求,如果返回成功,则重置表单,并更新输出信息到指定位置,反之则仅更新输出信息。你会注意到,我们返回的JSON 对象起到了作用。

 

   问题:如果我使用了类似 rorJavaScriptHelper 那样的脚本标签库,生成的脚本与 html 代码混在一起,这样算不算违背客户端 MVC 原则呢?很显然,不算,因为对于输出前的 jsp 代码模板,程序结构是清晰的。

(F) 结构和细节

    关于MVC 各部分的介绍,再让我们来看看整体结构,假设我们是在一个eclipse 工程中,打开WebRoot 目录,如果忽略META-INFWEB-INF 目录的话,我们的示例目录是这样的:

module

404.jsp

500.jsp

index.htm

其中module 就是我们应用所有的视图,由于我们对于jsp 的使用方式,还包括所有的控制器。将应用程序都放在一个目录下是为了方便今后使用filter 对目录进行保护,或者进行某些其他预处理。打开module 目录,展现的结构是这样的:

common

worker

一个common 目录,存放所有共用程序文件,一个worker 目录存放关于员工这个实体的功能文件。实际上你的结构也可能是sys/worker 或者center/sys/worker 等等,就要看实际的应用需求了,总之,通过目录划分功能模块,以正确的树形结构描述他是没错的。我们再来打开common 目录:

css

img

js

import_page.def

前三个目录分别存放的是常用文件,而最后一个是文件,这个文件将用这种方式添加到每个视图jsp 头,在<head></head> 间加入:

<%@ include file=”../common/import_page.def”%>

这里使用了相对路径,增加这个文件是为了方便的引用其他cssjs 库文件。使用这种方式可以避免在部署的阶段因合并js 或进行cssjs 库文件的版本变更而带来的麻烦。

再来看看java src 的目录结构:

webappdeom

    common

    worker

datasource.xml

log4j.properties

除了根目录,与WebRoot 下的结构是一致的。两个文件:datasource.xml 是数据源配置文件,也是笔者demo 中使用的API 所需要的,log4j.propertieslog4j 的属性文件。另外,系统默认使用hsql 数据库,如果要正常启动,需要改动web.xml 中的一个配置项,具体位置有注释说明。

再具体的结构和代码这里就不多讲了,如果感兴趣,可以下载webappdemo 看看,里面的注释比较完整。不过在demo 里还有一些隐藏问题,以及可以扩展的思路,不知道你能不能找出来。

尾声

老话说,条条大路通罗马,在开源的java 的世界更是如此,我们可以有自己的想法并且拿出来分享,这就是开源最大的魅力,也是资源的宝库。尽管sun 被收购,但是java 仍然会是开源最响亮的声音,javafx 是笔者目前最关注的技术,希望他能一路走好。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值