阅读目录
Form(表单)对于每个WEB开发人员来说,应该是再熟悉不过的东西了,可它却是页面与WEB服务器交互过程中最重要的信息来源。 虽然Asp.net WebForms框架为了帮助我们简化开发工作,做了很完美的封装,让我们只需要简单地使用服务端控件就可以直接操作那些 HTML表单元素了。但我认为了解一些基础的东西,可以使我们不必束缚在WebForms框架上,以及遇到一些奇怪问题时, 可以更从容地解决它们。
今天,我将和大家来聊聊表单,这个简单又基础的东西。我将站在HTML和单纯的Asp.net框架的角度来解释它们的工作方式, 因此,本文不演示WebForms服务器控件的相关内容。
简单的表单,简单的处理方式
好了,让我们进入今天的主题,看看下面这个简单的HTML表单。
在这个HTML表单中,我定义了二个文本输入框,一个提交按钮,表单将提交到Handler1.ashx中处理,且以POST的方式。 注意哦,如果我们想让纯静态页面也能向服务器提交数据,就可以采用这样方式来处理:将action属性指向一个服务器能处理的地址。
说明:当我们使用WebForms的服务器表单控件时,一般都会提交到页面自身来处理(action属性指向当前页面), 这样可以方便地使用按钮事件以及从服务器控件访问从浏览器提交的控件输入结果。 如果在URL重写时,希望能在页面回传时保持URL不变,即:action为重写后的URL,那么可以Page类中执行以下调用:
Form.Action = Request.RawUrl; // 受以下版本支持:3.5 SP1、3.0 SP1、2.0 SP1
好了,我们再回到前面那个HTML表单,看一下如果用户点击了“提交”按钮,浏览器是如何把表单的内容发出的。 在此,我们需要Fiddler工具的协助,请在提交表单前启动好Fiddler。我将这个表单的提交请求过程做了如下截图。
上图是将要提交的表单的输入情况,下图是用Fiddler看到的浏览器发出的请求内容。
在这张图片中,我们可以看到浏览器确实将请求发给了我前面在action中指定的地址,且以POST形式发出的。 表单的二个控件的输入值放在请求体中,且做了【编码】处理,编码的方式用请求头【Content-Type】说明, 这样,当服务端收到请求后,就知道该如何读取请求的内容了。 注意:表单的数据是以name1=value1&name2=value2 的形式提交的,其中name,value分别对应了表单控件的相应属性。
我们还可以在Fiddler中,将视图切换到WebForms选项卡,这样能更清楚地只查看浏览器提交的数据,如下图。
看了客户端的页面和请求的内容,我们再来看看在服务端如何获取浏览器提交的表单的输入吧,代码如下:
string name = context.Request.Form["CustomerName"];string tel = context.Request.Form["CustomerTel"];
代码很简单,直接根据表单控件的name属性访问Request.Form就可以了。
表单提交,成功控件
我们再来看一下浏览器是如何提交表单的,或者说,浏览器在提交表单时,要做哪些事情。
浏览器并不是将所有的表单控件全部发送到服务器的,而是会查找所有的【成功控件】,只将这些成功控件的数据发送到服务端, 什么是成功控件呢? 简单地来说,成功控件就是:每个表单中的控件都应该有一个name属性和”当前值“, 在提交时,它们将以 name=value 的形式做为提交数据的一部分。 对于一些特殊情况,成功控件还有以下规定: 1. 控件不能是【禁用】状态,即指定【disabled="disabled"】。即:禁用的控件将不是成功控件。 2. 如果一个表单包含了多个提交按键,那么仅当用户点击的那个提交按钮才算是成功控件。 3. 对于checkbox控件来说,只有被用户勾选的才算是成功控件。 4. 对于radio button来说,只有被用户勾选的才算是成功控件。 5. 对于select控件来说,所有被选择的选项都做为成功控件,name由select控件提供。 6. 对于file上传文件控件来说,如果它包含了选择的文件,那么它将是一个成功控件。 此外,浏览器不会考虑Reset按钮以及OBJECT元素。
注意: 1. 对于checkbox, radio button来说,如果它们被确认为成功控件,但没有为控件指定value属性, 那么在表单提交时,将会以"on"做为它们的value 2. 如果在服务端读不到某个表单控件的值,请检查它是否满足以上规则。
提交方式:在前面的示例代码中,我为form指定了method="post",这个提交方法就决定了浏览器在提交数据时,通过什么方式来传递它们。 如果是【post】,那么表单数据将放在请求体中被发送出去。 如果是【get】,那么表单数据将会追加到查询字符串中,以查询字符串的形式提交到服务端。 建议:表单通常还是以post方式提交比较好,这样可以不破坏URL,况且URL还有长度限制。
数据的编码:前面我将浏览器的请求细节用Fiddler做了个截图,从这个图中我们可以看到:控件输入的内容并不是直接发送的, 而是经过一种编码规则来处理的。目前基本上只会只使用二种编码规则:application/x-www-form-urlencoded 和 multipart/form-data , 这二个规则的使用场景简单地说就是:后者在上传文件时使用,其它情形则使用前者(默认)。 这个规则是在哪里指定的呢? 其实form还有个enctype属性,用它就可以指定编码规则,当我在VS2008写代码时,会有以下提示:
按照我前面说过的编码规则选择逻辑,application/x-www-form-urlencoded做为默认值,所以,一般情况下我们并不用显式指定。 除非我们要上传文件了,那么此时必须设置enctype="multipart/form-data"
好了,说了这么一大堆理论,我们再来看一下浏览是如何处理表单数据的。这个过程大致分为4个阶段: 1. 识别所有的成功控件。 2. 为所有的成功控件创建一个数据集合,它们包含 control-name/current-value 这样的值对。 3. 按照form.enctype指定的编码规则对前面准备好的数据进行编码。编码规则将放在请求中,用【Content-Type】指出。 4. 提交编码后的数据。此时会区分post,get二种情况,提交的地址由form.action属性指定的。
多提交按钮的表单
用过Asp.net WebForms框架的人可能都写过这样的页面:一个页面中包含多个服务端按钮。处理方式嘛, 也很简单:在每个按钮的事件处理器写上相应的代码就完事了,根本不用我们想太多。 不过,对于不理解这背后处理过程的开发人员来说,当他们转到MVC框架下,可能会被卡住:MVC框架中可没有按钮事件! 即使用不用MVC框架,用ashx通用处理器的方式,也会遇到这种问题,怎么办? 对于这个问题,本文将站在HTML角度给出二个最根本的解决办法。
方法1:根据【成功控件】定义,我们设置按钮的name,在服务端用name来区分哪个按钮的提交:
HTML代码
服务端处理代码
// 注意:我们只要判断指定的name是否存在就可以了。 if( string.IsNullOrEmpty(context.Request.Form["btnSave"]) == false ) { // 保存的处理逻辑 } if( string.IsNullOrEmpty(context.Request.Form["btnQuery"]) == false ) { // 查询的处理逻辑 }
方法2:我将二个按钮的name设置为相同的值(根据前面的成功控件规则,只有被点击的按钮才会提交),在服务端判断value,示例代码如下:
string action = context.Request.Form["submit"];if( action == "保存" ) { // 保存的处理逻辑 } if( action == "查询" ) { // 查询的处理逻辑 }
当然了,解决这个问题的方法很多,我们还可以在提交前修改form.action属性。 对于MVC来说,可能有些人会选择使用Filter的方式来处理。最终选择哪种方法,可根据各自喜好来选择。 我可能更喜欢直接使用Ajax提交到一个具体的URL,这样也很直观,在服务端也就不用这些判断了。接着往下看吧。
上传文件的表单
前面我说到“数据的编码"提到了form.enctype,这个属性正是上传表单与普通表单的区别,请看以下示例代码:
我将上传2个小文件
我们再来看看当我点击提交按钮时,浏览器发送的请求是个什么样子的:
注意我用红色边框框出来的部分,以及请求体中的内容。此时请求头Content-Type的值发生了改变, 而且还多了一个叫boundary的参数,它将告诉服务端:请求体的内容以这个标记来分开。 并且,请求体中每个分隔标记会单独占一行,且具体内容为:"--" + boundary, 最后结束的分隔符的内容为:"--" + boundary + "--" 也是独占一行。 从图片中我们还可以发现,在请求体的每段数据前,还有一块描述信息。 具体这些内容是如何生成的,可以参考本文后面的实现代码。
再来看看在服务端如何读取上传的文件。
HttpPostedFile file1 = context.Request.Files["file1"];if( file1 != null && string.IsNullOrEmpty(file1.FileName) == false ) file1.SaveAs(context.Server.MapPath("~/App_Data/") + file1.FileName); HttpPostedFile file2 = context.Request.Files["file2"]; if( file2 != null && string.IsNullOrEmpty(file2.FileName) == false ) file2.SaveAs(context.Server.MapPath("~/App_Data/") + file2.FileName);
或者
HttpFileCollection files = context.Request.Files;foreach( string key in files.AllKeys ) { HttpPostedFile file = files[key]; if( string.IsNullOrEmpty(file.FileName) == false ) file.SaveAs(context.Server.MapPath("~/App_Data/") + file.FileName); }
二种方法都行,前者更能体现控件的name与服务端读取的关系,后者在多文件上传时有更好的扩展性。
安全问题:注意,上面示例代码中,这样的写法是极不安全的。正确的做法应该是:重新生成一个随机的文件名, 而且最好能对文件内容检查,例如,如果是图片,可以调用.net的一些图形类打开文件,然后"另存"文件。 总之,在安全问题面前只有一个原则:不要相信用户的输入,一定要检查或者转换。
MVC Controller中多个自定义类型的传入参数
前面的所有示例代码中都有一个规律:在服务端读取浏览器提交的数据时,都会使用控件的name属性,基本上在Asp.net中就是这样处理。 但是在MVC中,MS为了简化读取表单数据的代码,可以让我们直接在Controller的方法中直接以传入参数的形式指定, 此时框架会自动根据方法的参数名查找对应的输入数据(当然也不止表单数据了)。下面举个简单的例子:
Conntroller中的方法的签名:
public ActionResult Submit(Customer customer){}public ActionResult Submit(string name, string tel) { }
以上二种方法都是可以的,当然了,前者会比较好,但需要事先定义一个Customer类,代码如下:
public class Customer{ public string Name { get; set; } public string Tel { get; set; } }
如果表单简单或者业务逻辑简单,我们或许一直也不会遇到什么麻烦,以上代码能很好的工作。 但是,如果哪天我们有了新的业务需要求,需要在这个表单中同时加上一些其它的内容,例如,要把业务员的资料也一起录入进去。 其中业务员的实体类定义如下:
public class Salesman{ public string Name { get; set; } public string Tel { get; set; } }
Controller的接口需要修改成:
public ActionResult Submit(Customer customer, Salesman salesman){}
这时,HTML表单又该怎么写呢?刚好,这二个类的(部分)属性名称一样,显然,前面表单中的Name,Tel就无法对应了。 此时我们可以将表单写成如下形式:
注意Controller方法中的参数名与HTML表单中的name是有关系的。
F5刷新问题并不是WebForms的错
刚才说到了MVC框架,再来说说WebForms框架。以前时常听到有些人在抱怨用WebForms的表单有F5的刷新重复提交问题。 在此我想为WebForms说句公道话:这个问题并不是WebForms本身的问题,是浏览器的问题, 只是如果您一直使用WebForms的较传统用法,是容易产生这个现象的。那么什么叫做【传统用法】呢?这里我就给个我自己的定义吧: 所谓的WebForms的传统用法是说:您的页面一直使用服务器控件的提交方式(postback),在事件处理后,页面又进入再一次的重现过程, 或者说:当前页面一直在使用POST方式向当前页面提交。
那么如何避开这个问题呢?办法大致有2种:
1. PRG模式(Post-Redirect-Get),在事件处理后,调用重定向的操作Response.Redirect(), 而不要在事件处理的后期再去给一些服务器控件绑定数据项了! 建议:按钮事件只做一些提交数据的处理,将数据绑定的操作放在OnPreRender方法中处理,而不是写在每个事件中(遍地开花)。 不过,这种方式下,可能伟大的ViewState就发挥不了太大的作用了,如果您发现ViewState没用了,在Web.config中全局关掉后, 又发现很多服务器控件的高级事件又不能用了!嗯,杯具有啊。 这个话题说下去又没完没了,到此为止吧,不过,千万不要以为这种方法是在倒退哦。
2. 以Ajax方式提交表单,请继续阅读本文。
以Ajax方式提交整个表单
前面一直在说”浏览器提交表单",事实上我们也可以用JavaScript提交表单,好处也有很多,比如前面所说的F5刷新问题。 以Ajax方式提交表单的更大好处它是异步的,还可以实现局部刷新,这些特性都是浏览器提交方式没有的。 前面我提到表单在提交时,浏览器要实现的4个步骤,基本上用JS来完成这个操作也是一样的。 但是,前面说的步骤好像很麻烦呢,有没有简单的方法来实现这个过程呢? 嗯,有的,这里我将使用JQuery以及jquery.form.js这个插件来演示这个复杂过程的简单处理方案。
示例用的HTML表单还是我前面用的代码,完全不需要修改:
JS代码如下:
$(function(){ $('form').ajaxForm({ success: function(responseText){ alert(responseText); } });});
是的,就是这么简单,只要调用ajaxForm()就行了。你也可以传入任何$.ajax()能接受的参数。 它的作用是:修改表单的提交方式,改成Ajax方式提交。最终当用户点击“提交”按钮时,此时不再是浏览器的提交行为了, 而是使用Ajax的方式提交,提交的URL以及提交方法就是在FORM中指定的参数。
如果您希望要用户点击某个按钮或者链接时,也能提交表单(不经过提交按钮),那么可以使用如下方法:
$(function(){ $("#btnId").click(function(){ $('form').ajaxSubmit({ success: function(responseText){ alert(responseText); } }); }); });
变化很小,只需要将ajaxForm修改成ajaxSubmit就OK了。 与ajaxForm()不同,调用ajaxSubmit()方法将会立即提交表单。
以Ajax方式提交部分表单
在前面的示例中,我们看到以Ajax方式提交一个表单是非常容易的,它完全模拟了浏览器的行为。 不过,有时我们可能需要只提交表单的一部分,为的是更好的局部更新,那么又该如何做呢? 假如我有以下表单的一部分,我只希望在用户某个按钮时将它提交到服务端:
客户名称:
客户电话:
我们可以这样来提交这部分表单的数据:
$("#btnId").click(function(){ $.ajax({ url: "Handler1.ashx", type: "POST", data: $('#divCustomerInfo :text').fieldSerialize(), success: function(responseText){ alert(responseText); } }); return false; });
注意关键的代码行:data: $('#divCustomerInfo :text').fieldSerialize() 注意:此时将由您指定一个【JQuery选择器】来过滤要提交的控件,而不是使用成功控件的筛选逻辑。
或者,您也可以使用下面将要介绍的方法,仍然是使用 data: {} 的方式,但需要手工指定数据成员。
使用JQuery,就不要再拼URL了!
JQuery越来越流行,以至于在创建MVC项目时,VS IDE会把JQuery也准备好了,可能MS认为开发WEB项目离不开JQuery了。 的确,JQuery非常方便,尤其是在处理DOM时,不仅如此,在处理AJAX请求时,也非常方便。
不过,有件事却让我很纳闷:经常看到有人在使用JQuery实现Ajax时,把一堆参数放在URL中传递,当然了, 发送GET请求嘛,这样做不错,但是,让我不解的是:URL是拼接起来的,而且代码又臭又长!
如果是一个简单的参数:"aaa.aspx?id=" + xxId ,这样也就罢了。但是当一堆参数拼接在一起时,可能一下子还看不清楚到底有几个什么样的参数。 而且经验丰富一些的开发人员会发现这样做有时会有乱码问题,可能网上搜过后,知道还有编码的工作要处理,于是又加了一堆编码方法。 到此为止,这段代码会让人看起来很累!
如果您平时也是这样做的,那么我今天就告诉您:不要再拼接URL了! $.ajax()的参数不是有个data成员嘛,用它吧。看代码:
$.ajax({ url: "Handler1.ashx", type: "POST", data: { id: 2, name: "aaa", tel: "~!@#$%^&*()_+-=<>?|", xxxx: "要多少还可以写多少", encoding: "见鬼去吧。?& :)" }, success: function(responseText) { $("#divResult").html(responseText); } });
你说什么,只能使用GET ? 哦,那就改一下 type 参数吧。
$.ajax({ url: "Handler1.ashx", type: "GET", data: { id: 2, name: "aaa", tel: "~!@#$%^&*()_+-=<>?|", xxxx: "要多少还可以写多少", encoding: "见鬼去吧。?& :)" }, success: function(responseText) { $("#divResult").html(responseText); } });
看了这个示例,您还会继续拼URL吗?
说明:为了排版简单,我将参数放在一行了,建议实际使用时,不要挤在一行。
id, name 有什么关系
通常我们在写HTML代码时,会给控件指定一个id属性,这个属性只供JS和CSS使用,在表单提交时,它不起任何作用。
在上面的示例代码中,可能data {}中的各个value就来源于各个不同的控件,那么为那些控件指定相应的id属性将会方便地找到它们。 但是如果不需要用JS和CSS控制的控件,或许它们只是用来显示一些数据(只读),那么就没有必要指定id属性, 当然了,name属性也可以不用给出(避免提交无意义的数据)。
使用C#模拟浏览器提交表单
浏览器也是一个普通的应用程序,.net framework也提供一些类也能让我们直接发起HTTP请求。 今天我将再次用C#来模拟浏览器的提交请求,同时也可以加深对HTTP请求的理解。
示例代码分为二段,一段示范了使用application/x-www-form-urlencoded编码方式提交, 另一段则示范了使用multipart/form-data的编码方式。 为了让大家能再次利用这些代码,我已将关键部分写成独立方法,希望当您有这方面的需求时能马上可以用上。 代码如下:
1. application/x-www-form-urlencoded
////// 向指定的URL地址发起一个POST请求,同时可以上传一些数据项。/// /// 要请求的URL地址/// 要上传的数据项 /// 发送,接收的字符编码方式 ///服务器的返回结果 static string SendHttpRequestPost(string url, Dictionarykeyvalues, Encoding encoding) { if( string.IsNullOrEmpty(url) ) throw new ArgumentNullException("url"); string postData = null; // 将数据项转变成 name1=value1&name2=value2 的形式 if( keyvalues != null && keyvalues.Count > 0 ) { postData = string.Join("&", (from kvp in keyvalues let item = kvp.Key + "=" + HttpUtility.UrlEncode(kvp.Value) select item ).ToArray() ); } if( encoding == null ) encoding = Encoding.UTF8; HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); request.Method = "POST"; request.ContentType = "application/x-www-form-urlencoded; charset=" + encoding.WebName; if( postData != null ) { byte[] buffer = encoding.GetBytes(postData); Stream stream = request.GetRequestStream(); stream.Write(buffer, 0, buffer.Length); stream.Close(); } using( WebResponse response = request.GetResponse() ) { using( StreamReader reader = new StreamReader(response.GetResponseStream(), encoding) ) { return reader.ReadToEnd(); } } } // 调用上面方法的示例代码 string Test_SendHttpRequestPost() { string url = "http://localhost:1272/FormWebSite1/Handler1.ashx"; Dictionary keyvalues = new Dictionary (); keyvalues.Add("CustomerName", "我是李奇峰,$%@+& ?#^/"); keyvalues.Add("CustomerTel", "1381723505x"); return SendHttpRequestPost(url, keyvalues, null); }
2. multipart/form-data 。注意这部分代码有点复杂,因此我加了很多注释。
////// 向指定的URL地址发起一个POST请求,同时可以上传一些数据项以及上传文件。/// /// 要请求的URL地址/// 要上传的数据项 /// 要上传的文件列表 /// 发送数据项,接收的字符编码方式 ///服务器的返回结果 static string SendHttpRequestPost(string url, Dictionarykeyvalues, Dictionary fileList, Encoding encoding) { if( fileList == null ) return SendHttpRequestPost(url, keyvalues, encoding); if( string.IsNullOrEmpty(url) ) throw new ArgumentNullException("url"); if( encoding == null ) encoding = Encoding.UTF8; HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); request.Method = "POST"; // 要上传文件,一定要是POST方法 // 数据块的分隔标记,用于设置请求头,注意:这个地方最好不要使用汉字。 string boundary = "---------------------------" + Guid.NewGuid().ToString("N"); // 数据块的分隔标记,用于写入请求体。 // 注意:前面多了一段: "--" ,而且它们将独占一行。 byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n--" + boundary + "\r\n"); // 设置请求头。指示是一个上传表单,以及各数据块的分隔标记。 request.ContentType = "multipart/form-data; boundary=" + boundary; // 先得到请求流,准备写入数据。 Stream stream = request.GetRequestStream(); if( keyvalues != null && keyvalues.Count > 0 ) { // 写入非文件的keyvalues部分 foreach( KeyValuePair kvp in keyvalues ) { // 写入数据块的分隔标记 stream.Write(boundaryBytes, 0, boundaryBytes.Length); // 写入数据项描述,这里的Value部分可以不用URL编码 string str = string.Format( "Content-Disposition: form-data; name=\"{0}\"\r\n\r\n{1}", kvp.Key, kvp.Value); byte[] data = encoding.GetBytes(str); stream.Write(data, 0, data.Length); } } // 写入要上传的文件 foreach( KeyValuePair kvp in fileList ) { // 写入数据块的分隔标记 stream.Write(boundaryBytes, 0, boundaryBytes.Length); // 写入文件描述,这里设置一个通用的类型描述:application/octet-stream,具体的描述在注册表里有。 string description = string.Format( "Content-Disposition: form-data; name=\"{0}\"; filename=\"{1}\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n", kvp.Key, Path.GetFileName(kvp.Value)); // 注意:这里如果不使用UTF-8,对于汉字会有乱码。 byte[] header = Encoding.UTF8.GetBytes(description); stream.Write(header, 0, header.Length); // 写入文件内容 byte[] body = File.ReadAllBytes(kvp.Value); stream.Write(body, 0, body.Length); } // 写入结束标记 boundaryBytes = Encoding.ASCII.GetBytes("\r\n--" + boundary + "--\r\n"); stream.Write(boundaryBytes, 0, boundaryBytes.Length); stream.Close(); // 开始发起请求,并获取服务器返回的结果。 using( WebResponse response = request.GetResponse() ) { using( StreamReader reader = new StreamReader(response.GetResponseStream(), encoding) ) { return reader.ReadToEnd(); } } } // 调用上面方法的示例代码 string Test_SendHttpRequestPost2() { string url = "http://localhost:1272/FormWebSite1/Handler2.ashx"; Dictionary keyvalues = new Dictionary (); keyvalues.Add("Key1", "本示例代码由 Fish Li 提供"); keyvalues.Add("Key2", "http://www.cnblogs.com/fish-li"); keyvalues.Add("Key3", "来几个特殊字符:~!@#$%^&*()-=_+{}[]:;'\"<>?/.,|\\"); Dictionary fileList = new Dictionary (); fileList.Add("file1", @"H:\AllTempFiles\ascx中文字.gif"); fileList.Add("file2", @"H:\AllTempFiles\asax中文字.gif"); return SendHttpRequestPost(url, keyvalues, fileList, Encoding.UTF8); }
说明:上面的示例方法中,我并没有对KEY编码,是因为:我想大家选用的KEY应该是不需要编码的(英文字母与数字的组合)。 而且,我也没加入对Cookie处理的那部分代码,如果您需要在发送请求时,保留Cookie,那么