新增主要功能:修改页面适配手机屏幕。
Android 手机截图效果:
第三个版本,jAlbum完整版差不多完工了:https://github.com/shentar/jAlbum/releases/tag/jAlbum_0.1.3。
最后解决了文件系统监控的问题。利用Java1.7封装的文件系统的通知回调机制,实现对指定目录的递归监控,避免每次全盘扫描性能太低的问题。
注意对于linux系统对于单个用户能够监听的inotify对象个数做了限制,一般是限制为8192,因此需要修改系统内核的配置:在/etc/sysctl.conf文件中,新增一行:fs.inotify.max_user_watches=1000000,表明将该限制修改为100万个。下次重启后会生效,如果希望当前立即生效,则需要执行命令:sysctl –p 。
对于WatchService,只监控当前目录的变化,当前目录下新增文件或者文件夹时,当前文件夹会有修改事件或者创建事件,但是再下一层的文件夹或者文件发生变更时,并不会有任何事件,因此如果要监控指定的文件夹,需要递归监控到最后一层。没有验证文件系统的notify机制对进程性能和文件系统性能的影响,在树莓派上面简单验证了下,能够非常及时的发现新增文件,进程CPU和内存也没有明显的增加。终于解决了每次都有依赖全盘扫描一遍文件才能发现新增的文件的问题。
具体代码请参见源码中的DirWatchService类。
在基于自攒的PC搭建了Home NAS系统之后,使用btsync工具将家里的各个终端的照片和视频集中备份到了Home NAS。主要是台式电脑、笔记本和家人的各个手机上面的照片、拍摄视频、微信聊天视频和朋友圈短视频等。备份好之后,发现各个设备上面的图片存放时并没有非常严谨的分类,并且有大量的重复照片,就想着做一个照片、视频管理系统放在Home NAS上面持续运行并提供相册服务。系统前端提供web访问接口,浏览器可以直接访问,后台生成一个简易相册库,以WEB页面的方式供各个终端查看和下载照片。 想到哪儿做到哪儿,并没有非常详尽的需求分析分析和软件设计。最终实现了如下功能:
软件架构图:
源码托管在此GIT服务上面:jAlbum(http://git.codefine.site:3000/Shentar/jAlbum) ,欢迎fork、提交issue和修改代码。
以下是适用于X86 Windows系统和Linux系统的软件包。运行环境要求Java 1.8,JDK或者JRE都可以。下载软件包后,解压缩,完成必须的配置即可运行。
版本 | 发布时间 | 更新说明 | 下载链接 |
0.3.1 | 24 Feb 2021 | 1、缩略图显示优化; 2、支持整合图片到单独目录; 3、支持本地自建的人脸识别服务。 | 下载 |
0.3.0 | 3 Mar 2018 | 1、支持使用Google Analytics跟踪访问记录; 2、解决若干已知bug。 | |
0.2.9 | 25 Nov 2017 | 1、支持备份数据到Huawei Cloud OBS对象存储。 | |
0.2.8 | 18 Nov 2017 | 1、支持HTTPS访问; 2、解决响应中重复HTTP头域Date和Expire问题; 3、解决鉴权Bug。 | |
0.2.7 | 19 Jul 2017 | 1、新增“风景”页; 2、使用Maven管理项目; 3、其他问题解决。 | |
0.2.6 | 19 May 2017 | 添加访问权限控制,相册放在外网访问时,发放token才能访问,避免照片被陌生人访问,如:搜索引擎抓取照片。 | |
0.2.5 | 7 May 2017 | 性能提升。 | |
0.2.4 | 3 Apr 2017 | 1、可靠性功能问题解决。 2、性能提升。 | |
0.2.3 | 17 Mar 2017 | 增加人像识别和按照任务归集照片功能。 | |
0.2.2 | 29 Jan 2017 | 1、增加后台同步照片到AmazonS3服务端实现。 2、修改若干重复文件检测bug。 | |
0.2.1 | 8 Jan 2017 | 解决问题:Chrome浏览器Ranges下载时第一个Range时不按照协议实现收取所有数据,最终在页面有多个Video时会导致浏览器挂死。单页面多个Video时,提取视频文件的缩略图呈现。 | |
0.2.0 | 6 Nov 2016 | 1、新增Video的支持,支持对MP4视频文件呈现; 2、修正稳定性和功能细节的bug。 | |
0.1.9 | 14 Aug 2016 | 1、PC端浏览器,增加键盘翻页,左方向键翻上一页,右方向键翻下一页。 2、改进“照片发现”性能,最快在20秒内新增照片可以呈现到页面上。 3、其他bug解决。 | |
0.1.8 | 5 Aug 2016 | 1、优化照片隐藏逻辑,当隐藏的照片被移动或者删除后又添加时,也不会被显示出来。 2、适配移动终端浏览器,实现触控滑动照片翻页。 | |
0.1.7 | 31 Jul 2016 | 自动识别图片的旋转角度,在前端呈现时自动适应浏览器并旋转到正确的方向。 | |
0.1.6 | 24 Jul 2016 | 修正若干bug。使用按钮代替超链接来导航相册。 | |
0.1.5 | 21 Jul 2016 | 1、增加前端照片旋转功能。 2、完善照片删除功能。 3、解决稳定性bug。 | |
0.1.4 | 18 Jul 2016 | 新增删除照片功能。在单张照片显示的页面上面可以点击删除链接“删除”该照片,该照片之后不会再显示,当然照片还存在于磁盘中,只是不再显示。 | |
0.1.3 | 10 Jul 2016 | 1、增加文件系统监控,利用文件系统的notify机制及时处理新的文件变更; 2、不再定时全盘扫描,只在启动时进行全盘扫描; 3、定时根据base表的变更情况刷新辅助表及时呈现新增照片。 | |
0.1.1 | 2 Jul 2016 | 1、支持缩略图; 2、优化编译脚本; 3、增加配置文件。 | |
0.1.0 | 23 Jun 2016 | first release. |
在Git仓库下载最新的版本的代码包,执行如下命令编译安装,注意尽量使用jdk 1.8版本,其他版本未适配,不保证编译和启动成功 ^_^
cd jAlbum
mvn install
cd distribute
sh jalbum.sh start
如果是Windows系统则运行start.bat批处理文件启动,启动成功界面为:
直接下载编译好的软件包,参考软件包下载。
服务端默认监听2148(http)和5443(https)端口,使用浏览器访问即可,如在本机访问,直接使用http://127.0.0.1:2148即可访问。针对android系统,在相册主页有apk可以下载安装,在设置界面指定服务端地址也可访问。
最新版本主页截图:
视频合集页面截图:
根据人像识别结果进行照片归集页面截图:
停止运行jAlbum,将新版本的软件包中根目录下的jalbum.xml、log4j.xml、keystore和dedup.db文件删除,然后将剩下的所有文件和目录覆盖到原运行目录。重新启动jAlbum即可。
当前支持的照片文件类型有:
<picfilesuffix>
<suffix>jpg</suffix>
<suffix>jpeg</suffix>
<suffix>png</suffix>
</picfilesuffix>
将上述代码添加到jalbum.xml的config标签内即可。
当前仅支持mp4文件的分析和呈现,并且需要先安装ffmpeg工具。在ffmpeg官方站点下载当前操作系统的工具,将ffprobe和ffmpeg可执行文件置于系统PATH变量所指向的路径下,如:C:\Windows目录下。jAlbum运行中会调用该工具分析MP4视频文件。 另外,在jalbum.xml内,picfilesuffix标签内增加如下内容:
<suffix>mp4</suffix>
<minfilesize>51200</minfilesize>
<maxfilesize>512000000</maxfilesize>
此配置用于定制最小文件和最大文件大小。
指定jAlbum监控的目录:
<inputdir>
<dir>D:\\</dir>
<dir>C:\\</dir>
</inputdir>
可以配置多个目录。注意Windows系统下,需要使用双斜杠来作为目录分隔符。 指定jAlbum排除某些特定目录,对于某些系统目录或者出于隐私保护而不希望呈现的目录,可以使用此配置排除监控:
<excludedir>
<dir>C:\\windows\\</dir>
<dir>C:\\Program Files\\</dir>
<dir>./</dir>
</excludedir>
指定缩略图存放目录:
<thumbnaildir>./thumbnail</thumbnaildir>
thumbnaildir标签用于指定缩略图文件存放的目录。
maxpicsperonepage标签用于定制单个页面中显示的媒体文件的数量。
threadcount标签用于定制任务并发数量。可以根据当前系统的CPU性能定制该数值。 hashalog用于定制计算照片文件和视频文件的HASH算法,常用的有MD5, SHA-256, SHA-1,一般而言HASH算法强度越高计算要求也越高,对于普通家用照片剔重,MD5比较合适。
首先需要到旷视科技申请API Key,然后在jalbum.xml中配置如下标签:
<Facer>
<ak></ak>
<sk></sk>
<facesetprefix></facesetprefix>
<maxfacescount></maxfacescount>
</Facer>
其中ak填写为API Key,sk填写为API Secret。facesetprefix为face set的前缀,当有多个应用使用同一个账号时,可以使用此前缀做区分。maxfacescount为相册人像主页上呈现的人物的个数。
jalbum支持将数据备份到华为云对象存储和亚马逊的S3对象存储。当备份到华为云时,默认使用归档存储级别。两种备份目的端的使用是完全相同的。
<s3>
<bucketname></bucketname>
<ak></ak>
<sk></sk>
<useHttps></useHttps>
</s3>
<HuaweiOBS>
<bucketname></bucketname>
<ak></ak>
<sk></sk>
<useHttps></useHttps>
</HuaweiOBS>
其中,bucketname指定用于备份的容器,ak、sk分别为对象存储服务提供的访问凭据,需要到相应的对象存储服务上申请。useHttps指定是否使用https协议上传数据,为提高性能,如数据安全性要求不高,可以直接使用http协议传输。
jalbum本身支持access日志,有log4j.xml配置。可以自行根据需要修改log4j.xml定制日志格式和存放目录。 另外,当相册在互联网上面开放访问时,可以配置Goolge Analytics,实现对访问进行统计。只需要将Goolge Analytics提供的ID写入如下配置:
<GoogleAnalyticsID></GoogleAnalyticsID>
如果配置为空,则不启用统计。
当jalbum开放到互联网访问时,为了避免搜索引擎以及其它爬虫工具抓取照片,系统默认开启了需要输入Token方式来访问。 当不需要此权限控制时,可关闭Token访问,配置如下标签:
<accessAuth>true</accessAuth>
在开启权限控制时,相册提供后台页面和管理员登录方式来管理Token,启动后台备份和立即刷新数据等操作。
在本地登录时,可以管理Token,使用浏览器或者curl -X GET ${url}命令访问如下地址:
当使用管理员Token登录,或者在本地登录时:
Jetty 8 长连接的超时断开连接的机制:超时连接机制针对IO传输过程中的数据阻塞时间超过一定阈值时,断开该连接。阻塞指当前处于数据传输阶段,但是连续指定时间内都没有发出或者接收到任何数据时,Jetty系统断开该连接。强调一下,只有在数据传输过程中才会有超时机制。在服务端处理已经收到的数据时是不会检测该超时时间的。
下面看一下具体的代码实现。在jetty 8.1.17版本中,由以下代码控制一个连接的空闲、非空闲和断开检查方法,在SelectChannelEndpoint类中:
这个问题很早就分析、修改和验证过了,一直没有来得及总结整理。今天突然想起来了,首先用我那蹩脚的英语给jetty的维护团队提了一个问题单,在等待他们回复的同时,我也把我发现问题的过程分享一下。
问题单链接:https://bugs.eclipse.org/bugs/show_bug.cgi?id=415282
经过打断点和调试,发现如下调用占用了性能下降(相对于jetty7版本)的绝大多数处理耗时:
public class HttpOutput extends ServletOutputStream
{
/* ------------------------------------------------------------ */
private void write(Buffer buffer) throws IOException
{
if (_closed)
throw new IOException("Closed");
if (!_generator.isOpen())
throw new EofException();
// Block until we can add _content.
while (_generator.isBufferFull())
{
_generator.blockForOutput(getMaxIdleTime());
if (_closed)
throw new IOException("Closed");
if (!_generator.isOpen())
throw new EofException();
}
// Add the _content
_generator.addContent(buffer, Generator.MORE);
// Have to flush and complete headers?
if (_generator.isAllContentWritten())
{
flush();
close();
}
else if (_generator.isBufferFull())
_connection.commitResponse(Generator.MORE);
// Block until our buffer is free
while (buffer.length() > 0 && _generator.isOpen())
{
_generator.blockForOutput(getMaxIdleTime());
}
}
}
public class HttpGenerator extends AbstractGenerator
{
public void addContent(Buffer content, boolean last) throws IOException
{
if (_noContent)
throw new IllegalStateException("NO CONTENT");
if (_last || _state==STATE_END)
{
LOG.warn("Ignoring extra content {}",content);
content.clear();
return;
}
_last = last;
// Handle any unfinished business?
if (_content!=null && _content.length()>0 || _bufferChunked)
{
if (_endp.isOutputShutdown())
throw new EofException();
flushBuffer();
if (_content != null && _content.length()>0)
{
if (_bufferChunked)
{
Buffer nc=_buffers.getBuffer(_content.length()+CHUNK_SPACE+content.length());
nc.put(_content);
nc.put(HttpTokens.CRLF);
BufferUtil.putHexInt(nc, content.length());
nc.put(HttpTokens.CRLF);
nc.put(content);
content=nc;
}
else
{
Buffer nc=_buffers.getBuffer(_content.length()+content.length());
nc.put(_content);
nc.put(content);
content=nc;
}
}
}
_content = content;
_contentWritten += content.length();
// Handle the _content
if (_head)
{
content.clear();
_content=null;
}
else if (_endp != null && (_buffer==null || _buffer.length()==0) && _content.length() > 0 && (_last || isCommitted() && _content.length()>1024))
{
_bypass = true;
}
else if (!_bufferChunked)
{
// Yes - so we better check we have a buffer
if (_buffer == null)
_buffer = _buffers.getBuffer();
// Copy _content to buffer;
int len=_buffer.put(_content);
_content.skip(len);
if (_content.length() == 0)
_content = null;
}
}
}
// Handle the _content
if (_head)
{
content.clear();
_content=null;
}
else if (_endp != null && (_buffer==null || _buffer.length()==0) && _content.length() > 0 && (_last || isCommitted() && _content.length()>1024))
{
_bypass = true;
}
else if (!_bufferChunked)
{
// Yes - so we better check we have a buffer
if (_buffer == null)
_buffer = _buffers.getBuffer();
// Copy _content to buffer;
int len=_buffer.put(_content);
_content.skip(len);
if (_content.length() == 0)
_content = null;
}
Netty的NIO框架模型。在以前的文章中,为解决Jetty的问题,分析过Java NIO基于多路事件分离器的异步IO框架模型。一直都没有系统分析Netty和Jetty的网络模型,这两天将二者的网络框架部分的代码仔细读了一下,整理了二者的网络模型,画出了Netty的模型图:
在图中,每个侦听都会创建一个Acceptor Reactor,由Boss线程来监控多路分离器,这里只关注连接事件,当有新的建立连接请求达到时,该线程会第一时间响应,将接收到的请求注册到事件多路分离器中,事件多路分离器有多个,默认情况下其个数为CPU核心数的两倍,应该是CPU超线程的数目。这里会给每一个达到的连接编一个序号,将序号对分离器个数取模(hash到0~3的一维空间),根据模值分配给相应的分离器。事件分离器开始监听新的连接上面的读写事件。检查线程为NioWorker。读写数据会通过回调用户注册的handler的相应接口来实现。因此,处理耗时数据的情况下,需要用户将其提交给后台线程,而不应该阻塞事件分离器,否则会导致新的连接无法建立,其他并发请求无法处理。
Jetty在代码风格上面跟Netty差别很大,看jetty代码感觉更清晰一点,可能是因为以前处理问题已经看得非常多了。前面的文章也说过,Jetty是在一个线程中调用一个同步的accept()方法来等待新的连接请求,等到新的连接到来时,就生成新的change事件放到多路分离器中,同样也是有多个多路分离器,选取原则与Netty完全一样。简单的轮询实现负载均衡。这是典型的半同步半异步(Half-Sync/Half-Async)的模式。在只使用1个事件分离器时,会发现分发线程通常会引入很多问题。前面两篇文章中提到的问题分析都跟这个有关。
不知道Jetty与Netty为什么在接受新请求这里有差别,难道Netty的方式更利于处理短连接,而Jetty则更利于处理长连接,比如Http连接?优待进一步的并发测试才能说明问题。如果netty的方式很好,那么Jetty应该也早就改成了该方式。
在上一篇中介绍了jetty的反映器模型,selector线程与业务子线程交互的点有:
1、分发事件给子线程做,启动子线程;
2、子线程发现阻塞或者连接关闭等时间时,注册内部changes,等待selector线程调度;
3、检测超时连接,并且关闭连接。
以下分析针对jetty的特定版本:http://dev.eclipse.org/svnroot/rt/org.eclipse.jetty/jetty/tags/jetty-7.2.1.v20101111
首先介绍一下Jetty的反映器模型,Jetty用的经典的NIO异步模型(Scalable IO in Java http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)。连接管理的示意图如下:
Jetty在使用这个模型的时候,做了一些改动,acceptor是独立出来的一个阻塞线程,用于阻塞地接受新的连接请求,而所有的连接建立之后,都会想selector线程注册网络事件和内部的事件(changes),selector需要同时处理网络事件和内部的changes。同时还要定期检查超时的链接。