Asp.NET大文件上传开发总结

以前开发过一个用Asp.NET上传大文件的代码,由于是在NET1.1下开发的,发现不能在NET.20下运行,前几天将这个问题解决了,现在把整个开发过程整理一下,供大家参考。由于内容较多,所以我打算分成六篇来写,第一篇写一下需要解决的问题和大致解决思路,再用四篇具体写一下开发中需要解决的四个主要问题,最后再写一个组件的使用方法。

大家可以点击这里下载NET1.1的源代码。

  • 1、 问题提出:

使用Asp.NET在上传文件时,IIS接收到请求内容后,发送到Asp.NET的管道中,然后Asp.NET的ISAPI将读取的内容是全部装入服务器内存(NET2.0在请求内容较大时,会存放到临时文件中,见后面的开发说明),在上传文件时,消耗服务器资源较多,所以Asp.NET对上传文件的大小会有限制,且不能提供上传进度显示。而我们在实际应用中,往往会上传较大的文件的现象,这时,可以通过修改配置文件将最大将求长度配置设置得很大,但这样,服务器将会消耗大量内存。

为此,如果可以在IIS将客户端发送的内容发送到Asp.NET的管道时,随着服务器接收内容的同时,将文件内容写到服务器磁盘中,然后在 Asp.NET的管道中只放入请求的对应的Form内容。这样可以避开Asp.NET上传文件的大小限制,同时提供服务器接收进度显示。

  • 2、 解决方案分析:

Asp.NET提供了HttpModule功能,提供了让用户处理客户请求的手段,要实现HttpModule功能,用户只需实现 System.Web.IHttpModule接口。System.Web.IHttpModule定义了两个方法void Init (HttpApplication context)和void Dispose (),Init方法给我们提供了一个类型为HttpApplication的参数,这个HttpApplication类型中,给我们提供了处理 Asp.NET页面生命周期中发生的各种事件(具体请参照MSDN),我们只需要在最早的事件BeginRequest中,处理数据接收,就可以避开Asp.NET的最大请求长度限制。

为了不改变Asp.NET对页面处理逻辑,我们需要在提供文件内容后,将其它请求内容正确封装给Asp.NET,为此我们需要分析客户端发送到服务器端内容的具体格式,我们可以通过其它工具,提供这些内容,这些内容大致如下:

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="UploadID"

 

de858d87-e8b8-4f7a-a832-357da2efdf13

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="__VIEWSTATE"

 

/wEPDwULLTIwODE1OTkzNTUPZBYCAgMPFgIeB2VuY3R5cGUFE211bHRpcGFydC9mb3JtLWRhdGFkZAQ0pcd9kiSZ7/MOe0enKOTGFxMk

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="TextBox1"

 

 

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="TextBox2"

 

 

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="file1"; filename="C:/Documents and Settings/Administrator/??????/componentart.web.ui.rar"

Content-Type: application/x-rar-compressed

 

Rar! ??s这里我把文件具体内容去掉了,我们提取文件后,封送给Asp.NET也就是去掉这部分后的内容

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="Button1"

 

开始上传

-----------------------------7d81e441d025c

Content-Disposition: form-data; name="__EVENTVALIDATION"

 

/wEWBAL1w7VGAuzRsusGAuzR9tkMAoznisYGMJIxZyapejeOQaIIwcOkPrWK6nE=

-----------------------------7d81e441d025c--

    请注意红色注释部分,那里原来是上传的文件内容,我们封送给Asp.NET就应当时去掉文件内容后内容,这样,页面控件的值、页面事件就会正确的发送到Asp.NET页面。只是页面不能正确获取上传文件的内容。

    这里又出现了三个问题:一是:我们如何获取文件内容,如何确定那部分内容是上传的文件内容而不是页面其它控件内容;二是:我们如何在提取文件内容后让 Asp.NET页面正确获取文件;三是:我们如何将改写后的内容封送给Asp.NET,让它感觉不到我们提取了文件内容。

    只要解决了上面三个问题,那大文件上传的问题就解决了,最后就是提供进度显示,由于内容较多,我分四部分来介绍这三个问题和进度显示。

待续...

不知地震什么时候结束,为了给老婆小孩守夜,看来还不能睡,那就把第二篇也写了吧,只是不知对大家有没有用哟。

为了提供文件内容,我们需要首先需确定客户请求中发送的有文件内容,然后确定文件内容的位置。这部分对应的代码如下:

 1 HttpApplication app  =  sender  as  HttpApplication;
 2             HttpWorkerRequest request  =  GetWorkerRequest(app.Context);
 3             
 4              if  ( ! IsUploadRequest(app.Request))  return // 不是文件上传,则退出
 5             
 6              string  sContentType  =  app.Request.ContentType.ToLower();
 7              byte [] arrBoundary  =  GetMultipartBoundary(sContentType);
 8              int  ContentLength  =  app.Request.ContentLength;  // 信息体的总长度
 9
10             DataReader dataReader  =   new  DataReader(app.Context.Request.ContentEncoding, arrBoundary);
11             DateTime startDate  =  DateTime.Now;
12              byte [] arrBuffer  =  request.GetPreloadedEntityBody();
13              if  (arrBuffer  ==   null )
14              {
15                arrBuffer = new Byte[0];
16                tempFile.Close();
17                return//没有读取到信息体
18            }

19              else
20              {
21            // 这里是对文件内容的处理
22            }

上面的代码中,我们先获取Asp.NET对客户端请求的处理对象HttpWorkerRequest,然后根据这个对象的ContentType 属性是否为multipart/form-data来确定对应的请求是否有上传文件,如果没有上传文件,就不处理此请求,以提高处理效率。这样处理的依据是,在有文件上传的HTML Form中,对应的enctype属性为multipart/form-data。这样就解决了确定客户端请求中是否发送有文件内容。
要确定文件的内容位置,为此我们需要使用Reflector工具反编译 System.Web.dllHttpRequest的代码,可以发现有一个GetEntireRawContent方法,这个方法里,调用了HttpWorkerRequest对的 GetPreloadedEntityBody方法来获取数据,可喜的是,这个方法是Public的,所以我们也可以直接调此方法来获取客户端发送的数据。
获取数据后,我们可以将这些数据写入到一个文件中,这些数据是byte[]类型的,在写入文件时,因为Http协议是基于文本的,所以我们可以采用System.Text.Encoding.GetString方法,将这些字节数组编码成字符串,我这里的编码采用ASCII,只是这样请求中的中文就成了乱码。这样就可以获取请求的内容的文体格式,以方便我们分析(在代码中,这个写文件的功能我已去除,因为这里写全部请求内容只是用于分析使用,请参见第一篇文章中我列出的请求内容示例)。
通过分析这些请求内容,我们可以发现:每个页面控件,均可以在这里找到对应的内容,两个控件内容间是用字符串“-----------------------------7d81e441d025c”分隔的。同时在我们反编译 System.Web.dllHttpRequest的代码时,也可以发有GetMultipartBoundary这个方法,这个方法我们可以从字面意思得知是取分隔标识。我们把这个方法提取出来:
 1 private   byte [] GetMultipartBoundary()
 2 {
 3    string attributeFromHeader = GetAttributeFromHeader(this.ContentType, "boundary");
 4    if (attributeFromHeader == null)
 5    {
 6        return null;
 7    }

 8    attributeFromHeader = "--" + attributeFromHeader;
 9    return Encoding.ASCII.GetBytes(attributeFromHeader.ToCharArray());
10}

 

 

 

 

 

 

 

 


然后我们采用这个方法,就可以从请求的内容中提取出类似“-----------------------------7d81e441d025c”这样的字符串。这样,我们就可以分隔页面内不同控件的内容了。
最后,我们分析文件上传控件的内容:

Content - Disposition: form - data; name = " file1 " ; filename = " C:/Documents and Settings/Administrator/??????/componentart.web.ui.rar "
Content
- Type: application / x - rar - compressed

Rar
!   ?? s


我们可以发现,文件上传控件有一个filename属性,然后,下一行指定文件的MIME类型,随后这个空白行,在空白行下面,直到下一处类似“-----------------------------7d81e441d025c”标志字符串间,就是客户端发送的文件内容。我们只需把这些内容写入到服务器文件中即可。

这里,文件内容的提取的问题就可以解决了。在提取文件内容时,对处理标志字符串时需要特别处理,防止标志字符分别读取到两个缓冲区中,所以这部分代码还是比较多的,具体请参看源代码中的DataReader类。

第二篇就写到这,实在是太困了,后面的等一下再写吧!

这一篇相对就要简单一些了,只需要确定对文件的处理思路和与Asp.NET页面信息的传递的问题。

        .NET1.1中,Asp.NET将获取的请求内容放入到一个Byte[]类型中,但在Asp.NET2.0中,却将数据封装到一个类型为 System.Web.HttpRawUploadedContent的对象中,在这个新对象中,有一个重要的属性:_file,这个属性的类型为 TempFile。正好是这个类型提供了将请求内容保存到临时文件的功能。这些信息都可能通过Reflector工具反编译获得(所以啊,做.NET开发,这个工具是必备的)。

        在HttpRequest类中,我们可以发现如下用于分析页面各控件值的代码:

private  MultipartContentElement[] GetMultipartContent()
{
    
if (this._multipartContentElements == null)
    
{
        
byte[] multipartBoundary = this.GetMultipartBoundary();
        
if (multipartBoundary == null)
        
{
            
return new MultipartContentElement[0];
        }

        HttpRawUploadedContent entireRawContent 
= this.GetEntireRawContent();
        
if (entireRawContent == null)
        
{
            
return new MultipartContentElement[0];
        }

        
this._multipartContentElements = HttpMultipartContentTemplateParser.Parse(entireRawContent, entireRawContent.Length, multipartBoundary, this.ContentEncoding);
    }

    
return this._multipartContentElements;
}

        说了这么多,只是说明了一个问题:通过改变Asp.NET页面对象的属性值来向页面传送是比较麻烦的。所以我采取单件模式的设计思路。具体是,将获取的文件内容写入到磁盘文件;每次上传文件过程中,初始化一个会话标志,页面通过这个会话标志来提取这些文件信息。在系统中,定义了两个类用来处理这项工作:

 
UploadContext用于封装文件信息。
类UploadContextFactory用于会话标志的管理。

在上传页面里,组件将在页面中注册一个名为UploadID的Hidden字段。存放一个GUID作为会话标识。这个标志会在文件上传时,出现在请求内容中,组件通过获取这个会话标志和文件内容,正确将文件保存到临时目录中,并将提取文件信息供页面使用。页面则根据Request["UploadID"]字段在类UploadContextFactory提取上传文件对应的会话标志,并获取文件信息。这样就实现了页面对文件内容的提取。

这样,要求在上传页面的Page_Load事件中,采用如下语句来初始化一个会话,并指定存放文件的目录。

UploadContext context  =  UploadContextFactory.InitUploadContext( this @" c:/myupload/ " );


在需要获取文件信息的地方:采用如果语句来获取:

UploadContext context  =  UploadContextFactory.GetUploadContext();
            
if  ((context  !=   null &&  (context.Status  ==  uploadStatus.Complete))
            
{
                context.SaveFile(file1.ClientID, Request.MapPath(
"/myupload/"));
            }

 


当然这里就存在一个问题是:会话标志过期及没有初始化标志上传文件时的处理(如不请求页面,而直接发送文件,这可能会在用户使用其它工具发送时出现)。这个问题暂时没有处理。

现在,上传的文件可以上传到服务器了,页面也可以获取上传的文件内容以作进一步处理,如限制文件类型、存放到数据库等。

感觉思路有点乱了,那就先写出来,我随后再来整理。

这个功能主要是为了不影响Asp.NET的处理模型,将请求中除上传的文件内容外的其它正常请求内容继续发送到 Asp.NET页面处理。

    我们通过分析 HttpRequest对象的 GetEntireRawContent()方法在读取数据,并且发现这个方法在读取数据时,是将数据赋值到了 _rawContent属性,所以我们只要能把处理后的数据赋值给 HttpRequest对象的 _rawContent属性,即可封送数据给 Asp.NET页面。同时,由于我们改写了请求内容,所以 HttpRequest _contentLength也应当改写。

    由于这些属性方式是私有的,我们不能直接访问,所以我们必须采用反射的方法给属性赋值。代码如下:

    private   void  InjectTextParts(HttpRequest request,  byte [] textParts)
          
  {
             BindingFlags flags1 
=  BindingFlags.NonPublic  |  BindingFlags.Instance;
             Type type1 
=  request.GetType();
             FieldInfo info1 
=  type1.GetField( " _rawContent " , flags1);
             FieldInfo info2 
=  type1.GetField( " _contentLength " , flags1);
             
             
if  ((info1  !=   null &&  (info2  !=   null ))
              
 {
                 Assembly web 
=  Assembly.GetAssembly( typeof (HttpRequest));
                 Type hraw 
=  web.GetType( " System.Web.HttpRawUploadedContent " );
                 
object [] argList  =   new   object [ 2 ];
                 argList[
0 =  textParts.Length  +   1024 ;
                 argList[
1 =  textParts.Length;
 
                 CultureInfo currCulture 
=  CultureInfo.CurrentCulture;
                 
object  httpRawUploadedContent  =  Activator.CreateInstance(hraw,
                                                                          BindingFlags.NonPublic 
|  BindingFlags.Instance,
                                                                          
null ,
                                                                         argList,
                                                                          currCulture,
                                                                          
null );
 
                Type contentType 
=  httpRawUploadedContent.GetType();
 
                FieldInfo dataField 
=  contentType.GetField( " _data " , flags1);
                dataField.SetValue(httpRawUploadedContent, textParts);
 
                FieldInfo lengthField 
=  contentType.GetField( " _length " , flags1);
                lengthField.SetValue(httpRawUploadedContent, textParts.Length);
 
                FieldInfo fileThresholdField 
=  contentType.GetField( " _fileThreshold " , flags1);
                fileThresholdField.SetValue(httpRawUploadedContent, textParts.Length 
+   1024 );
 
                info1.SetValue(request, httpRawUploadedContent);
                info2.SetValue(request, textParts.Length);
             }
 

         }
 



    在这里,在将代码用到 NET2.0 时,遇到了问题。因为在 NET1.1 时, HttpRequest _rawContent 属性是一个 byte[] 类型,但到了 NET2.0 ,这个属性变成了 HttpRawUploadedContent 类型的对象,出现了赋值失败。通过查看 HttpRawUploadedContent 反射代码,发现原来这个类是为了将过大的请求内容写到磁盘文件中。没办法,我只有创建这个类的实例后再赋值给 HttpRequest _rawContent 属性。这下能上传文件了,可是, Asp.NET 页面的控件值全部丢失了,数据没有有效的封送到页面。嘿嘿,这里正好发挥我刚学会的一项技术 --- 调试框架源码。于是从 GetEntireRawContent() 方法一路跟踪下去,发现是由于 HttpRawUploadedContent 对象的 _length 属性为零,导致 HttpRequest 对象认为没有有效数据而没有分析数据。导致通过 HttpRequest Params 属性和 Forms 数据不能访问到请求内容。给这两个数据赋值后,哈!哈!一切顺利。上传过程全部结束!
由于NET2.0代码只是初步完成,等我在作进一步测试后,将在写完这部分总结后,提供给大家下载。

这几天又出去躲地震了,本来以为没什么了,让电视里的那些专家出来一吓,害得我又出去受了几天罪,这就象网上说的:比地震更可怕的是余震,比余震更可怕的是预报余震,比预报余震更可怕预报了余震却一直不震。所以这篇就写得迟了一些,今天打算回家睡了,所以随笔也就接到写了。
首先先看看我做的上传进度信息显示效果吧:

 


        怎么样,就点象C/S效果吧。不过这里正象前面一位网友说的:这是靠不停的查询服务器来获得进度信息的,这一点对提高系统的并发度有影响。不过,我想:使用Asp.NET上传文件的应用系统,一般都不会有太多的并发用户,之所以要用Asp.NET上传文件主要是为了提高用户体验,简化系统部署和开发吧。在一般的信息化系统使用这个组件还是很方便的。大家说呢。

        前面只要把文件上传处理了,这里要从服务器获取进度信息相对就容易多了,我这里用的是弹出模态窗口,用刷新页面的方式来显示进度信息,当然也可以使用AJAX技术获取进度信息,也可以不用模态窗口,而用DIV标签或IFrame方式,不过,如果不模态窗口方式,那在文件上传完成后,这些进度信息也会自动随着页面刷新而消失。大家可以实现这几种方式,如果有朋友做出来了,别忘了给我一份哟。

对于进度的显示,大家可以下载我的源码看看就会明白,我就不多说了,我只提示一下网友需要注意的几个地方:在进度显示页面里,要设<base target="_self"> 标签,以免在IE5.0下面不能正常显示进度信息,刷新时,可能会打开新的窗口。
可以修改这里Response.AppendHeader("Refresh","3");,控制刷新频率,这里有一个效率和用户体验的权衡,刷新时间过小,会增加服务器负担,时间过大,进度条显示会呈跳跃状。
其它的请网友下载我的源代码吧,一看就会明白,我的第一篇文章里有net1.1版本的下载 ,用户可以在我的下一篇也是本系列的最后一篇下载Net2.0版本。

1编写目的

为了方便用户使用 大文件上传组件,特写使用说明书,以指明调用组件的接口,操作流程和注意事项。

2背景

由于 ASP.NET在上传文件时,是全部装入服务器内存,在上传文件时,消耗服务器资源较多,且不能提供上传进度显示,由于消耗服务器资源较多,所以 ASP.NET上传文件时往往大小受到限制。此组件解决了所有这些问题

3定义

大文件:大文件主要意思是指大小不受限制,

Web.config:是 ASP.NET的配置文件。

4功能

所有需要用 ASP.NET上传文件的地方,均可以使用本组件。本组件可以解除 ASP.NET对上传文件大小的限制,同时提供上传进度提示,用户还可以选择中断上传过程。减少对服务器资源的消耗(经测试,服务器资源消耗与运行普通 ASP.NET页面相差无几)

5、组件主要类及操作:

5.1 BigFileUploadModuleHandle

大文件上传处理模块,用于在客户端请求发送到服务器上后,分析请求内容,如果是上传文件,则在 IIS将数据出发送到 ASP.NET的管道中时,读取数据,写入文件,并重新组织请求内容,重新组织的内容是去掉了文件内容的请求内容。页面在接收到请求后,除文件上传控件不能读取文件内容外,其它组件的属性可以正常访问。

5.2 DataReader

请求数据分析和文件写入。

5.3 UpFileInfo

上传文件属性结构,供组件内部使用。

5.4 UploadContext

文件上传环境,组件在使用时,主要使用此类。具体有以下方法和属性:

UploadContext :构造函数,需传入文件上传页面对象和文件临时存放目录。外部不能直接调用些构造函数,要构建文件上传环境,需调用 UploadContextFactory的提供的静态方法

TmepFileDir :获取和设置上传临时文件存放路径

FileNames 上传的文件名列表

TotalLength 发送信息总长度,包括页面所有控件请求内容

Readedlength 已接收的信息长度 包括面页控件请求内容

StartReadDateTime 开始接收时间    

FileConIds 页面文件上传控件 ID列表,对文件上传控件在服务器端运行时,为控件的 UniqueID

GUID :上传文件唯一标志

Abort  :设置和读取是否中断上传过程

Ratio 上传速率 ,返回每秒上传的字节数

FormatRatio 获取格式化的上传速度,以适当的字节, K字节, M字节表示

LeftTime 估计上传剩余时间 ,以秒为单位

FormatLeftTime 获取格式上的上传剩余时间,适当的以小时,分钟,秒表示

CurrentFile 当前正在处理的上传文件

Status 上传状态

FormatStatus  以字符串格式,返回当前上传操作的状态

SaveFile 另存上传的数据文件,此方法主要将文件从临时存放目录移到用户需要求的目录,由于采取移动文件办法,所以此方法调用成功后,临时文件对应文件将被移走。

GetFileNameByControl 根据页面文件上传控件名称,获取上传的文件名

Dispose 当前上下文环境中接收的所有文件

GetFileName  :根据文件上传控件名称,获取对应的上传的文件。

5.5 UploadContextFactory

创建和获取文件上传环境。

InitUploadContext(System.Web.UI.Page page,string TempFileDir) 根据页面对象和上传文件临时目录文件夹,获取一个文件上传上下文类实例

GetUploadContext 根据页面发送的上传会话编号,获取文件上传上下文

GetUploadContext(string GUID) 根据会话编号的 GUID获取文件上传的上下文

Release 在页面逻辑处理完成后,释放上传上下文,并删除临时文件

uploadStatus :文件上传状态枚举类

6 使用方法及注意事项

1、  将组件 dll复制到应用系统 bin目录,然后再 VS.NET中引用本组件,

2、  web.config增加 ASP.NET处理模块

<httpModules>

<add name="BigFileUploadModuleHandle " type="HelpSoft.BigFileUploadModuleHandle,BigFileUploadHandle" />

</httpModules>

3、  在要上传文件的 asp.net页面的页面装入事件中或初始化事件中,初始化文件上传环境,注册文件上传存放的临时文件夹,如下面代码:

private void Page_Load(object sender, System.EventArgs e)

{

       UploadContext context = UploadContextFactory.InitUploadContext(this, @"c:" myupload"");

}

提示: A、文件存放目录要求设置相应的访问权限,目录必须存在

      B、初始化上传环境后,页面须在一天内发送文件。否则上传环境失效。

      C、初始化上传环境时,组件将在页面内注册隐藏域,记录上传会话唯一标志。隐藏域命名为 UploadID, 页面不能有同名控件。

        D、需要上传文件表单编码方式必须为 multipart/form-data,(对于文件上传控件作为服务器端控件运行时,系统会自动加入此编码方式,否则需用户手工添加)

        E、要使本上传组件生效,必须在页面访问时初始化上传环境,和设置表单编码方式,反之,如不希望本组件对特定页面生效,只可取消前两条件之一。

4、  在文件上传提交事件中,直接从上传文件环境工厂类中获取文件上传环境,进一步获取上传的文件信息。类似代码如下:

UploadContext context = UploadContextFactory.GetUploadContext();

string FileName = context.GetFileNameByControl(resFile.UniqueID);

string filePath=@"c:"workdir";           

if (context.SaveFile(resFile.UniqueID,filePath+FileName))

{

       UploadContextFactory.Release();

}

提示: A、根据控件名获取对应的上传文件信息时,对服务器控件需要传入控件唯一标志,对非服务器控件,需传入控件名称。

       B、对文件另存时的目录访问权限,是否存在、可用空间大小,由页面调用者检查处理。如此类型错误发生,系统将引发对应异常。

C、如没找到控件对应文件(可能是客户端没有指定文件或传的控件标志有误)、临时目录中对应文件也不存在(已成功调用过此方法,文件已被移走),本方法将返回假,操作成功后,方法返回真。

D UploadContextFactory.Release()将从系统缓存中清除上传环境对象,将不能用 GetUploadContext方法获取上传环境,但已取得的上传环境仍然可用。

5、  读取文件上传进度时,需另建一个 ASPX页面,读取进度主要是根据上传文件的会话唯一标志,从系统缓存中(通过 GetUploadContext(string GUID)方法))获取上传文件环境,访问此环境类,即可以获取文件上传开始时间、信息总长度、当前已传送信息长度、速度、估计剩余时间等信息。系统再使用定时刷新的办法,不断获得上传进度信息,即可形成上传进度提示。

进度显示可以使用 XML无回刷新方式,也可以使用页面定时刷新方式。

6、  要在传输过程中,中断传输入过程,只需用会话唯一标志,获取上传环境,将一上传环境的 abort属性设为真,系统将主动与客户端断开连接,中断上传过程,同时页面提交的其它数据也将取消。

现在把NET2.0的源代码提供给大家,希望对大家有用。点击这里下载 源码。

由于我错误的将我的测试代码放到网上了,让许多朋友调试过通不过。今天我完整的将系统测试了一遍,实现了在默认的.NET配置下,上传28M文件。并且修改了以前的一个BUG:最后一次读取数据时,时间过长。源码已更新,欢迎各位指正。

在测试时,请在C: 盘根目录下建一个名为myupload 目录用于存放临时文件,同时在IIS中建一个名称myupload 的虚拟目录,存放最终文件。需要保证Asp.net对这两个目录有对应的操作权限。同时,你也可以在Default.aspx.cs中,修改这两个目录。

我是在英文XP+Asp.NET 2.0下测试通过。

欢迎大家指正。

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值