【多文件自平衡云传输 四】断点续传

接上篇【多文件自平衡云传输 三】发送端接收端收发多文件&资源发现完成了多文件收发之后。需要考虑的是断点续传问题。

何时产生断点

  1. 发送端掉线:在发送端正在发送数据的时候,也即与服务器通信的时候,如果此时发送端掉线,那么肯定的为该发送端分配的发送任务一定是不能完成,也即接收端接收的数据不完整。这就导致产生断点。
  2. 接收端掉线:接收端接收数据的时候掉线,导致接收数据不完整。这种断点的解决方案:在接收端每次接收到某个文件片段时,将该片段信息记录到外存,可以使用log4j,然后下一次启动,第一次接收时从外存中获知自己已经接收了哪些片段,哪些没有接收,对于没有接收到的片段,再重新分配给发送端。

下面只是针对发送端掉线产生断点的情况做出解决。

要能获知断点详细信息就需先知道自己哪些片段没有接收到。之前用到FileSectionInfo类表示一个文件片段的具体信息,也没有考虑到断点,所以首先需要一个类来表示断点详细信息(也就是需要记录自己没有接收到的片段)。

UnsectionInfo

public class UnsectionInfo {
    private long offset;                //表示未接收片段的偏移量
    private long len;                   //表示未接收片段的长度
	
    UnsectionInfo() {}

    UnsectionInfo(long offset, long len) {
	this.offset = offset;
	this.len = len;
    }

    public long getOffset() {
	return offset;
    }

    void setOffset(long offset) {
		this.offset = offset;
    }

    public long getLen() {
	return len;
    }

    void setLen(long len) {
	this.len = len;
    }
	
    /**
     * 是否符合范围:当前未接收片段的偏移量 + 片段长度  > offset(当前接收的片段偏移量)      
     * 那就说明当前接收的片段    不属于  该未接收片段的范围
     * 
     * 如果符合范围则会返回true
     */
    boolean isRange(long recOffset) {
	return this.offset + this.len > recOffset;
    }

    @Override
    public String toString() {
	return offset + " : " + len;
    }
	
}

上面可以表示一个未接收片段的信息,由于一个文件可能会产生多个未接收片段,故一个文件应该存在一个未接收片段的集合。所以可以将片段集合与文件对应起来,再封装一个类,如下(现在只关心其数据结构和最基本的功能):

UnreceiveSection

public class UnreceiveSection {
    private int fileNo;			                //文件编号
    private List<UnsectionInfo> unsectionList;		//一个文件对应的未接收片段集合	
	
    public UnreceiveSection(int fileNo, long fileSize) {
	this.fileNo = fileNo;
	this.unsectionList = new LinkedList<>();
	this.unsectionList.add(new UnsectionInfo(0, fileSize));
    }
	
    public UnreceiveSection(int fileNo) {
	this.fileNo = fileNo;
	this.unsectionList = new LinkedList<>();
    }
    
    //添加未接收的片段
    public void addUnreceiveSection(long offset, long len) {
	this.unsectionList.add(new UnsectionInfo(offset, len));
    }
	
    public int getFileNo() {
	return this.fileNo;
    }

    //如果该文件完全接收成功了,那么这个集合一定是空的
    public boolean isReceived() {
	return unsectionList.isEmpty();
    }

    //获取未接收片段的集合
    public List<UnsectionInfo> getSectionList() {
	return unsectionList;
    }
}

由于是多文件传输,故一个发送端可能再负责多个文件的不同片段,在上篇中文件均衡分配策略中也有体现这一点,也就是说一个发送端如果掉线,那么我们所接收的多个文件可能都会存在未接收的片段,也就是会存在一个UnreceiveSection的集合。首先可以将未接收片段封装到一个Map中,一文件编号为键来区分。故需要对之前的ResourceFilePool做出修改。之前是在该类中主要封装了一个文件对应的读写操作对象。现在需要将未接收片段加进去。修改如下(只展示变动过的代码)

public class ResourceFilePool {
    private Map<Integer, FileReadWrite> fileAcceptPool;		
    private Map<Integer, UnreceiveSection> fileUnreceivePool;	//标识未接收到的片段
    private IFileReadWriteIntercepter fileReadWriteIntercepter= new FileReadWriteIntercepterAdapter();

    public ResourceFilePool() {
        this.fileAcceptPool = new HashMap<Integer, FileReadWrite>();
	this.fileUnreceivePool = new HashMap<Integer, UnreceiveSection>();
    }
    
    public void addFileList(SourceFileList sourceFileList) {
	List<FileInfo> fileInfoList = sourceFileList.getFileInfoList();
		
	//这里遍历的是不同的单个文件信息
	for (FileInfo fileInfo : fileInfoList) {
	    int fileNo = fileInfo.getFileNo();
	    FileReadWrite frw = new FileReadWrite(fileNo, sourceFileList.getAbsoluteRoot() + fileInfo.getFilePath());
            
	    frw.setIntercepter(fileReadWriteIntercepter);
    	    fileAcceptPool.put(fileInfo.getFileNo(), frw);				
	  	
	    //注意:该方法是根据原多文件信息,来创造FileReadWrite对象。
            //FileReadWrite对象只想让其初始化一次在发送接收开始前
            //最开始没有进行收发的时候将其初始化,而且所有文件都是未接收状态,而且每个文件的未接收片段都只有一个就是它本身
	    UnreceiveSection unreceiveSection = new UnreceiveSection(fileNo, fileInfo.getFileSize());
	    this.fileUnreceivePool.put(fileNo, unreceiveSection);
	}
    }

    public UnreceiveSection getUnreceiveSection(int fileNo) {
	return this.fileUnreceivePool.get(fileNo);
    }

    public void removeUnreceiveSection(int fileNo) {
	this.fileUnreceivePool.remove(fileNo);
    }
	
    public boolean isEmpty() {
	return this.fileUnreceivePool.isEmpty();
    }
    
    //根据UnsectionInfo集合得到FileSectionInfo集合
    public List<FileSectionInfo> getUnFileSectionList() {
	List<FileSectionInfo> sectionInfoList = new ArrayList<FileSectionInfo>();
	UnreceiveSection unreceiveSection = null;
	List<UnsectionInfo> unsectionInfoList = null;
		
	for(Integer key : fileUnreceivePool.keySet()) {
	    unreceiveSection = fileUnreceivePool.get(key);
	    int fileNo = unreceiveSection.getFileNo();
	    unsectionInfoList = unreceiveSection.getSectionList();
	    for(UnsectionInfo unsectionInfo : unsectionInfoList) {
	        int len = (int) unsectionInfo.getLen();
	        long offset = unsectionInfo.getOffset();
				
	        FileSectionInfo sectionInfo = new FileSectionInfo(fileNo, offset, len);
	        sectionInfoList.add(sectionInfo);
	    }
        }
	return sectionInfoList;
    }

    //用于测试---输出断点信息
    public void showBreakPoint() {
	for(Integer key : fileUnreceivePool.keySet()) {
	    UnreceiveSection unreceiveSection= fileUnreceivePool.get(key);
	    System.out.println(unreceiveSection.getFileNo() + " : " + unreceiveSection.getSectionList());
	}
    }    
}

未接收片段何时更新?

当然是接收到了一个片段更新一次。

接收端的ResourcePool是在实例化接收服务器对象时被创建的,而真正的填充容器初始化未接收片段信息是在第一次发送请求之前,而且只会填充一次。而且在与磁盘交互文件读写的时候特意留了扩展的接口如下:

public class FileReadWriteIntercepterAdapter implements IFileReadWriteIntercepter {
    public FileReadWriteIntercepterAdapter() {}
    @Override
    public void beforeRead(FileSectionInfo sectionInfo) {}
    @Override
    public FileSectionInfo afterRead(FileSectionInfo sectionInfo) {
	return sectionInfo;
    }
    @Override
    public void beforeWrite(String filePath, FileSectionInfo sectionInfo) {}
    @Override
    public void afterWrite(FileSectionInfo sectionInfo) {}
}
public boolean writtenSection(FileSectionInfo sectionInfo) {
    //写片段的前置拦截
    this.intercepter.beforeWrite(filePath, sectionInfo);
		
    //....省略一堆
		
    //真正地写入操作。
    try {
	synchronized (filePath) {
	    this.raf.seek(sectionInfo.getOffset());
	    this.raf.write(sectionInfo.getContent());
				
	    //写完之后拦截
	    this.intercepter.afterWrite(sectionInfo);
        }
    } catch (IOException e) {
	e.printStackTrace();
        return false;
    }
    return true;
}

所以可以在afterWrite方法中来执行真正的更新的逻辑。

大体思路:

  1. 当接收到一个新的文件片段就会自动的写入到磁盘文件中,在写入之后,可以拿到该新写入的片段的信息。通过文件编号获取到该文件中未接收的片段集合(执行更新未接收片段操作)。
  2. 每更新完毕一次还需要判断该文件的所有片段是否已经全部接收到了。如果是那就可以关闭资源。并且移除该文件未接收记录

下面这个是接收端服务器的内部类。如下:

class FileReadWriteAction extends  FileReadWriteIntercepterAdapter{
		
    public FileReadWriteAction() {}
		
    //文件片段写入磁盘之后执行。注意这个参数是当前已经接收到并写入到磁盘文件中去的判断
    @Override
    public void afterWrite(FileSectionInfo section) {
	int fileNo = section.getFileNo();
			
	//获取该文件对应的未接收片段
	UnreceiveSection unsection = receiveFilePool.getUnreceiveSection(fileNo);
	try {
	    //逻辑都在这个方法中
	    unsection.updataSection(section.getOffset(), section.getLen());
				
	    //当某个文件的所有片段都被接收端写入磁盘中了, 那我就可以关闭该文件的资源了
	    if (unsection.isReceived()) {				
	        FileReadWrite readWrite = receiveFilePool.getAcceptFrw(fileNo);
		//移除该文件的记录
		receiveFilePool.removeUnreceiveSection(fileNo);				
		readWrite.close();						
	    }
	} catch (ReceiveSectionOutOfRangeException e) {
	    e.printStackTrace();
	}
    }
		
}

其实主要的还是如何跟新的逻辑。即updataSection具体过程,该方法可以定义在UnreceiveSection类中,如下:

public void updataSection(long recOff, long recLen) throws ReceiveSectionOutOfRangeException {
    int index = searchSection(recOff);
		
    //获取该下标处的未接收片段信息
    UnsectionInfo curSection = unsectionList.get(index);
		
    //获取当前未接收片段的偏移量和长度
    long curOff = curSection.getOffset();
    long curLen = curSection.getLen();
		
    //新的左侧未接收片段的  偏移量 和 长度
    long lOff = curOff; 
    long lLen = recOff - curOff;
		
    //新的右侧未接收片段的  偏移量 和  长度
    long rOff = recOff + recLen;
    long rLen = curOff + curLen - rOff;
		
    //更新原来的未接收片段信息
    unsectionList.remove(index);				
		
    //如果任何 新形成的左侧/右侧未接收片段的长度不为0,那么都需要将其保存为新的未接收的片段
    if (rLen > 0) {
	unsectionList.add(index, new UnsectionInfo(rOff, rLen));
    }
    if (lLen > 0) {
	unsectionList.add(index, new UnsectionInfo(lOff, lLen));
    }
}

private int searchSection(long recOffset) throws ReceiveSectionOutOfRangeException {
    for (int i = 0; i < unsectionList.size(); i++) {
        UnsectionInfo section = unsectionList.get(i);
	if (section.isRange(recOffset)) {
	    //如果包含那就返回该未接收片段所在sectionList中的下标
	    return i;											
	}
    }
		
    throw new ReceiveSectionOutOfRangeException("文件号:" + fileNo + " 片段偏移量:" + recOffset);
}

如果说将上述过程形象化一些,那应该是这样的:

 到此为止,如果客户端掉线了产生断点,是可以获取到断点信息的。未接收片段的数据已经可以拿到了。现在的问题是需要续传

处理续传:

何时续传?

我的基本出发点是,不论传输过程中有没有产生断点,最起码都算是一个有效的连接。每有一个发送端连接上了,我将其标记一下。当所有发送端都连接上了并且传输结束(我将其视为一个完整的接收单元),这里我想表达的传输结束并非传输成功,即所有片段都发送过来了,如果传输一半掉线了,虽然也传输了数据但是没有发送全部信息,我也将其视为传输结束。(所以就可能产生断点,因为你虽然连接上了,但是又掉线了,导致自己负责的数据只发送了一部分),我需要等一个完整的接收单元完毕之后进行判断并处理续传问题。可以这样做:当一个完整的接收单元完毕之后我来检测是否有未接收的片段,如果有,则拿到未接收片段信息(断点信息),再获取当前还存在的发送端为他们重新分配任务。让其继续发送未接收片段。直到我都接收完了为止。

 

所以首先对之前的逻辑做一些修改:接收端服务器(ReceiveServer)的修改

上篇中在接收端服务器中维护了一个静态成员:tmpCount。当时主要是为了能够标识传输完成之后能够让其成为资源持有者给的。现在我的目的变了,我需要让其标识上面所说的“一个完整的接收单元”,接收前为其赋值为发送端数量。每有发送端传输结束就将其减1.当值为0时就说明“一个完整的接收单元”完毕。修改如下:

public void run() {
    try {
	System.out.println("线程启动,准备往磁盘写入数据");
	FileSectionInfo sectionInfo = this.fssr.receiveSection(dis);
	FileReadWrite frw = null;
	while(sectionInfo.getLen() > 0) {
	    frw = receiveFilePool.getAcceptFrw(sectionInfo.getFileNo());
	    frw.writtenSection(sectionInfo);
	    sectionInfo = this.fssr.receiveSection(dis);
	}
    } catch (IOException e) {
	System.out.println("有发送端宕机了,产生断点");
    }finally {
        //将其调整到finally中
	ResourceReceiver.tmpCount.decrementAndGet();
    }

}

而且侦听完连接之后我也不讲服务器宕机了,因为,如果产生断点,那么还需要再次发送,即再次连接服务器。所以直接宕机是不合理的。上篇的方式只适用于不考虑断点的情况。那么何时宕机?我的处理是,所有数据接收完毕在宕机。修改如下

@Override
public void run() {
    if(this.senderCount <= 0) {
	return;
    }
    for(int index = 0; index < senderCount; index++) {
        try {
	    Socket socket = server.accept();
	    System.out.println("侦听到一个连接");
	    Receiver reveiver = new Receiver(socket);
	    threadPool.execute(reveiver);
        } catch (IOException e) {
	    e.printStackTrace();
        }
    }
    /*
     * 当然与前面不同的是   服务器宕机不能在此处。
     * 因为此处如果宕机,发现断点之后还会有发送端需要连接。显然此处宕机是及其不合适的
     */	
}

上面只是接收端服务器的更改,最多的修改地方在ResourceReceiver中。

关于读写时拦截器的添加:

public class ReceiveServer implements Runnable{
    private IFileReadWriteIntercepter fileReadWriteAction;	

    public ReceiveServer(int port) {
        try {
	    //....
	    this.fileReadWriteAction = new FileReadWriteAction();
	    this.receiveFilePool.setFileReadWriteIntercepter(fileReadWriteAction);    
            //...
        } catch (UnknownHostException e) {
	    e.printStackTrace();
	}
    }

    public void startReceive() {
	//此处开启线程侦听连接
	new Thread(this,"服务器接收端").start();	
    }

    public void setFileReadWriteAction(IFileReadWriteIntercepter fileReadWriteAction) {
	this.fileReadWriteAction = fileReadWriteAction;
    }
//....
}

ResourceReceiver的修改:

首先在该类的核心方法getResourceFiles中处理续传。外部通过调用该方法来接收资源。第一次接收资源,需要启动接收端服务器,注意启动一次就行了,宕机是成为资源持有者之后再宕机。而且第一次还需要初始化ResourcePool(在这个过程中,我们将所有一整个文件都当成未接收片段),而且需要开辟许多操作文件的对象,那底层会消耗很多操作系统资源,所以也只有必要做一次。再者第一次是需要对所有文件进行全文件层面的分片分配,而续传只需要对未接收的那一部分非配即可。基于此,我将接收分为第一次接收和非第一次接收处理。  非第一次只需要不停的获取断点信息,处理续传即可。都是一样的操作。

首先发送端筛选策略要改。如果有发送端掉线,那么资源注册中心会通过健康检测来将其移除。只不过我设置的时间是3s。在上下3s的时间差内,接收端获取到新的发送端列表时就可能读取到脏数据,也即已经掉线但是没有从资源注册中心移除的资源持有者数据。其实我这块资源注册中心健康检测的本质就是作为RMI客户端,去执行资源持有者的远程方法(执行远程方法时需要双端通信)。如果资源持有者“不健康”也就是异常掉线了,就无法连接上“资源持有者的RMI服务器”。所以资源注册中心就可以捕获到这个异常,从而移除该资源持有者的信息。 其实就健康检测的功能而言。我感觉资源注册中心微乎其微。因为此刻我传输数据是接收端与发送端长链接的。半秒不到就能检测到宕机,然后继续发起请求处理续传。这个过程几行代码,程序运行是很快的。他会再次获取“资源持有者”地址集合,3s时间差,它一定会读取到脏数据。我不能明确地知道多长时间可以规避这种误差。但是由于在筛选策略中也是执行发送端的RMI方法来获取其任务记录数。虽然我读取到脏数据,但是我还可以双重检测,再捕获异常,如果执行不成功那自然就是你发送端掉线了,但是资源注册中心又没有及时移除掉你的信息。

修改如下:

class SenderSelect implements ISenderSelectedStrategy {

    public SenderSelect() {}
		
    @Override
    public List<DefaultNetNode> selectSender(List<DefaultNetNode> senderList) {
	//执行RMI方法。获取资源持有者当前的发送任务数量。
	List<DefaultNetNode> sendList = new ArrayList<DefaultNetNode>();
	for (DefaultNetNode defaultNetNode : senderList) {
	    final int senderPort = defaultNetNode.getPort();
	    receiveClient.setRmiServerPort(senderPort);
	    try {
	        SendCounter sendCounter = resourceSender.getSendCounter();
		if(sendCounter.getSendAccount() > 100 || sendCounter.getSendCount() > 20) {
		    continue;
		}
		sendList.add(defaultNetNode);
	    } catch (Exception e) {
	        System.out.println("从注册中心获取到脏数据,跳过它");
	    }
        }
        return sendList;
    }
}

对核心方法的修改:

//核心方法
    public boolean getResourceFiles() throws ResourceNotExistException{
	firstReceive(this.sourceFileList);
		
	//等待第一个接收单元执行完毕
	while(ResourceReceiver.tmpCount.get() != 0) {
            //因为底层通信收发是通过独立的线程进行的,我不能确定何时才能停止
            //所以我只能根据tmpCount判断,yield是表示让出当前线程的CPU资源
	    Thread.yield();            
	}
		
	ResourceFilePool receiveFilePool = this.receiveServer.getReceiveFilePool();
		
	//处理断点
	while(!receiveFilePool.isEmpty()) {			
	    //用于测试输出一下断点信息
	    System.out.println("检测到断点,正在处理.....    断点信息如下:");
	    receiveFilePool.showBreakPoint();
			
	    //获取未接收到的片段集合
	    List<FileSectionInfo> sectionList = receiveFilePool.getUnFileSectionList();
	    //重新获取资源持有者列表并筛选合适的成为发送端
	    List<DefaultNetNode> senderList  = getSenderList();
			
			
	    int senderCount = senderList.size();
	    this.receiveServer.setSenderCount(senderCount);
            
            //此处我只是再次开启一下侦听线程,之前是在启动服务器时顺便开启了侦听线程
            //但是现在接收服务器并没有宕机,但是伴随着上次传输结束,上次的侦听也结束了
            //所以现在需要重新开启一下
	    this.receiveServer.startReceive();
	    
            //为每个发送端分配未接收到的片段作为其发送任务
	    List<List<FileSectionInfo>> fileSectionInfoListList = this.fileDistribution.getFileSectionInfoListList(sectionList, senderCount);
	    send(senderList.size(), fileSectionInfoListList, senderList);
			
	    //等待一个接收单元执行完毕
	    while(ResourceReceiver.tmpCount.get() != 0) {
	        Thread.yield();
	    }
			
    }
		
    //能执行到这里一定是tmpCount == 0了,也就是数据全部接收到了,没有丢失的情况
    registerMe();
    return true;
}

对分配策略的写法做了稍稍调整:

//第一次是调用这个方法的
public List<List<FileSectionInfo>> distribution(SourceFileList sourceFileList, int count){
    //实例化文件片段集合
    List<FileSectionInfo> sectionList = new ArrayList<FileSectionInfo>();
		
    //获取该资源下的所有文件集合
    List<FileInfo> fileList = sourceFileList.getFileInfoList();
		
    //这里将每个文件都切分成 众多"片段" 的集合存储进sectionList中
    for(FileInfo fileInfo : fileList) {
        int fileNo = fileInfo.getFileNo();
        long fileSize = fileInfo.getFileSize();
        int len;
        long offset = 0;
        while(fileSize > 0) {
	    //将该文件分成---最大为maxSectionSize长度的片段,只是分成片段了还没有设置该片段的具体内容。
	    len = (int) (fileSize > maxSectionSize ? maxSectionSize : fileSize);
	    FileSectionInfo sectionInfo = new FileSectionInfo(fileNo, offset, len);
	    sectionList.add(sectionInfo);
				
	    fileSize -= len;
	    offset += len;
        }
    }
		
    return getFileSectionInfoListList(sectionList, count);
}
	
//将此方法抽取出来
public List<List<FileSectionInfo>> getFileSectionInfoListList(List<FileSectionInfo> sectionList,  int count){
    return distributionStrategy.distributionFileSection(sectionList, count);
}

还有其中细分出来的方法如下:

private void firstReceive(SourceFileList sourceFileList) throws ResourceNotExistException{
    List<DefaultNetNode> senderList  = getSenderList();
    int senderCount = senderList.size();

    //TODO 如果是接收端宕机导致断点,我应该从此处着手处理
    		
    List<List<FileSectionInfo>> fileSectionInfoListList = fileDistribution.distribution(sourceFileList, senderCount);
		
    this.receiveServer.setReceiveFilePool(this.sourceFileList);
    this.receiveServer.setSenderCount(senderCount);
    //此处启动服务器
    this.receiveServer.startUp();												
		
    send(senderCount, fileSectionInfoListList, senderList);
}


private List<DefaultNetNode> getSenderList() throws ResourceNotExistException{
    List<DefaultNetNode> senderList = requester.getAddrList(baseInfo);
		
    if(senderList == null || senderList.isEmpty()) {
        throw new ResourceNotExistException("资源[" + baseInfo + "]不存在");
    }
		
    if(this.senderSelectedStrategy == null) {
        this.senderSelectedStrategy = new SenderSelect();
    }
    senderList =  senderSelectedStrategy.selectSender(senderList);

    //注意每次获取到发送端个数我会立即给标记成员赋值。  立刻!		
    ResourceReceiver.tmpCount = new AtomicInteger(senderList.size());
    return senderList;
}

private void send(int senderCount, List<List<FileSectionInfo>> fileSectionInfoListList, List<DefaultNetNode> senderList) {
    for(int index = 0; index < senderCount; index++) {
	DefaultNetNode sendNode = senderList.get(index);
	List<FileSectionInfo> sectionList = fileSectionInfoListList.get(index);
			
	this.receiveClient.setRmiServerIp(sendNode.getIp());
	this.receiveClient.setRmiServerPort(sendNode.getPort());
	this.resourceSender.sendResource(this.receiveServer.getIp(), this.receiveServer.getPort(), baseInfo,sectionList);
    }
}

public void registerMe() {
    SourceHolderNode rhn = SourceHolderNode.getInstance();
    rhn.setCenterIp(centerIp);							
    rhn.setCenterPort(centerPort);								
    rhn.setServerPort(receiveServer.getPort());								
    rhn.registryResource(baseInfo, sourceFileList);
		
    /**
     * 此处有个细节问题:注意一定要先宕机,再去注册成为资源持有者。因为资源持有者需要建立RMI服务器
     * 如果不先宕机,则会造成端口号冲突
     */
    receiveServer.shutDown();
    System.out.println("接收端服务器已经宕机");
    rhn.startUp();
		
    System.out.println("注册成功,我已成为合格的资源持有者");
}

测试结果:

资源注册中心:

接收端与发送端:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值