HDFS读取文件的重要概念

HDFS一个文件由多个block构成。HDFS在进行block读写的时候是以packet(默认每个packet为64K)为单位进行的。每一个packet由若干个chunk(默认512Byte)组成。Chunk是进行数据校验的基本单位,对每一个chunk生成一个校验和(默认4Byte)并将校验和进行存储。在读取一个block的时候,数据传输的基本单位是packet,每个packet由若干个chunk组成。

HDFS客户端读文件示例代码

FileSystem hdfs = FileSystem.get(new Configuration());
Path path = new Path("/testfile");// reading
FSDataInputStream dis = hdfs.open(path);
byte[] writeBuf = new byte[1024];
int len = dis.read(writeBuf);
System.out.println(new String(writeBuf, 0, len, "UTF-8"));
dis.close();hdfs.close();

文件的打开

HDFS打开一个文件,需要在客户端调用DistributedFileSystem.open(Path f, int bufferSize),其实现为:

public FSDataInputStream open(Path f, int bufferSize) throws IOException {return new DFSClient.DFSDataInputStream(dfs.open(getPathName(f), bufferSize, verifyChecksum, statistics));
}

其中dfs为DistributedFileSystem的成员变量DFSClient,其open函数被调用,其中创建一个DFSInputStream(src, buffersize, verifyChecksum)并返回。

DFSClient.DFSDataInputStream实现了HDFS的FSDataInputStream,里面简单包装了DFSInputStream,实际实现是DFSInputStream完成的。

在DFSInputStream的构造函数中,openInfo函数被调用,其主要从namenode中得到要打开的文件所对应的blocks的信息,实现如下:

synchronized void openInfo() throws IOException {  LocatedBlocks newInfo = callGetBlockLocations(namenode, src, 0, prefetchSize);this.locatedBlocks = newInfo;this.currentNode = null;
}private static LocatedBlocks callGetBlockLocations(ClientProtocol namenode,String src, long start, long length) throws IOException {return namenode.getBlockLocations(src, start, length);
}

LocatedBlocks主要包含一个链表的List<LocatedBlock> blocks,其中每个LocatedBlock包含如下信息:

  • Block b:此block的信息
  • long offset:此block在文件中的偏移量
  • DatanodeInfo[] locs:此block位于哪些DataNode上

上面namenode.getBlockLocations是一个RPC调用,最终调用NameNode类的getBlockLocations函数。

NameNode返回的是根据客户端请求的文件名字,文件偏移量,数据长度,返回文件对应的数据块列表,数据块所在的DataNode节点。

文件的顺序读取

hdfs文件的顺序读取是最经常使用的.

文件顺序读取的时候,客户端利用文件打开的时候得到的FSDataInputStream.read(byte[] buffer, int offset, int length)函数进行文件读操作。

FSDataInputStream会调用其封装的DFSInputStream的read(byte[] buffer, int offset, int length)函数,实现如下:

public synchronized int read(byte buf[], int off, int len) throws IOException {...if (pos < getFileLength()) {int retries = 2;while (retries > 0) {try {if (pos > blockEnd) {//首次pos=0,blockEnd=-1,必定调用方法blockSeekTo,初始化blockEnd,以后是读完了当前块,需要读下一个块,才会调用blockSeekTocurrentNode = blockSeekTo(pos);//根据pos选择块和数据节点,选择算法是遍历块所在的所有数据节点,选择第一个非死亡节点
        }int realLen = Math.min(len, (int) (blockEnd - pos + 1));int result = readBuffer(buf, off, realLen);if (result >= 0) {pos += result;} else {throw new IOException("Unexpected EOS from the reader");}...return result;} catch (ChecksumException ce) {throw ce;            } catch (IOException e) {...if (currentNode != null) { addToDeadNodes(currentNode); }//遇到无法读的DataNode,添加到死亡节点if (--retries == 0) {//尝试读三次都失败,就抛出异常throw e;}}}}return -1;
}

blockSeekTo函数会更新blockEnd,并创建对应的BlockReader,这里的BlockReader的初始化和上面的fetchBlockByteRange差不多,如果客户端和块所属的DataNode是同个节点,则初始化一个通过本地读取的BlockReader,否则创建一个通过Socket连接DataNode的BlockReader。

BlockReader的创建也是通过BlockReader.newBlockReader创建的,具体分析请看后面。

readBuffer方法比较简单,直接调用BlockReader的read方法直接读取数据。

BlockReader的read方法就根据请求的块起始偏移量,长度,通过socket连接DataNode,获取块内容,BlockReader的read方法不会做缓存优化。

文件的随机读取

对于MapReduce,在提交作业时,已经确定了每个map和reduce要读取的文件,文件的偏移量,读取的长度,所以MapReduce使用的大部分是文件的随机读取。

文件随机读取的时候,客户端利用文件打开的时候得到的FSDataInputStream.read(long position, byte[] buffer, int offset, int length)函数进行文件读操作。

FSDataInputStream会调用其封装的DFSInputStream的read(long position, byte[] buffer, int offset, int length)函数,实现如下:

public int read(long position, byte[] buffer, int offset, int length)throws IOException {
  long filelen = getFileLength();
  int realLen = length;if ((position + length) > filelen) {realLen = (int)(filelen - position);}//首先得到包含从offset到offset + length内容的block列表//比如对于64M一个block的文件系统来说,欲读取从100M开始,长度为128M的数据,则block列表包括第2,3,4块blockList<LocatedBlock> blockRange = getBlockRange(position, realLen);int remaining = realLen;//对每一个block,从中读取内容//对于上面的例子,对于第2块block,读取从36M开始,读取长度28M,对于第3块,读取整一块64M,对于第4块,读取从0开始,长度为36M,共128M数据for (LocatedBlock blk : blockRange) {long targetStart = position - blk.getStartOffset();long bytesToRead = Math.min(remaining, blk.getBlockSize() - targetStart);fetchBlockByteRange(blk, targetStart, targetStart + bytesToRead - 1, buffer, offset);remaining -= bytesToRead;position += bytesToRead;offset += bytesToRead;}...return realLen;
}

getBlockRange方法根据文件的偏移量和长度,获取对应的数据块信息。主要是根据NameNode类的getBlockLocations方法实现,并做了缓存和二分查找等优化。

fetchBlockByteRange方法真正从数据块读取内容,实现如下:

private void fetchBlockByteRange(LocatedBlock block, long start,long end, byte[] buf, int offset) throws IOException {Socket dn = null;int numAttempts = block.getLocations().length;//此while循环为读取失败后的重试次数while (dn == null && numAttempts-- > 0 ) {//选择一个DataNode来读取数据DNAddrPair retval = chooseDataNode(block);DatanodeInfo chosenNode = retval.info;InetSocketAddress targetAddr = retval.addr;BlockReader reader = null;int len = (int) (end - start + 1);try {if (shouldTryShortCircuitRead(targetAddr)) {//如果要读取的块所属的DataNode与客户端是同一个节点,直接通过本地磁盘访问,减少网络流量reader = getLocalBlockReader(conf, src, block.getBlock(),accessToken, chosenNode, DFSClient.this.socketTimeout, start);} else {//创建Socket连接到DataNodedn = socketFactory.createSocket();dn.connect(targetAddr, socketTimeout);dn.setSoTimeout(socketTimeout);//利用建立的Socket链接,生成一个reader负责从DataNode读取数据reader = BlockReader.newBlockReader(dn, src, block.getBlock().getBlockId(), accessToken,block.getBlock().getGenerationStamp(), start, len, buffersize, verifyChecksum, clientName);}        //读取数据int nread = reader.readAll(buf, offset, len);return;} finally {IOUtils.closeStream(reader);IOUtils.closeSocket(dn);dn = null;}//如果读取失败,则将此DataNode标记为失败节点
    addToDeadNodes(chosenNode);}
}

读取块内容,会尝试该数据块所在的所有DataNode,如果失败,就把对应的DataNode加入到失败节点,下次选择节点就会忽略失败节点(只在独立的客户端缓存失败节点,不上报到namenode)。

BlockReader的创建也是通过BlockReader.newBlockReader创建的,具体分析请看后面。

最后,通过BlockReader的readAll方法读取块的完整内容。

dfsclient和datanode的通信协议

dfsclient的连接

dfsclient首次连接datanode时,通信协议实现主要是BlockReader.newBlockReader方法的实现,如下:

public static BlockReader newBlockReader( Socket sock, String file,long blockId,long genStamp,long startOffset, long len,int bufferSize, boolean verifyChecksum,String clientName) throws IOException {//使用Socket建立写入流,向DataNode发送读指令DataOutputStream out = new DataOutputStream(new BufferedOutputStream(NetUtils.getOutputStream(sock,HdfsConstants.WRITE_TIMEOUT)));out.writeShort( DataTransferProtocol.DATA_TRANSFER_VERSION );out.write( DataTransferProtocol.OP_READ_BLOCK );out.writeLong( blockId );out.writeLong( genStamp );out.writeLong( startOffset );out.writeLong( len );Text.writeString(out, clientName);out.flush();//使用Socket建立读入流,用于从DataNode读取数据DataInputStream in = new DataInputStream(new BufferedInputStream(NetUtils.getInputStream(sock),bufferSize));short status = in.readShort();//块读取的状态标记,一般是成功DataChecksum checksum = DataChecksum.newDataChecksum( in );long firstChunkOffset = in.readLong();//生成一个reader,主要包含读入流,用于读取数据return new BlockReader( file, blockId, in, checksum, verifyChecksum,startOffset, firstChunkOffset, sock );
}

这里的startOffset是相对于块的起始偏移量,len是要读取的长度。

DataChecksum.newDataChecksum(in),会从DataNode获取该块的checksum加密方式,加密长度。

BlockReader的readAll函数就是用上面生成的DataInputStream读取数据。

下面是是读数据块时,客户端发送的信息:

version operator blockid generationStamp startOffset length clientName  accessToken

operator:byte Client所需要的操作,读取一个block、写入一个block等等
version:short Client所需要的数据与Datanode所提供数据的版本是否一致
blockId:long 所要读取block的blockId
generationStamp:long 所需要读取block的generationStamp
startOffset:long 读取block的的起始位置
length:long 读取block的长度
clientName:String Client的名字
accessToken:Token Client提供的验证信息,用户名密码等

DataNode对dfsclient的响应

DataNode负责与客户端代码的通信协议交互的逻辑,主要是DataXceiver的readBlock方法实现的:

private void readBlock(DataInputStream in) throws IOException {//读取指令long blockId = in.readLong();         Block block = new Block( blockId, 0 , in.readLong());long startOffset = in.readLong();long length = in.readLong();String clientName = Text.readString(in);//创建一个写入流,用于向客户端写数据OutputStream baseStream = NetUtils.getOutputStream(s,datanode.socketWriteTimeout);DataOutputStream out = new DataOutputStream(new BufferedOutputStream(baseStream, SMALL_BUFFER_SIZE));//生成BlockSender用于读取本地的block的数据,并发送给客户端//BlockSender有一个成员变量InputStream blockIn用于读取本地block的数据BlockSender blockSender = new BlockSender(block, startOffset, length,true, true, false, datanode, clientTraceFmt);out.writeShort(DataTransferProtocol.OP_STATUS_SUCCESS); // 发送操作成功的状态//向客户端写入数据long read = blockSender.sendBlock(out, baseStream, null);……} finally {IOUtils.closeStream(out);IOUtils.closeStream(blockSender);}
}

DataXceiver的sendBlock用于发送数据,数据发送包括应答头和后续的数据包。应答头如下(包含DataXceiver中发送的成功标识):

DataXceiver的sendBlock的实现如下:

long sendBlock(DataOutputStream out, OutputStream baseStream, BlockTransferThrottler throttler) throws IOException {...try {try {checksum.writeHeader(out);//写入checksum的加密类型和加密长度if ( chunkOffsetOK ) {out.writeLong( offset );}out.flush();} catch (IOException e) { //socket errorthrow ioeToSocketException(e);}...ByteBuffer pktBuf = ByteBuffer.allocate(pktSize);while (endOffset > offset) {//循环写入数据包long len = sendChunks(pktBuf, maxChunksPerPacket, streamForSendChunks);offset += len;totalRead += len + ((len + bytesPerChecksum - 1)/bytesPerChecksum*checksumSize);seqno++;}try {out.writeInt(0); //标记结束
        out.flush();} catch (IOException e) { //socket errorthrow ioeToSocketException(e);}}...return totalRead;
}

DataXceiver的sendChunks尽可能在一个packet发送多个chunk,chunk的个数由maxChunks和剩余的块内容决定,实现如下:

//默认是crc校验,bytesPerChecksum默认是512,checksumSize默认是4,表示数据块每512个字节,做一次checksum校验,checksum的结果是4个字节
private int sendChunks(ByteBuffer pkt, int maxChunks, OutputStream out) throws IOException {int len = Math.min((int) (endOffset - offset),bytesPerChecksum * maxChunks);//len是要发送的数据长度if (len == 0) {return 0;}int numChunks = (len + bytesPerChecksum - 1)/bytesPerChecksum;//这次要发送的chunk数量int packetLen = len + numChunks*checksumSize + 4;//packetLen是整个包的长度,包括包头,校验码,数据
    pkt.clear();// write packet headerpkt.putInt(packetLen);//整个packet的长度pkt.putLong(offset);//块的偏移量pkt.putLong(seqno);//序列号pkt.put((byte)((offset + len >= endOffset) ? 1 : 0));//是否最后一个packetpkt.putInt(len);//发送的数据长度int checksumOff = pkt.position();int checksumLen = numChunks * checksumSize;byte[] buf = pkt.array();if (checksumSize > 0 && checksumIn != null) {try {checksumIn.readFully(buf, checksumOff, checksumLen);//填充chucksum的内容} catch (IOException e) {...}}int dataOff = checksumOff + checksumLen;if (blockInPosition < 0) {IOUtils.readFully(blockIn, buf, dataOff, len);//填充块数据的内容if (verifyChecksum) {//默认是false,不验证//校验处理
      }}try {//通过socket发送数据到客户端
      } catch (IOException e) {throw ioeToSocketException(e);}...return len;
}

数据组织成数据包来发送,数据包结构如下:

packetLen offset sequenceNum isLastPacket startOffset dataLen checksum   data

packetLen:int packet的长度,包括数据、数据的校验等等
offset:long packet在block中的偏移量
sequenceNum:long 该packet在这次block读取时的序号
isLastPacket:byte packet是否是最后一个
dataLen:int 该packet所包含block数据的长度,纯数据不包括校验和其他
checksum:该packet每一个chunk的校验和,有多少个chunk就有多少个校验和
data:该packet所包含的block数据

数据传输结束的标志,是一个packetLen长度为0的包。客户端可以返回一个两字节的应答OP_STATUS_CHECKSUM_OK(5)

dfsclient读取块内容

hdfs文件的随机和顺序分析逻辑,都分析到BlockReader的readAll方法和read方法,这两个方法完成对数据块的内容读取。

而readAll方法最后也是调用read方法,所以这里重点分析BlockReader的read方法,实现如下:

public synchronized int read(byte[] buf, int off, int len) throws IOException {//第一次read, 忽略前面的额外数据if (lastChunkLen < 0 && startOffset > firstChunkOffset && len > 0) {int toSkip = (int)(startOffset - firstChunkOffset);if ( skipBuf == null ) {skipBuf = new byte[bytesPerChecksum];}if ( super.read(skipBuf, 0, toSkip) != toSkip ) {//忽略// should never happenthrow new IOException("Could not skip required number of bytes");}}boolean eosBefore = gotEOS;int nRead = super.read(buf, off, len);// if gotEOS was set in the previous read and checksum is enabled :if (dnSock != null && gotEOS && !eosBefore && nRead >= 0&& needChecksum()) {//checksum is verified and there are no errors.
        checksumOk(dnSock);}return nRead;
}

super.read即是FSInputChecker的read方法,实现如下

public synchronized int read(byte[] b, int off, int len) throws IOException {//参数检查int n = 0;for (;;) {int nread = read1(b, off + n, len - n);if (nread <= 0) return (n == 0) ? nread : n;n += nread;if (n >= len)return n;}
}
//read1的len被忽略,只返回一个chunk的数据长度(最后一个chunk可能不足一个完整chunk的长度)
private int read1(byte b[], int off, int len)throws IOException {int avail = count-pos;if( avail <= 0 ) {if(len>=buf.length) {//直接读取一个数据chunk到用户buffer,避免多余一次复制        //很巧妙,buf初始化的大小是chunk的大小,默认是512,这里的代码会在块的剩余内容大于一个chunk的大小时调用int nread = readChecksumChunk(b, off, len);return nread;} else {//读取一个数据chunk到本地buffer,也是调用readChecksumChunk方法        //很巧妙,buf初始化大小是chunk的大小,默认是512,这里的代码会在块的剩余内容不足一个chunk的大小时进入调用
        fill();if( count <= 0 ) {return -1;} else {avail = count;}}}//从本地buffer拷贝数据到用户buffer,避免最后一个chunk导致数组越界int cnt = (avail < len) ? avail : len;System.arraycopy(buf, pos, b, off, cnt);pos += cnt;return cnt;
}

FSInputChecker的readChecksumChunk会读取一个数据块的chunk,并做校验,实现如下:

//只返回一个chunk的数据长度(默认512,最后一个chunk可能不足一个完整chunk的长度)
private int readChecksumChunk(byte b[], int off, int len)throws IOException {// invalidate buffercount = pos = 0;int read = 0;boolean retry = true;int retriesLeft = numOfRetries; //本案例中,numOfRetries是1,也就是说不会多次尝试do {retriesLeft--;try {read = readChunk(chunkPos, b, off, len, checksum);if( read > 0 ) {if( needChecksum() ) {//这里会做checksum校验
            sum.update(b, off, read);verifySum(chunkPos);}chunkPos += read;} retry = false;} catch (ChecksumException ce) {...if (retriesLeft == 0) {//本案例中,numOfRetries是1,也就是说不会多次尝试,失败了,直接抛出异常throw ce;}//如果读取的chunk校验失败,以当前的chunkpos为起始偏移量,尝试新的副本if (seekToNewSource(chunkPos)) {seek(chunkPos);} else {//找不到新的副本,抛出异常throw ce;}}} while (retry);return read;
}

readChunk方法由BlockReader实现,分析如下:

//只返回一个chunk的数据长度(默认512,最后一个chunk可能不足一个完整chunk的长度)
protected synchronized int readChunk(long pos, byte[] buf, int offset,int len, byte[] checksumBuf) throws IOException {//读取一个 DATA_CHUNK.long chunkOffset = lastChunkOffset;if ( lastChunkLen > 0 ) {chunkOffset += lastChunkLen;}//如果先前的packet已经读取完毕,就读下一个packet。if (dataLeft <= 0) {//读包的头部int packetLen = in.readInt();long offsetInBlock = in.readLong();long seqno = in.readLong();boolean lastPacketInBlock = in.readBoolean();int dataLen = in.readInt();//校验长度
        lastSeqNo = seqno;isLastPacket = lastPacketInBlock;dataLeft = dataLen;adjustChecksumBytes(dataLen);if (dataLen > 0) {IOUtils.readFully(in, checksumBytes.array(), 0,checksumBytes.limit());//读取当前包的所有数据块内容对应的checksum,后面的流程会讲checksum和读取的chunk内容做校验
        }}int chunkLen = Math.min(dataLeft, bytesPerChecksum); //确定此次读取的chunk长度,正常情况下是一个bytesPerChecksum(512字节),当文件最后不足一个bytesPerChecksum,读取剩余的内容。if ( chunkLen > 0 ) {IOUtils.readFully(in, buf, offset, chunkLen);//读取一个数据块的chunkchecksumBytes.get(checksumBuf, 0, checksumSize);}dataLeft -= chunkLen;lastChunkOffset = chunkOffset;lastChunkLen = chunkLen;...if ( chunkLen == 0 ) {return -1;}return chunkLen;
}

总结

本文前面概要介绍了dfsclient读取文件的示例代码,顺序读取文件和随机读取文件的概要流程,最后还基于dfsclient和datanode读取块的过程,做了一个详细的分析。

参考 http://caibinbupt.iteye.com/blog/284979

http://www.cnblogs.com/forfuture1978/archive/2010/11/10/1874222.html

HDFS dfsclient读文件过程 源码分析相关推荐

  1. HDFS dfsclient写文件过程 源码分析

    HDFS写入文件的重要概念 HDFS一个文件由多个block构成.HDFS在进行block读写的时候是以packet(默认每个packet为64K)为单位进行的.每一个packet由若干个chunk( ...

  2. Spark sql 读文件的源码分析

    从spark jobs监控页面上经常看到这种job:     Listing leaf files and directories for 100 paths: 如图:     这其实是spark s ...

  3. 简单直接让你也读懂springmvc源码分析(3.1)-- HandlerMethodReturnValueHandler

    该源码分析系列文章分如下章节: springmvc源码分析(1)-- DispatcherServlet springmvc源码分析(2)-- HandlerMapping springmvc源码分析 ...

  4. 读写锁ReentrantReadWriteLock源码分析

    文章目录 读写锁的介绍 写锁详解 写锁的获取 写锁的释放 读锁详解 读锁的获取 读锁的释放 锁降级 读写锁的介绍 在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java ...

  5. Android源码分析(十一)-----Android源码中如何引用aar文件

    一:aar文件如何引用 系统Settings中引用bidehelper-1.1.12.aar 文件为例 源码地址:packages/apps/Settings/Android.mk LOCAL_PAT ...

  6. 鸿蒙系统源代码解析,鸿蒙内核源码分析(系统调用篇) | 图解系统调用全貌

    本篇说清楚系统调用 读本篇之前建议先读鸿蒙内核源码分析(总目录)工作模式篇. 本篇通过一张图和七段代码详细说明系统调用的整个过程,代码一捅到底,直到汇编层再也捅不下去. 先看图,这里的模式可以理解为空 ...

  7. 鸿蒙内核代码 行,鸿蒙内核源码分析(CPU篇) | 内核是如何描述CPU的 ? | 祝新的一年牛气冲天 ! | v36.01...

    本篇说清楚CPU 读本篇之前建议先读鸿蒙内核源码分析(总目录)进程/线程篇.指令是稳定的,但指令序列是变化的,只有这样计算机才能够实现用计算来解决一切问题这个目标.计算是稳定的,但计算的数据是多变的, ...

  8. 鸿蒙内核 cpu兼容,鸿蒙内核源码分析(CPU篇) | 整个内核就是一个死循环 | 祝新的一年牛气冲天 ! | v32.04...

    本篇说清楚CPU 读本篇之前建议先读鸿蒙内核源码分析(总目录)进程/线程篇. 指令是稳定的,但指令序列是变化的,只有这样计算机才能够实现用计算来解决一切问题这个目标.计算是稳定的,但计算的数据是多变的 ...

  9. java class源码查看_eclipse查看class文件的源码

    eclipse查看class文件的源码: 1.网上下载jadClipse的jar包和执行文件jad.exe和 net.sf.jadclipse_3.3.0.jar. 2.把上面下载的jar包放在ecp ...

  10. python3怎么定义long_python3 整数类型PyLongObject 和PyObject源码分析

    python3 整数类型PyLongObject 和PyObject源码分析 一 测试环境介绍和准备 测试环境: 操作系统:windows10 Python版本:3.7.0 下载地址 VS版本:vs2 ...

最新文章

  1. CA证书服务器(4) 证书、CA、PKI
  2. 学习python需要什么基础-学习python需要什么基础
  3. 【CF582E】Boolean Function 树形DP+FWT
  4. 小议看板列与职能筒仓
  5. 浅析“字典--NSDirctionary”理论
  6. 计算机里的网络是什么意思啊,计算机网络中本地站点是什么意思
  7. (32) css—opcity属性
  8. mxnet(gluon)—— 模型、数据集、损失函数、优化子等类、接口大全
  9. Spring Cloud消息驱动整合
  10. atomic原子类实现机制_原子操作CAS及其实现类
  11. 软件测试理论基础知识
  12. 无根树任意根深度加强版
  13. 9大代理服务器软件的比较与分析之CCProxy、Squid
  14. 麦克纳姆轮(全向轮)
  15. 利用Java进行身份证正反面信息识别
  16. AI当“暖男”:给裸照自动穿上比基尼
  17. Flash鼠绘教程:临摹徐悲鸿的骏马图
  18. rtthread + STM32F407VE + esp8266 +SD卡 从网络下载文件存到SD卡中
  19. 国内优秀的多用户商城系统盘点(2022年整理)
  20. navicat for mysql新建表_navicat怎么新建表

热门文章

  1. 请你预想一下量子计算机未来,直播,研究量子计算机的我被曝光了
  2. 『梦想城镇』终极攻略
  3. 九宫格摆法_九宫格照片墙摆法,9张照片墙6竖3横怎么摆?
  4. 腾讯视频云流媒体技术探索
  5. cordova环境配置步骤
  6. 7-4 愿天下有情人都是失散多年的兄妹 (25 分)(第十二周编程题)
  7. 根目录在哪里 根目录下的目录有什么作用
  8. PDF修改文字的步骤
  9. 服务器中的软件如何备份文件夹在哪里找,PS的自动备份文件保存位置在哪里?
  10. 参与流片是一种怎样的体验