[{"content":"前言 家里的机顶盒是电信的，基本全部限制死了，无法安装第三方 APP，更别说看 Netflix 了，本来想着闲鱼淘一个二手破解的机顶盒玩玩，突然想到闲置的坚果 R1 及其备受争议的 TNT 大屏操作系统，好像还挺适合当机顶盒用的。\n遥控 其实本可以没有这篇文章，因为 TNT 系统已经够成熟了，连上键鼠完全可以像电脑一样操作，当作机顶盒播放视频绰绰有余。但是客厅里摆一套键鼠总觉得不够优雅，所以想着可不可以实现像遥控器操控机顶盒那样。\n手机遥控 第一个想法就是，如果有一个软件，可以将一个手机作为另外一个手机的输入设备，那么其实就可以摆脱键鼠了。\nAndroid 9.0 + 蓝牙 HID Android 9.0 自带将手机作为输入设备的功能，遥控助手 和 妙妙触控 都是利用此原理，但是遗憾的是国内大部分手机都阉割蓝牙 HID 功能，像我手上的 OPPO 就不行，用了别人的荣耀手机，可以实现，但是触控容易断，体验较差。\n局域网遥控 KDE Connect 利用局域网实现手机对手机之间遥控，体验不错，较为灵敏，但遗憾的是不兼容 TNT，即使在连接显示器的情况下，也是只能操作手机。期待以后兼容（希望太小了\u0026hellip;\u0026hellip;）\n官方软件遥控 国内大部分视频 APP 都会有个投屏功能，手机上找到想看的视频，点击投屏就可以投到已安装相应 TV 版的盒子上了。\n目前来说，B 站的体验较好，有一个全局投屏的功能，一开始连接上之后，后面每个视频都会自动在 TV 上播放。\n遥控器 软件层面的遥控试了一圈都不行，本来想着买个迷你版的键鼠，逛了一下，竟然找到一个支持鼠标功能的遥控器。\n官方明确说支持 Android 手机，支持蓝牙配对，买回来试了下，完美支持，甚至支持 TNT 的语音功能，但是得是在插入键盘的情况下😳，可能插入键盘系统才会作映射吧。\n软件体验 Kodi Kodi 是一款开源媒体播放器，有庞大的附加组件社区，是机顶盒的热门软件。支持本地所有类型的媒体，支持 DLNA/SMB 远程播放，支持 IPTV 电视流播放。国外主流的视频网站插件 Netfilx、Youtube 都有，国内的优酷、B 站也有，只是更新维护不够。\n中文设置 支持中文，但是直接设置会乱码，需要先修改字体；\nSettings -\u0026gt; Interface -\u0026gt; Skin -\u0026gt; Fonts，设置为 Aria based； Settings -\u0026gt; Interface -\u0026gt; Regional -\u0026gt; Language，设置为 Chinese (Simple)； 手机远程遥控 官方的遥控软件 Kore 体验也非常好，Android 和 IOS 都支持，而且很多第三方的遥控软件也都会支持 Kodi，以 Android 为例：\n设置 -\u0026gt; 服务 -\u0026gt; 控制；允许通过 HTTP 远程控制，端口号建议改成非常用端口如 18080； Google Play 下载 Kore，需要在同一局域网下，软件会自动识别出设备连接； DLNA 播放 首先需要设置里打开此功能：\n设置 -\u0026gt; 服务 -\u0026gt; UPnP/DLNA，启用 UPnP 支持； 电影 -\u0026gt; 添加视频源 -\u0026gt; UPnp 设备；局域网内的设备就会自动展示出来； 播放电视 首先需要下载 IPTV 客户端插件，插件 -\u0026gt; PVR 客户端 -\u0026gt; PVR IPTV Simple Client；\n播放电视首先需要电视源，GitHub 上有很多这类项目，我用的是这个 链接，国外源也可以播放，但是也听不懂，所以就选择中文分类的列表导入。\n在插件中，设置选择远程路径，输入 https://iptv-org.github.io/iptv/languages/zho.m3u。\n等待加载片刻，电视频道里就会出现查询到的频道里。如果加载不出来，可能是 GitHub 国内访问太慢，可以将此文件下载到手机上，然后在插件中设置本地路径，导入即可。\n播放 Netflix Kodi 官方库中没有此插件，可能是版权的原因吧。但是民间自有大神，名为 Stefano Gottardo 的开发人员开发了 Netflix 插件，测试可用。安装步骤稍微复杂些：\npython 脚本生成 Key； 下载第三方源插件，选择插件从 zip 文件中导入； 选择从第三方库安装 Netflix 插件； 点击 Netflix，使用身份密钥登陆； YouTube YouTube 使用有三种方法：\n1. 直接安装 APP\n其实 TNT 系统已经兼容 YouTube 了，打开会展开为平板模式，但是无法通过遥控器进行按键操作，所以作为次要选择。\n2. Kobi 安装 插件\n另外想到的方案，就是 Kobi 插件了，这种方案肯定可行，而且体验也会不错，毕竟融入到 Kobi 平台里了。\n3. SmartTube Next\n最后想到的是 YouTube TV，既然是 TV 版本，肯定会兼容遥控器，遗憾的是官方只支持盒子安装，无法在手机上安装。\n网上搜了下，竟然找到了一个代替 YouTube TV 的第三方软件 SmartTube Next。兼容遥控器，使用起来体验很好，唯一有点不好的是每进入下一个页面，系统就会生成一个独立的窗口。\n其他体验好的软件 支持遥控器按钮操作：\nMX Player 适配 TNT 全屏：\nYouTube Kids Google Drive 哔哩哔哩等各大视频网站的 TV 版 成果演示 遥控器操作 TNT 的演示视频：\n","date":"2022-01-13T00:00:00Z","image":"https://images.helloqb.xyz/onedrive/blog-imag-080.png","permalink":"/post/2022/0112_tv.html","title":"Smartisan TNT 连接遥控器变成超强机顶盒"},{"content":"前言 知道 Axure 导出的原型图是静态 html 文件后，思路就清晰了，在本地搭建一个 web 服务器即可局域网之间访问。\n但在正式搭建的时候，还是遇到些棘手的问题，先列举出来，读者可以提前思考下：\n产品经理如何用自己的电脑（通常 Windows）将共享的文件导出到服务器上（通常 Linux 上）； 可不可以搭建一个首页，前端自动识别服务器里存在的原型项目并展示在首页； Samba 共享文件 在如何将 Windows 的文件导出到 Linux 上时，也纠结了几种方法，如用 svn 同步文件，或者 sftp 传输文件。但想到产品经理又不是我们这些敲代码的，对这些不熟悉。所以想要使用一种尽量没有学习成本的方法。\n最后用了 Samba。Linux 上配置好后，Windows 映射成网络驱动器，然后在 Axure 分享链接时，选择本地此驱动器即可上传到服务器上。具体操作如下。\nLinux 安装 Samba 公司的 Wiki 项目已经用到了 Samba，所以没有亲自安装，直接配置：\n1 2 3 4 5 6 7 8 9 10 11 12 [axure] comment = share for axure path = /root/server_axure/html public = yes security = user map to guest = Bad User guest ok = yes guest account = ftp browseable = yes writable = yes create mask = 0777 directory mask = 0777 配置文件在 /etc/samba/smb.conf 中，其中 path 用于存放 html 文件，即原型图上传的位置。\nWindows 映射网络驱动器 Linux 配置好 Samba 后，打开 Windows 资源管理器，选择映射网络驱动器（或者添加一个网络位置），输入 samba 共享的地址，如本例中 \\\\192.168.0.105\\axure。\n上传原型图到服务器 Axure 点击分享，选择 Publish Locally，选择映射好的网络驱动器，注意新建一个文件夹，点击发布会自动上传到服务器上。\n搭建 Web 端 此时，已成功将原型图 html 文件上传到服务器上，只要搭建一个 web 服务器就可以访问了。如本例中采用的是 Docker + Nginx 配置：\n1 2 3 4 5 6 7 8 9 10 version: \u0026#39;3\u0026#39; services: web: image: \u0026#34;nginx:latest\u0026#34; volumes: - \u0026#34;$PWD/html:/usr/share/nginx/html\u0026#34; - \u0026#34;$PWD/conf/nginx.conf:/etc/nginx/nginx.conf\u0026#34; container_name: axure_web ports: - \u0026#34;12345:8080\u0026#34; 1 2 3 4 5 6 7 8 server { listen 8080; root /usr/share/nginx/html; location / { index index.html index.htm; } } 本例中，将原型图 html 文件上传到了 /project 文件夹下，命名为 test，则此时该原型图的访问地址为 http://192.168.0.105/project/test/start.html。\n前端自动获取服务器目录 但是手动修改链接总归不够好，所以想看实现一个逻辑，当产品经理把原型图放入到指定文件夹中， 服务器能自动读取所添加的原型图，然后在首页展示。\n这个需求我问了下前端，前端断然否决，说不可能，前端无法访问服务器的。\n本来也打算写一个后台实现这个需求的，因为谷歌也没有找到结果。但是在配置 Nginx 的时候突然想到其有一个参数 autoindex on，可实现目录浏览功能。\n1 2 3 4 5 server { location /project/ { autoindex on; } } 有希望！既然是个网址，前端是否可以通过 js 获取，解析网页，获取想要的目录文件呢！\n研究良久，实现如下（本人是后端\u0026hellip;\u0026hellip;随意写的，前端大神肯定有更好的写法）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 \u0026lt;script\u0026gt; function loadDoc() { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function () { if (this.readyState == 4 \u0026amp;\u0026amp; this.status == 200) { myFunction(this); } }; xhttp.open(\u0026#34;GET\u0026#34;, \u0026#34;/project/\u0026#34;, true); xhttp.send(); } function myFunction(xml) { // console.log(xml.responseText) var parser = new DOMParser(); var htmlDoc = parser.parseFromString(xml.responseText, \u0026#39;text/html\u0026#39;); var preList = htmlDoc.getElementsByTagName(\u0026#34;pre\u0026#34;)[0].getElementsByTagName(\u0026#34;a\u0026#34;) for (i = 1; i \u0026lt; preList.length; i++) { console.log(preList[i].innerHTML) //jqueryAdd(preList[i].innerHTML.slice(0,-1)) } } \u0026lt;/script\u0026gt; 前端小白抄袭网页 实现了前端自动获取服务器指定目录后，后面的事情其实可以交给公司前端帮忙弄一下了，就是首页列表展示，没多大工作量的。\n但是，折腾的我还是想要自己来弄，在此分享一下自己如何直接将别人的网页拿来用的思路。\n首先，将别人的网页右键保存下来，找到想要用的地方，比如本例中想要列表，则右键检查到列表的某个子模块，复制整个 html 实现；\n然后，编写 js 方法如下，实现添加子模块到列表模块中；\n再者，将写好的方法放入到循环中，如本例上述注释的地方；\n最后，将原先的列表下的所有子模块删除，改为调用循环方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 \u0026lt;script\u0026gt; function jqueryAdd(name) { var child = $(` \u0026lt;div class=\u0026#34;el-col el-col-6\u0026#34; style=\u0026#34;padding-left: 20px; padding-right: 20px;\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;task-card\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;node-name node-name-my\u0026#34;\u0026gt;${name}\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;task-card-main\u0026#34; style=\u0026#34;background: url(/static/images/${name}.png); background-size: cover\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;task-card-top\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p class=\u0026#34;task-brief\u0026#34;\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;task-status\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;task-card-apply\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;apply-info\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;apply-btn\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;to-edit-task can-set\u0026#34; onclick=\u0026#34;window.open(\u0026#39;/project/${name}/start.html\u0026#39;)\u0026#34; \u0026gt;\u0026lt;span\u0026gt;查看原型\u0026lt;/span\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; `) $(\u0026#34;#taskListId\u0026#34;).append(child) } \u0026lt;/script\u0026gt; 最终实现结果如下：\n参考 Linux 局域网内共享｜SameWorld Parse an HTML string with JS｜stackoverflow Get list of filenames in folder with Javascript｜stackoverflow ","date":"2021-06-17T00:00:00Z","image":"https://images.helloqb.xyz/onedrive/blog-imag-001.jpeg","permalink":"/post/2021/0617_axshare.html","title":"Axure 原型图局域网共享指南"},{"content":"思路 第一个想法，是 java 接收 tail 命令返回，然后通过 websocket 推送给前端。也搜到了有人利用此方法实现了。\n但是个人觉得日志数据量较大，此方法效率偏低。巧的是，Apache Commons 包里已经帮忙实现了这个方法。\n要注意的是，2.4 之前不支持中文。Commons 包较为常见，一开始我没有考虑版本，拿着 2.4 的包用，结果乱码，且没有相关参数指定编码，一度打算舍弃它。\n实现 Apache Commons 包中的 Tailer 类实现了这个，一旦了解了这个类，实现起来就简单了。直接贴代码了。\nwebsocket 入口\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 import org.apache.commons.io.input.Tailer; import org.apache.commons.io.input.TailerListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.File; import java.util.concurrent.atomic.AtomicInteger; /** * websocket */ @ServerEndpoint(\u0026#34;/websocket/log/{sid}\u0026#34;) @Component public class WebSocketServerLog { private static Logger LOG = LoggerFactory.getLogger(WebSocketServerLog.class); private static AtomicInteger onlineCount = new AtomicInteger(0); private Tailer tailer; //接收sid private String sid=\u0026#34;\u0026#34;; /** * 连接建立成功调用的方法 */ @OnOpen public void onOpen(Session session, @PathParam(\u0026#34;sid\u0026#34;) String sid) { addOnlineCount(); this.sid = sid; //tail listen TailerListener listener = new MyTailerListener(session); tailer = Tailer.create(new File(\u0026#34;C:\\\\Users\\\\lenovo\\\\Desktop\\\\\u0026#34; + sid), listener, 1000, true); LOG.info(\u0026#34;sid \u0026#34; + sid + \u0026#34; on open and online count \u0026#34; + getOnlineCount()); } /** * 连接关闭调用的方法 */ @OnClose public void onClose() { subOnlineCount(); tailer.stop(); LOG.info(\u0026#34;sid \u0026#34; + sid + \u0026#34; on close and online count \u0026#34; + getOnlineCount()); } @OnError public void onError(Throwable error) { LOG.error(\u0026#34;sid {} error: {}\u0026#34;, sid, error.getMessage()); } public static int getOnlineCount() { return onlineCount.get(); } public static void addOnlineCount() { onlineCount.incrementAndGet(); } public static synchronized void subOnlineCount() { onlineCount.decrementAndGet(); } } 继承 Tailer\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import org.apache.commons.io.input.TailerListenerAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.websocket.Session; import java.io.IOException; /** * 继承 TailerListenerAdapter */ public class MyTailerListener extends TailerListenerAdapter { private static Logger LOG = LoggerFactory.getLogger(MyTailerListener.class); private Session session; public MyTailerListener(Session session) { this.session = session; } @Override public void handle(String line) { try { LOG.info(line); if (session.isOpen()){ session.getBasicRemote().sendText(line); } } catch (IOException e) { e.printStackTrace(); } } } 参考 Java WebSocket + tail命令实现Web实时日志｜叉叉哥的BLOG\n","date":"2021-06-09T00:00:00Z","permalink":"/post/2021/0609_tailer.html","title":"Web 实现日志文件实时监听"},{"content":" 2021-06-01 补充：捕捉文件上传大小限制的异常和参数 key 都不传的情况。\n前言 工作的项目中，先前的同事其实已经实现了参数校验异常统一处理。部分实现代码如下。\n1 2 3 4 5 6 7 8 //全局异常处理类 @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value = BaseException.class) public S2VJSONResult baseHandler(BaseException e) { return S2VJSONResult.msg(false, 400, e.getMessage()); } 1 2 3 4 5 6 7 8 //接口 @PostMapping(\u0026#34;/transfer/company\u0026#34;) S2VJSONResult transferCompany(@Validated @RequestBody AdminTransferCompanyVO vo, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return S2VJSONResult.msg(false, 400, bindingResult.getFieldError().getDefaultMessage()); } 1 2 3 4 5 6 7 8 9 //参数实体类 public class AdminTransferCompanyVO { @NotNull(message = \u0026#34;客户 ID 不能为空\u0026#34;) private List\u0026lt;Integer\u0026gt; companyIds; @NotNull(message = \u0026#34;目标客户经理 ID 不能为空\u0026#34;) private Integer targetCustomerId; @NotNull(message = \u0026#34;管理员 ID 不能为空\u0026#34;) private Integer operationCustomerId; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 //统一返回响应 public class S2VJSONResult\u0026lt;T\u0026gt; { private Integer code;//状态码 private Boolean isSuccess;//状态 private String message;//消息 private T data;//数据对象 public static \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; all(Boolean success, Integer code,String message,T data) { return new S2VJSONResult\u0026lt;\u0026gt;(success, code, message, data); } public static \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; msg(Boolean success, Integer code,String message) { return new S2VJSONResult\u0026lt;\u0026gt;(success, code, message); } 整理了一下其思路，大概如下：\n利用 @RestControllerAdvice 全局处理异常 Spring MVC 会将验证结果放入 BindResult 中 封装报错信息返回统一错误响应 我个人很懒，感觉有些地方还是重复代码了，不够优雅，比如 BindingResult 每个接口都要加，既然是异常应该就能够捕捉到，为什么不全局处理？验证的 message 每个字段都要写，可否在不写的情况下，返回特定模板的报错信息？\n全局处理 要解决我的问题，思路也很清晰，只要知道校验失败具体抛出的是什么异常，获取异常中有用的的报错信息，然后利用 @RestControllerAdvice 全局处理一下就好了。\n这里遇到个坑，同事写的全局处理类继承了 ResponseEntityExceptionHandler，而我要特定捕捉的异常都在这里面，所以我一开始的时候，老是报重复处理异常的错误。。。\n1. 处理 RequestParam 和 PathVariable 方式 Controller 层代码示例如下，将需要的约束添加到 @RequestParam 注解的前面，此时需要在类上添加注解 @Validated 才能实现验证。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Controller @RequestMapping(\u0026#34;/report1\u0026#34;) @ResponseBody @Validated public class TestController { @PostMapping(\u0026#34;/test1\u0026#34;) S2VJSONResult\u0026lt;String\u0026gt; test1(@Max(3) @RequestParam(\u0026#34;id\u0026#34;) Integer id){ return S2VJSONResult.all(true, 200, \u0026#34;success\u0026#34;, JSON.toJSONString(id)); } @PostMapping(\u0026#34;/test2/{id}\u0026#34;) S2VJSONResult\u0026lt;String\u0026gt; test2(@Max(value = 3, message = \u0026#34;别瞎填！\u0026#34;) @PathVariable(\u0026#34;id\u0026#34;) Integer id){ return S2VJSONResult.all(true, 200, \u0026#34;success\u0026#34;, JSON.toJSONString(id)); } 这种情况会抛出 ConstraintViolationException 异常。Spring 会处理这种异常并抛出报错信息，\n1 2 3 4 5 6 7 8 { \u0026#34;timestamp\u0026#34;: \u0026#34;2021-02-02 11:34:45\u0026#34;, \u0026#34;status\u0026#34;: 500, \u0026#34;error\u0026#34;: \u0026#34;Internal Server Error\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;test1.id: 最大不能超过3\u0026#34;, // \u0026#34;message\u0026#34;: \u0026#34;test2.id: 别瞎填！\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/pcg/report1/test1\u0026#34; } 可以看到 message 字段中有较为完整的报错信息，可以直接拿来用。统一异常处理代码如下，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 import javax.validation.ConstraintViolationException; /** * 处理 @RequestParam @PathVariable 参数验证失败 * * @param e * @return S2VJSONResult\u0026lt;T\u0026gt; * @Author zrr * @Date 2021/2/2 10:48 * @throws */ @ExceptionHandler(value = ConstraintViolationException.class) public \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; handlerConstraintViolationException(ConstraintViolationException e) { return S2VJSONResult.msg(false, 400, \u0026#34;参数验证未通过（\u0026#34; + e.getMessage() + \u0026#34;）\u0026#34;); } 另外，用户也存在参数 key 都不传的情况，这个时候的异常也需要捕捉。\n1 2 3 4 5 6 7 8 9 10 11 import org.springframework.web.bind.MissingServletRequestParameterException; /** * 处理 @RequestParam 参数验证失败（整个不传） * @param e * @param \u0026lt;T\u0026gt; * @return */ @ExceptionHandler(value = MissingServletRequestParameterException.class) public \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; handlerMissingServletRequestParameterException(MissingServletRequestParameterException e) { return S2VJSONResult.msg(false, 400, \u0026#34;参数验证未通过（\u0026#34; + e.getMessage() + \u0026#34;）\u0026#34;); } 封装报错信息，统一返回响应的结果如下，\n1 2 3 4 5 6 7 { \u0026#34;code\u0026#34;: 400, \u0026#34;isSuccess\u0026#34;: false, // \u0026#34;message\u0026#34;: \u0026#34;参数验证未通过（test1.id: 最大不能超过3）\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;参数验证未通过（test2.id: 别瞎填！）\u0026#34;, \u0026#34;data\u0026#34;: null } 基本实现了我想要的结果，不需要通过 BindingResult，只需要添加注解就可以校验参数，并且不写 message 也可以返回较为清楚的报错信息。\n但是这只是传参的一种情况，其他的情况抛出的异常还不一样，需要再次处理。\n2. 处理实体类接收 接口传入很多参数的时候，通常代码会用实体类接收。Controller 层代码示例如下，这种情况无需在类上添加注解 @Validated。\n1 2 3 4 @PostMapping(\u0026#34;/test3\u0026#34;) S2VJSONResult\u0026lt;String\u0026gt; test3(@Validated ReportVO vo){ return S2VJSONResult.all(true, 200, \u0026#34;success\u0026#34;, JSON.toJSONString(vo)); } 实体类添加相应的约束，\n1 2 3 4 5 6 7 8 9 10 11 12 13 import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import java.io.Serializable; public class ReportVO implements Serializable { @NotNull private Integer cgeId; @NotEmpty(message = \u0026#34;baseinfoSqrxm 不能为空\u0026#34;) private String baseinfoSqrxm; } 这种情况会抛出 BindException 异常。Spring 仍然会处理这种异常并抛出报错信息，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 { \u0026#34;timestamp\u0026#34;: \u0026#34;2021-02-02 13:58:18\u0026#34;, \u0026#34;status\u0026#34;: 400, \u0026#34;error\u0026#34;: \u0026#34;Bad Request\u0026#34;, \u0026#34;errors\u0026#34;: [ { \u0026#34;codes\u0026#34;: [ \u0026#34;NotNull.reportVO.cgeId\u0026#34;, \u0026#34;NotNull.cgeId\u0026#34;, \u0026#34;NotNull.java.lang.Integer\u0026#34;, \u0026#34;NotNull\u0026#34; ], \u0026#34;arguments\u0026#34;: [ { \u0026#34;codes\u0026#34;: [ \u0026#34;reportVO.cgeId\u0026#34;, \u0026#34;cgeId\u0026#34; ], \u0026#34;arguments\u0026#34;: null, \u0026#34;defaultMessage\u0026#34;: \u0026#34;cgeId\u0026#34;, \u0026#34;code\u0026#34;: \u0026#34;cgeId\u0026#34; } ], \u0026#34;defaultMessage\u0026#34;: \u0026#34;不能为null\u0026#34;, \u0026#34;objectName\u0026#34;: \u0026#34;reportVO\u0026#34;, \u0026#34;field\u0026#34;: \u0026#34;cgeId\u0026#34;, \u0026#34;rejectedValue\u0026#34;: null, \u0026#34;bindingFailure\u0026#34;: false, \u0026#34;code\u0026#34;: \u0026#34;NotNull\u0026#34; } ], \u0026#34;message\u0026#34;: \u0026#34;Validation failed for object=\u0026#39;reportVO\u0026#39;. Error count: 1\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/pcg/report1/test3\u0026#34; } 可以看到 defaultMessage 中有相应的报错信息，但是不够完整，可以将字段名 field 一起拼接后作为报错信息，统一异常处理代码如下，\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import org.springframework.validation.BindException; /** * 处理实体校验失败（multipart/form-data、x-www-form-urlencoded） * * @param e * @return S2VJSONResult\u0026lt;T\u0026gt; * @Author zrr * @Date 2021/2/2 10:50 * @throws */ @ExceptionHandler(value = BindException.class) public \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; handlerBindException(BindException e) { FieldError fieldError = e.getBindingResult().getFieldError(); assert fieldError != null; String message = MessageFormat.format(\u0026#34;参数 {0} 有误，{1}\u0026#34;, fieldError.getField(), fieldError.getDefaultMessage()); return S2VJSONResult.msg(false, 400, message); } 其中，e.getBindingResult() 中会有所有的参数报错信息，对我来说感觉一个就够了，所以我只取了一个，统一错误响应后的结果为，\n1 2 3 4 5 6 7 { \u0026#34;code\u0026#34;: 400, \u0026#34;isSuccess\u0026#34;: false, \u0026#34;message\u0026#34;: \u0026#34;参数 baseinfoSqrxm 有误，baseinfoSqrxm 不能为空\u0026#34;, // \u0026#34;message\u0026#34;: \u0026#34;参数 cgeId 有误，不能为null\u0026#34;, \u0026#34;data\u0026#34;: null } 3. 处理 RequestBody 情况 RequestBody 情况和上面很类似，但抛出的异常不一样，仍然需要单独处理。Controller 层代码示例如下，\n1 2 3 4 @PostMapping(\u0026#34;/test4\u0026#34;) S2VJSONResult\u0026lt;String\u0026gt; test4(@Validated @RequestBody ReportVO vo){ return S2VJSONResult.ok(JSON.toJSONString(vo)); } 这种情况会抛出 MethodArgumentNotValidException 异常。默认报错消息同 BindException，处理方式基本一致。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import org.springframework.web.bind.MethodArgumentNotValidException; /** * 处理 @RequestBody 参数验证失败（application/json） * * @param e * @return S2VJSONResult\u0026lt;T\u0026gt; * @Author zrr * @Date 2021/2/2 10:49 * @throws */ @ExceptionHandler(value = MethodArgumentNotValidException.class) public \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) { FieldError fieldError = e.getBindingResult().getFieldError(); assert fieldError != null; String message = MessageFormat.format(\u0026#34;参数 {0} 有误，{1}\u0026#34;, fieldError.getField(), fieldError.getDefaultMessage()); return S2VJSONResult.msg(false, 400, message); } 这三种情况基本涵盖了传参的所有方式，后面若是有遗漏的情况，也是同样的原理，再加上就好了。\n4. 处理集合 参数是集合如 list 的时候，@Validated 失效，但 @Valid 可以支持。\n1 2 3 4 5 6 import javax.validation.Valid; public S2VJSONResult\u0026lt;Object\u0026gt; batchUpdate(@RequestBody List\u0026lt;@Valid ProductModuleVO\u0026gt; list){ productModuleService.batchUpdate(list); return S2VJSONResult.ok(); } 此时，抛出的异常为 ConstraintViolationException，和第 1 种情况相同。\n5. 处理文件上传大小异常 可能由于没有继承 ResponseEntityExceptionHandler，若是项目配置了文件上传大小限制，当超出时，系统没有捕捉异常，需要手动捕捉。\n1 2 3 4 spring: servlet: multipart: max-file-size: 10MB 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.beans.factory.annotation.Value; @Value(\u0026#34;${spring.servlet.multipart.max-file-size}\u0026#34;) private String maxFileSize; /** * 处理文件上传大小限制 * * @param e * @return com...entity.S2VJSONResult\u0026lt;T\u0026gt; * @Author zrr * @Date 2021/5/6 11:48 * @throws */ @ExceptionHandler(value = MaxUploadSizeExceededException.class) public \u0026lt;T\u0026gt; S2VJSONResult\u0026lt;T\u0026gt; handlerMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { logger.error(\u0026#34;catch MaxUploadSizeExceededException {}\u0026#34;, e.getMessage()); return S2VJSONResult.msg(false, 400, \u0026#34;文件大小超出限制（\u0026#34; + maxFileSize + \u0026#34;）\u0026#34;); } 校验失败立即返回 上述三种默认情况都是校验全部字段后再返回报错信息，可以通过配置实现当一个参数校验有误时立即返回，提高效率。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import org.hibernate.validator.HibernateValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; /** * valid * * @author: zrr * @date: 2021/2/1 16:30 */ @Configuration public class ValidConfig { @Bean public Validator validator() { ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class) .configure() .failFast(true) //立即返回 .buildValidatorFactory(); return validatorFactory.getValidator(); } } Validated 验证组 接口通常有新增和更新，它们字段一致但是约束可能不同，若想要利用同一个实体类，可以通过组的方式实现共用。注意 @Valid 不支持组只有 @Validated 可以。\n首先新增两个接口组，\n1 2 3 import javax.validation.groups.Default; public interface ValidGroupCreate extends Default { } 1 2 3 import javax.validation.groups.Default; public interface ValidGroupUpdate extends Default { } 这里我继承了 Default.class 默认分组，可以实现参数没有标明组的情况时，也可以使用组。可以减少一些代码量。\n实体类添加约束如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 public class ReportVO implements Serializable { @NotNull private Integer cgeId; @NotEmpty(groups = ValidGroupCreate.class) private String baseinfoSqrxm; @NotEmpty(groups = ValidGroupUpdate.class) private String baseinfoSfzhm; @NotEmpty(groups = {ValidGroupCreate.class, ValidGroupUpdate.class}) private String baseinfoHyzk; 再然后 Controller 层指定组即可，不填的话则是默认分组，\n1 2 3 4 @PostMapping(\u0026#34;/test3\u0026#34;) S2VJSONResult\u0026lt;String\u0026gt; test3(@Validated(ValidGroupUpdate.class) ReportVO vo){ return S2VJSONResult.all(true, 200, \u0026#34;success\u0026#34;, JSON.toJSONString(vo)); } 注解用法 在此列举一些常用的校验注解，方便查看。\n@NotNull 验证带注释的属性值不为 null。 @AssertTrue 验证带注释的属性值为 true。 @Size 验证带注释的属性值的大小在属性 min 和 max 之间；可以应用于 String， Collection， Map和数组属性。 @Min 验证带注释的属性的值不小于 value 属性。 @Max 验证带注释的属性的值不大于 value 属性。 @Email 验证带注释的属性是有效的电子邮件地址。 @NotEmpty 验证该属性不为 null 或为空；可以应用于 String， Collection， Map或Array值。 @NotBlank 只能应用于文本值，并验证该属性不是null还是空白。 @Positive 和 @PositiveOrZero 适用于数值，并验证它们严格为正，或包括 0 在内为正。 @Negative 和 @NegativeOrZero 适用于数值，并验证它们严格为负数，或为负数（包括0）。 @Past 和 @PastOrPresent 验证日期值是过去还是现在（包括现在）；可以应用于日期类型，包括 Java 8中添加的日期类型。 @Future 和 @FutureOrPresent 验证日期值是将来的日期还是将来的日期（包括现在）。 @Pattern 字段或属性的值必须与 regexp 元素中定义的正则表达式匹配。 后续 可以创建自定义约束，目前项目大多用到就是非空判断，暂时先不弄\n参考 Validations in Spring ","date":"2021-02-02T00:00:00Z","permalink":"/post/2021/0201_validate.html","title":"后端接口校验参数异常统一处理"},{"content":"前言 远程控制软件有用过 TeamViewer、向日葵、Chrome Remote Desktop 等，TeamViewer 的稳定性流畅性最好，但是后台挂久了，会被检测商业用途，需要付费，那价格实在没法交个朋友。向日葵 挺好的，但是对国产软件向来有些警惕，不敢给予太高的权限。Chrome Remote Desktop 国内没法使用无人值守访问。Windows 自带的远程桌面不能连接非局域网内的用户，公网 IP 对于租客来说也没法弄，后来了解了内网穿透技术，正好解决了问题。\n简单使用 frp 是一个专注于内网穿透的高性能的反向代理应用，支持 TCP、UDP、HTTP、HTTPS 等多种协议。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。\nfrp 实现需要具有公网 IP 节点的中转站，我用的是谷歌云，公司也用了内网穿透技术，用的是腾讯云，比较后发现公司速度更快。\nServer 端安装 官网 下载对应系统的版本，我的 VPS 是 linux x64，所以选用的是版本是 frp_0.34.1_linux_amd64.tar.gz。\n下载后解压文件夹，将 frps.ini 和 frps 文件拷贝到服务器上，运行即可。示例如下：\n1 2 3 4 # 后台运行，输出默认重定向到当前目录下 nohup.out 文件 nohup /root/frp/frps -c /root/frp/frps.ini \u0026amp; # 后台运行，不记录输出信息 nohup /root/frp/frps -c /root/frp/frps.ini \u0026amp;\u0026gt; /dev/null \u0026amp; 其中 frps.ini 文件中 bind_port = 7000 表示默认监听端口为 7000，需要安全组策略允许入站和出站。\nClient 端安装 下载 Windows 端的版本 frp_0.34.1_windows_amd64.zip，也只需要两个文件 frpc.ini 和 frpc.exe。运行示例如下：\n1 2 # 文件所在目录下 frpc.exe -c frpc.ini frps.ini 文件的示例：\n1 2 3 4 5 6 7 8 9 [common] server_addr = 35.***.**.216 server_port = 7000 [RDP1] type = tcp local_ip = 127.0.0.1 local_port = 3389 remote_port = 7002 其中：\nserver_addr：server 端公网 IP server_port：server 端监听端口，同 bind_port type：tcp，默认模式，不够安全 local_ip：本机使用，填 127.0.0.1 即可 local_port：windows 远程桌面默认端口为 3389，可以在设置中看到 remote_port：远程用来映射的端口 此时若两端日志都显示 start proxy success 说明连接成功。远程桌面连接时计算机名称输入 [server_port]:[remote_port] 即可实现远程控制。\nWindows 开机无窗口启动 由于客户端要运行命令行，Windows 需要每次打开 shell 客户端，这里分享两个小技巧实现开机无窗口启动命令。\n脚本无窗口运行\n新建 .vbs 运行文件，输入以下命令，CreateObject(\u0026quot;WScript.Shell\u0026quot;).Run \u0026quot;***\u0026quot;,0 将 *** 替换成需要的命令。\n应用开机启动\n创建该文件的快捷方式到 用户\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup 目录下，即可实现开机自动运行。\n后台启动后，想要查看进程的话，Windows PowerShell 查看进程和删除进程的命令如下：\n1 2 Get-Process -name frp* Stop-Process -name frpc frp 进阶 stcp 模式（安全） tcp 暴露的端口，任何设备都可以访问，有较大的安全隐患。为此 frp 提供了 stcp 模式。\n被访问端配置示例如下：\n1 2 3 4 5 6 7 8 9 [common] server_addr = 35.***.**.216 server_port = 7000 [RDP2] type = stcp sk = abcdefg local_ip = 127.0.0.1 local_port = 3389 访问端配置示例如下：\n1 2 3 4 5 6 7 8 9 10 11 [common] server_addr = 35.***.**.216 server_port = 7000 [RDP2_visitor] type = stcp role = visitor server_name = RDP2 sk = abcdefg bind_addr = 127.0.0.1 bind_port = 6000 此时访问端使用 127.0.0.1:6000 即可远程连接。\n此方式两边客户端都要配置，不如 tcp 方便些。\nxtcp 模式（高效） xtcp 模式是点对点模式，实现不通过服务器中转流量的方式来访问内网服务。使用方式同 stcp 类似，需要在两边都部署上 frpc 用于建立直接的连接。\n服务端需要额外配置监听一个 UDP 端口。\n1 2 3 [common] bind_port = 7000 bind_udp_port = 7001 被访问端配置示例如下：\n1 2 3 4 5 6 7 8 9 [common] server_addr = 35.***.**.216 server_port = 7000 [RDP2] type = xtcp sk = abcdefg local_ip = 127.0.0.1 local_port = 3389 访问端配置示例如下：\n1 2 3 4 5 6 7 8 9 10 11 [common] server_addr = 35.***.**.216 server_port = 7000 [RDP2_visitor] type = xtcp role = visitor server_name = RDP2 sk = abcdefg bind_addr = 127.0.0.1 bind_port = 6000 此方式穿透成功率较低，我测试了下，竟然可用，速度明显快很多，所以我最终采用的是此方法。\n后续 上文提到公司也使用了内网穿透技术，采用 frp + openVPN 的方法，给每个人分配了一个密钥，员工打开 openVPN，加载密钥，就可以实现在家访问公司内部搭建的网站，当然远程控制也可以。感觉比直连的方式稳定些，后续可以研究下。\n","date":"2020-11-02T00:00:00Z","permalink":"/post/2020/1102_frp.html","title":"frp 实现无公网 IP 使用 Windows 远程桌面"},{"content":"介绍 项目中遇到数据库返回的字段不想全部返回给前端，一个一个 setter 挺麻烦，不够优雅。市面上有挺多映射框架如 Dozer，Orika，MapStruct，ModelMapper，JMapper 等，其中 MapStruct 只是代码生成器，使用纯 Java 的方法调用源对象和目标对象之间进行映射，并非 Java 反射机制，所以其性能最好。\n实现 Maven 依赖\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ... \u0026lt;properties\u0026gt; \u0026lt;org.mapstruct.version\u0026gt;1.3.1.Final\u0026lt;/org.mapstruct.version\u0026gt; \u0026lt;/properties\u0026gt; ... \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.mapstruct\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mapstruct\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${org.mapstruct.version}\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; ... \u0026lt;build\u0026gt; \u0026lt;plugins\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-compiler-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.8.1\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;source\u0026gt;1.8\u0026lt;/source\u0026gt; \u0026lt;target\u0026gt;1.8\u0026lt;/target\u0026gt; \u0026lt;annotationProcessorPaths\u0026gt; \u0026lt;path\u0026gt; \u0026lt;groupId\u0026gt;org.mapstruct\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mapstruct-processor\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${org.mapstruct.version}\u0026lt;/version\u0026gt; \u0026lt;/path\u0026gt; \u0026lt;/annotationProcessorPaths\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; \u0026lt;/plugins\u0026gt; \u0026lt;/build\u0026gt; ... 如果项目中有用到 lombok 的话，MapStruct 也支持，添加如下即可。\n1 2 3 4 5 6 7 \u0026lt;annotationProcessorPaths\u0026gt; \u0026lt;path\u0026gt; \u0026lt;groupId\u0026gt;org.projectlombok\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;lombok\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${lombok.version}\u0026lt;/version\u0026gt; \u0026lt;/path\u0026gt; \u0026lt;/annotationProcessorPaths\u0026gt; 普通调用\nMapStruct 定义非常简单，因为它完全基于代码生成。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface MapStructConverter { MapStructConverter MAPPER = Mappers.getMapper(MapStructConverter.class); @Mapping(source = \u0026#34;status\u0026#34;, target = \u0026#34;orderStatus\u0026#34;) @Override Order convert(SourceOrder sourceOrder); @Override DestinationCode convert(SourceCode sourceCode); } 调用也很简单。\n1 DestinationCode destinationCode = MapStructConverter.MAPPER.convert(sourceCode); Spring 注解调用\nSpring 项目的话可以如下参数实现依赖注入。\n1 2 3 @Mapper(componentModel = \u0026#34;spring\u0026#34;) public interface MapStructConverter { } @Autowired 注入调用更方便。\n1 2 3 4 @Autowired private MapStructConverter mapStructConverter; DestinationCode destinationCode = mapStructConverter.convert(sourceCode); 尾巴 其实这个问题困扰我很久了，之前一直想着用多态方式实现，没找到解决方法后也就用一个一个 setter 方式实现，现在想来很懒，以后还是多思考，多找找轮子，尽可能少写垃圾代码。\n参考：\nPerformance of Java Mapping MapStruct 1.3.1.Final Reference Guide mapstruct github ","date":"2020-08-21T00:00:00Z","permalink":"/post/2020/0821_mapstruct.html","title":"MapStruct 实现 Java Bean 映射"},{"content":"进一步了解 开发中遇到了 RabbitMQ 丢失的情况，另外一端一直说是我的 Java 端的问题，无奈做了验证，本文的主要内容也就是这个：如何验证消息发送成功。\n配置 pom.xml 文件中添加依赖。\n1 2 3 4 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.boot\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-boot-starter-amqp\u0026lt;/artifactId\u0026gt; \u0026lt;/dependency\u0026gt; application.yml 文件中添加配置开启确认。\n1 2 3 4 5 6 7 8 9 10 11 spring: rabbitmq: host: 192.168.0.102 port: 5672 username: admin password: admin virtual-host: sl publisher-confirm-type: correlated publisher-returns: true template: mandatory: true 重写方法 RabbitTemplate.ConfirmCallback 确认消息是否到达交换机， RabbitTemplate.ReturnCallback 确认消息是否到达队列中。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.connection.CorrelationData; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; @Component public class CustomRabbitSend implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback { private static final Logger LOG = LoggerFactory.getLogger(CustomRabbitSend.class); @Autowired private RabbitTemplate rabbitTemplate; @PostConstruct public void init(){ rabbitTemplate.setConfirmCallback(this); rabbitTemplate.setReturnCallback(this); } @Override public void confirm(CorrelationData correlationData, boolean b, String s) { //到达Exchange交换机，会调用此方法 if (!b) { LOG.error(\u0026#34;消息发送异常!\u0026#34;); } else { LOG.info(\u0026#34;发送者收到确认，correlationData={} ,ack={}, cause={}\u0026#34;, correlationData.getId(), b, s); } } @Override public void returnedMessage(Message message, int i, String s, String s1, String s2) { //没有到达Queue队列中，会调用此方法 LOG.info(\u0026#34;returnedMessage ===\u0026gt; replyCode={} ,replyText={} ,exchange={} ,routingKey={}\u0026#34;, i, s, s1, s2); } } 交换机和队列绑定 Exchange 交换机需要通过 routingKey 才能将消息传递到 Queue 队列中（本文只用到 direct 类型，其实无需绑定，fanout、topic 类型才需要）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import org.springframework.context.annotation.Configuration; import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.DirectExchange; import org.springframework.amqp.core.Queue; import org.springframework.context.annotation.Bean; @Configuration public class DirectRabbitConfig { //队列 @Bean public Queue customDirectQueue() { return new Queue(\u0026#34;testQueue\u0026#34;); } //Direct交换机 @Bean public DirectExchange customDirectExchange() { return new DirectExchange(\u0026#34;testDirectExchange\u0026#34;, true, false); } //将队列和交换机绑定 @Bean public Binding bindingDirect() { return BindingBuilder.bind(customDirectQueue()).to(customDirectExchange()).with(\u0026#34;testRoutingKey\u0026#34;); } } 发送 传递 CorrelationData 类，包含消息唯一标识（AmqpTemplate 类无法传递，之前一直用的这个，死活找不到方法）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Component public class TestScheduled { @Autowired private RabbitTemplate rabbitTemplate; @Scheduled(fixedDelay = 3000) public void errorSpiderRerun(){ String content = \u0026#34;test rabbit and now \u0026#34; + LocalDateTime.now().toString(); CorrelationData cId = new CorrelationData(\u0026#34;unique key\u0026#34;); rabbitTemplate.convertAndSend(\u0026#34;testDirectExchange\u0026#34;, \u0026#34;testRoutingKey\u0026#34;, content, cId); } } 接收 用 Message 类接收。目前我用的是 String 传输，可以配置成 JSON 传输更好些。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONException; import com.alibaba.fastjson.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CustomRabbitReceiver { private static final Logger LOG = LoggerFactory.getLogger(CustomRabbitReceiver.class); @RabbitListener(queues = \u0026#34;testQueue\u0026#34;) public void listen(Message message) { String body = new String(message.getBody()); LOG.info(\u0026#34;receiver from python:\u0026#34; + body); } } 谁的锅 加了验证后，Java 端没有丢失，另一端消息也确实拿掉了，研究可能是多线程的问题，之前用的是 pika，官方说多线程不安全，后续可以改为 rabbitpy 更安全。\n关联阅读\nrabbitpy documentation RabbitMQ 实现跨语言通信 Springboot 整合RabbitMq ，用心看完这一篇就够了 Spring AMQP ","date":"2020-07-28T00:00:00Z","permalink":"/post/2020/0728_rabbitmq.html","title":"进一步了解 RabbitMQ 跨语言通讯"},{"content":"入门穷烧 大三，不知何事引起，对耳机产生兴趣，可能是看到论坛们一堆 高音甜，中音准，低音沉 的分析，感觉他们从里面获得了很大的满足，所以也想尝试一下。\n鉴于一直以来耳机不都是十块钱的印象，只能将价位定位在了一两百。瞄准两款 AKG420 和 JVC HA-S500。\nAKG420 名声更大，知乎初烧推荐里几乎都有这款。巧了，宿舍串门的时候正好看到同学在用，试了下，佩戴舒适，低音有氛围，颜值低调却在线，很喜欢，现在想来还是想拥有一部。\nHA-S500 在网上的形容词是 白菜神器，说外国某论坛关于此耳机盖了很多层楼。\n可能是得不到的永远在骚动，我最终选择了 HA-S500 。怀着无比激动的心情收到后，很爱惜，很享受，什么高音低音的也瞬间都感觉有了，经常晚上关灯听很久。现在想来，脑放的成分居多，此耳机声音同价位一般，唯一可以吹的是它的质量，拿到手里感觉就是特别结实，一度让我对日本制造印象特别好。现在为止已经换了两个耳套，它仍然硬硬的，躺着抽屉里，等我偶然间想起。\n后来又买了 潜99 和 爱华 AIWA HP-M38，潜99 入耳式的没啥印象，HP-M38 还有些印象，因为很轻，佩戴很舒服，而 HA-S500 特别夹耳。\n再烧 又不知何事引起，想再烧下耳机，也了解到好耳机真的需要上放，而上放对于我这种简约派来说想想都麻烦，需要把歌曲导进导出的，没法直接用手机听。所以我下了个决定，只直推试听，不行就不再烧。 有了工资后，定位当然也高了，盖得排行里，锁定了接近三千价位的歌德 SR325e。但三千毕竟不少，淘宝上找到个商家可以试听，每天 50，遂下单。等待期间看到了魅族 HiFi 解码耳放这么个小玩意，说能使手机推力变大，也下单了。\n拿到后，没感觉多少惊喜，有效果，但不值得花三千，算了，退单，再也不烧。\n可心里总是还有欲望，别人怎么能听得这么爽，我是木耳吗，难道真得上放才行?\n退单后，依旧在论坛贴吧瞎逛，忽然发现了个新词 USB独占。大概意思是：\n“Android 系统为了兼容不同 APP 音频同时输出，需要使用 Mixer(混音模块)将这些声音混在一起后输出，Android 混音输出的采样率固定为 48k，如果某个 APP 输出的声音格式和混音输出结果不一样，就需要 SRC（采样率转换），这对音质是一个大大的损伤。而在独占模式下，APP 会把数字信号直接送给 USB 系统，通过 USB 接口送给数字耳机。”\n这个理论又勾起了我的欲望。但是 SR325e 已经被退了，想来好久都没买耳机了，再买一个耳机用吧，等着 HA-S500 用坏怕是不可能了。\n逛了一番后，买了 HIFIMAN SUNDARA。我靠，真的是独占的原因！！！\n欲望被彻底勾起，先是觉得 SUNDARA 低频差点劲，二手了 DT990 和 DT1350，DT1350 山洞音没感觉，DT990 解析好，人声远，低频足，但齿音更重。不行，还是不够完美。接着逛，reddit 上 HD58X 呼声很高，国内竟然没有专卖店，学了下海淘，竟然比想象中简单的多。一个月后才拿到手，没有期待的好，但也有所提升。\n闲鱼这玩意不能逛，尤其是耳机这种不嫌多的产品，这不一开始的 SR325e 降到了 900！这还不抢！卖家也热情，实话实说，头梁处断了（后面淘宝 50 块换了个新的），也想退烧了，所以卖的低点，又说 SR325e 不上放没法听。\n一句话，又海淘了个 Atom 耳放。\n耳放确实得要有。SUNDARA 在 reddit 论坛里很火的，但我初听时除了声场大些其他没啥感觉，低音不够，接上耳放后低音瞬间好多了。SR325e 接上后解析也更好，声音更凝聚。但还是一直的问题，没期待的那么惊喜，没论坛里烧友那么有感触\u0026hellip;\u0026hellip;\n忽然想到了一直不坏的 HA-S500，拿起来听了下，我靠！也太难听了吧，声音像糊了层纸一样。顿悟：\n每一次的耳机的提升，亦或是器材的变化虽让音质提升微小，但最后汇集起来，确实是一种让人回不去的听觉享受。\n最后的最后，我还是退烧了，耳机带来的提升远远不如歌曲本身，何况我也不是很喜欢听歌（这也是我一直想打自己的原因，MD 不听歌你烧个毛啊）\u0026hellip;\u0026hellip;当然了，由奢入俭难，想要回到几十块是不可能了，后面可能哪个耳机火了，闲鱼会试听玩玩。\n聊聊听感 AKG420 \u0026amp; JVC HA-S500\n再选一次的话，我会选择 AKG420，它佩戴更舒服些，颜值也比 HA-S500 高，HA-S500 摸着结实，耐操。\nHIFIMAN SUNDARA\nHIFIMAN 家的东西真是跳水王，但在国外却深受欢迎，reddit 上经常有它的身影，获奖也很多。佩戴舒适，颜值喜欢，初听感受声场大，但低音欠缺些，总感觉没推好，果然上放后好很多。可能这也是跳水的原因，上放才能出好声，它却打着直推的标签。\nBeyerdynamic DT990\n初听惊艳，声场大，人声远，解析好，但人声太刺了，听了会就受不了。上放后好多了，但还是有点刺，听说猛煲有改善。还有一点挺好，不夹眼镜。\nBeyerdynamic DT1350\n山洞音，官方价 1700，闲鱼 700 入的，再卖 200\u0026hellip;\u0026hellip;MD 不卖了。隔音好，易推，佩戴舒适，没必要贱卖。\nMASSDROP X SENNHEISER HD58X\nMASSDROP X SENNHEISER HD 58X JUBILEE 联名款。Youtube 上有个 UP 主评测说就是 HD660S 加了层纸，而 HD660S 是三千价位的，它一千价位，可以说性价比极高了。入手后，却没什么惊喜，可能我的耳放推力不足。\nGrado SR225e\n卖家说人声好，初听时起鸡皮疙瘩了。女声渲染的是不错，但我觉得一般，可能我不好这口吧。\nGrado SR325e\n最钟意的一款，声音热情洋溢，摇滚的那股劲，就它声出的好听，其它的都稍微欠缺些。直推好，上放更好。\n最终搭配 最终搭配：APP（USB Audio Player PRO）+ 解码（MEIZU HIFI DAC PRO）+ 耳放（JDS LABS ATOM AMP）\nUSB Audio Player PRO\n用于实现 USB 独占，谷歌商店 9 美元，小贵，可用海贝音乐代替。\nMEIZU HIFI DAC PRO\n很强大！它让手机也能玩得转 HiFi，配合 Atom 耳放可以将性能发挥到极致。我是看了削腚恶的文章 魅族HiFi解码耳放与魅族HiFi解码耳放Pro评测 种草的。\n“魅族线与魅族线Pro都是音频性能非常优异的解码耳放转接线；\n魅族厂商完全没有虚标参数； 魅族线的实际推力为80mW@32Ω，13mW@300Ω； 魅族线Pro的实际推力为100mW@32Ω，13mW@300Ω； 魅族线与魅族线Pro的实际失真都\u0026lt;0.0002% (1kHz，300Ω负载) 魅族线与魅族线Pro的实际信噪比和动态范围都\u0026gt;123dBA (300Ω负载)\n考虑到二者的价格，可以算是性价比搅乱市场的产品，建议根据实际情况，酌情购买。\n魅族今年先后发布的HiFi解码耳放、HiFi解码耳放Pro告诉我们，再没落的手机厂的供应链控制，研发实力，品控质检水平，也不是小众的HiFi厂家可以望其项背的。”\n软件 无损歌曲\nQQ 音乐和网易云音乐上下载的无损歌曲都进行了加密，第三方无法播放。好在不难，Github 上可以搜到大神写的转换工具。\nmp3tag\n强迫症的可通过此软件修改歌曲元数据，添加封面，更改专辑名啥的。官网\nfootbar\nflac 格式个人觉得最好，cue 有时候识别不了，wav 又没法写入标签。通过 footbar 都可以转成 flac 格式。官网\n后续（2020-08-06） 没到一个月就打脸了，入了拓品家的 Topping E30\u0026hellip;\u0026hellip;\n起初以为那种台机必须把歌曲先导入进去才能听，觉得很麻烦。后来了解到可以用手机或者电脑作为输入源的。还有我自己没有外出听歌的习惯，算了，抱着砖听就抱着砖听吧。\n把魅族线换成 E30 后惊喜了一晚上，听感又上升了两个台阶，有些歌曲听到了之前没注意的细节，至此，我觉得应该发挥了我这款歌德耳机所有的潜力了。\n后续再烧的动力，想到两个方面，一是手机连着解码器的线挺碍事的，边听边玩手机时拖着线不太方便，接口处还容易松动，后续想要尝试下蓝牙解码器。二是现在这套装备我觉得能推得动大部分耳机了，想要尝试下难推的耳机，之前一直以易推作为选耳机的标准的。\n最后想说的是，还挺烧钱的，克制点，老弟！\n后续（2020-08-17） 没到半个月又打脸了，入了拓品家的 BC3\u0026hellip;\u0026hellip;\n因为手机连接 DAC 时得拖着挺长的一根线不够方便，想尝试下蓝牙，经了解，LDAC 传输虽然是有损传输，但是已经够了。连接后，无奈发现，虽然我的手机支持外接 DAC 自动切换采样率，但是 LDAC 模式下却 SRC 了。\nSRC 其实对音质的损耗很大，之前没在意，现在听来确实有挺大差距。网上搜了下，索尼的 Music Center 可以解决 SRC 问题，但是我的手机只成功过两次，经常是切换了采样率，但是播放却变得异常缓慢，暂先搁置吧，等着 APP 兼容或者手机兼容。\n","date":"2020-07-13T00:00:00Z","image":"https://images.helloqb.xyz/onedrive/blog-imag-012.jpg","permalink":"/post/2020/0713_headphone.html","title":"耳机初烧之旅"},{"content":"官方文档 VLOOKUP（查阅值、包含查阅值的区域、包含返回值的区域中的列号、近似匹配（TRUE）或完全匹配（FALSE））。其中：\n要查找的值，也被称为查阅值。\n查阅值所在的区域。 请记住，查阅值应该始终位于所在区域的第一列，这样 VLOOKUP 才能正常工作。 例如，如果查阅值位于单元格 C2 内，那么您的区域应该以 C 开头。\n区域中包含返回值的列号。 例如，如果指定 B2： D11 作为区域，则应将 B 作为第一列，将 C 作为第二列进行计数，依此类推。\n（可选）如果需要返回值的近似匹配，可以指定 TRUE；如果需要返回值的精确匹配，则指定 FALSE。 如果没有指定任何内容，默认值将始终为 TRUE 或近似匹配。\n实现 如下表格，想要知道左边哪些是已婚人士。\n1. 输入 VLOOKUP\n在上表 2B 单元格内输入 =VLOOKUP(。\n2. 选择查找值\n括号后第一个参数选中 2A，然后输入英文逗号。\n3. 选择查找区域\n括号后第二个参数选中 F:G 列，然后输入英文逗号。\n如果不选中整列，而是只是选中有数据部分如 F2:G4 列，则后续下拉拖动方式此参数会随之变化，导致没有得出想要的结果。\n全选时注意首行会不会影响结果。\n4. 其他参数\n第三个参数是匹配查阅区域的第几列，最后为是否精确匹配。\n最后整个公式为 =VLOOKUP(A2,F:G,1,FALSE)。\n5. 复制公式\n此时只是第一个单元格的结果，采用下拉拖动方式或者复制粘贴方式将公式复制到列中其他的单元格中。这两种方式粘贴的公式都会行自动变化。\n6. 获取其他单元格\n第二列数据改下第三个参数为 2 就行了。\n","date":"2020-06-04T00:00:00Z","permalink":"/post/2020/0604_vlookup.html","title":"EXCEL 技巧之 - VLOOKUP 函数"},{"content":"需求 需求其实很简单，就是合并单元格，行数少的情况下一个一个点就行了，但是行数多的情况下就麻烦了，秉承着重复性工作一定能被计算机代替的想法，翻阅很久找到了方法，在此分享。\n实现 1. 数据（菜单栏） -\u0026gt; 分类汇总\n其中：\n分类字段：需要合并的列，本例中为身份； 汇总方式：无关，默认即可； 选定汇总项：同分类字段； 替换当前分类汇总：无关，默认即可； 每组数据分页：不选； 汇总结果显示在数据下方：选中 2. 汇总列合并空值单元格\n对生成的汇总列进行空值定位，并合并空值单元格。操作如下：\n汇总列第一行填 1 -\u0026gt; 选中汇总列 -\u0026gt; 开始(菜单栏) -\u0026gt; 查找和选择 -\u0026gt; 定位条件：空值 -\u0026gt; 合并后居中\n3. 间隔一行（非必须）\n如果想要每一类间隔一行的话，使用操作 2 的步骤定位合并列（定位条件：数值）然后插入工作表行就行了。\n提一下这个是觉得定位这个功能很强大的样子，类似于编辑器中的多光标选择。\n4. 格式刷合并列\n将汇总列格式刷到合并列。\n4. 删除分类汇总\n数据（菜单栏） -\u0026gt; 分类汇总 -\u0026gt; 全部删除。\n","date":"2020-06-04T00:00:00Z","permalink":"/post/2020/0604_merge.html","title":"EXCEL 技巧之 - 相同单元格合并"},{"content":"介绍 以 Windows 自带纯色壁纸为基础，效果如上封面所示：\n流程 1. 用 Photoshop 打开壁纸\n个人喜欢纯色壁纸，Windows10 上可通过背景设置为纯色壁纸后截屏得到。\n2. 新建图层并填充为白色\n操作： 图层（菜单栏）-\u0026gt; 新建 -\u0026gt; 图层 -\u0026gt; 编辑（菜单栏）-\u0026gt; 填充 -\u0026gt; 背景色\n3. 缩小一半长宽比\n操作：编辑（菜单栏）-\u0026gt; 自由变换 -\u0026gt; W:49% H:49%\n49% 也就是留了 2% 的缝隙，可根据自己喜好修改。\n4. 切割图层\n以变换后的图层作为选区，分别左上、左下、右上、右下复制图层。\n操作：\n对齐：选中两个图层，点击菜单栏图层里的对齐，左对齐，然后再顶部对齐； 载入选区：按住 Ctrl，并点击白色图层的缩略图； 复制图层：点击背景图层，点击 Ctrl + J，将所选区域复制到新图层中； 同样方式左下、右上、右下生成另外三个图层； 5. 增加图层样式\n操作：\n双击图层打开图层样式，选择投影，距离和大小设置为 20 像素； 右键图层拷贝图层样式，然后复制到另外三个图层中； 大功告成，当然可根据自己喜好选择样式，添加相关文字等。\n","date":"2020-06-03T00:00:00Z","image":"https://images.helloqb.xyz/onedrive/blog-imag-017.png","permalink":"/post/2020/0603_ps.html","title":"四宫格办公壁纸制作"},{"content":"介绍 本文主要记录下 cron 表达式的用法，方便下次使用。\n配置 程序主入口添加 @SpringBootApplication 和 @EnableScheduling 注解以启动定时任务。\n1 2 3 4 5 6 7 8 9 10 11 12 import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class SchedulingTasksApplication { public static void main(String[] args) { SpringApplication.run(SchedulingTasksApplication.class); } } 然后方法上添加 @Scheduled 注解就行了。当然类注解 @Component 也必不可少。\n1 2 3 4 @Scheduled(fixedDelay = 5000) public void reportCurrentTime() { System.out.println(new Date()); } 定时调度 fixedDelay 毫秒单位，以上一个调用的完成时间开始计算。\n如间隔 3 秒，每个任务耗时 2 秒：\n1 2 3 4 5 6 7 8 9 10 11 @Scheduled(fixedDelay = 3000) public void errorSpiderRerun(){ LocalDateTime localDateTime = LocalDateTime.now(); System.out.println(\u0026#34;start \u0026#34; + localDateTime); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;end \u0026#34; + LocalDateTime.now()); } 结果如下：\n1 2 3 4 5 6 7 start 2020-06-02T15:49:59.650 end 2020-06-02T15:50:01.651 start 2020-06-02T15:50:04.653 end 2020-06-02T15:50:06.653 start 2020-06-02T15:50:09.654 end 2020-06-02T15:50:11.655 start 2020-06-02T15:50:14.657 fixedRate 需要固定速率，则使用 fixedRate。\n如间隔 3 秒，每个任务耗时 2 秒：\n1 2 3 4 5 6 7 8 9 10 11 @Scheduled(fixedRate = 3000) public void errorSpiderRerun(){ LocalDateTime localDateTime = LocalDateTime.now(); System.out.println(\u0026#34;start \u0026#34; + localDateTime); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(\u0026#34;end \u0026#34; + LocalDateTime.now()); } 结果如下：\n1 2 3 4 5 6 7 8 start 2020-06-02T15:58:03.563 end 2020-06-02T15:58:05.563 start 2020-06-02T15:58:06.563 end 2020-06-02T15:58:08.563 start 2020-06-02T15:58:09.563 end 2020-06-02T15:58:11.563 start 2020-06-02T15:58:12.563 end 2020-06-02T15:58:14.563 但是若上个任务耗时超过间隔，则下一次也需要在等待，等到结束后下一次立刻执行。\n如等待 7 秒后结果如下：\n1 2 3 4 5 6 7 8 9 start 2020-06-02T16:02:37.726 end 2020-06-02T16:02:44.727 start 2020-06-02T16:02:44.727 end 2020-06-02T16:02:51.727 start 2020-06-02T16:02:51.727 end 2020-06-02T16:02:58.727 start 2020-06-02T16:02:58.727 end 2020-06-02T16:03:05.728 start 2020-06-02T16:03:05.728 initialDelay 默认项目启动立即执行定时任务，若想要延迟，使用 initialDelay 参数。\n1 @Scheduled(initialDelay = 10000, fixedRate = 3000) 结果如下：\n1 2 3 2020-06-02 16:09:00.487 [main] INFO LabelApplication - Started LabelApplication in 4.901 seconds (JVM running for 5.689) start 2020-06-02T16:09:10.467 end 2020-06-02T16:09:15.468 cron 表达式 用法太多了，每次项目用到就记一下吧。\n表达式 描述 1 2 3 * * ? * 每天 3 点 2 分 1 秒执行 1 2 3 L * ? * 每月最后一天 3 点 2 分 1 秒执行 1 2 3 4 * ? * 每月 4 号 3 点 2 分 1 秒执行 0 0 12 ? * WED 每周三执行 ","date":"2020-06-02T00:00:00Z","permalink":"/post/2020/0602_cron.html","title":"Spring 定时任务"},{"content":"需求场景 做项目时遇到需要 Java 和 Python 通信的场景，摸索到了三种解决方案，并都正式在项目中实施过、踩坑过、改进过、整个推翻替换过。\n其中 RabbitMQ 是我最终的方案，所以会重点阐述，其中略带提一下。\n三种方式的实现 1. 直接调用 有 ProcessBuilder 和 Runtime.exec() 两种方法，用法差不多，都可以传一个字符串数组。\n1 2 String[] command = new String[]{\u0026#34;python\u0026#34;, \u0026#34;C:\\\\Users\\\\lenovo\\\\Desktop\\\\java2python.py\u0026#34;}; proc = Runtime.getRuntime().exec(command);// 执行py文件 ProcessBuilder 可以是列表，官方推荐 ProcessBuilder。\n1 2 3 4 5 List\u0026lt;String\u0026gt; command = new ArrayList\u0026lt;\u0026gt;(); command.add(\u0026#34;python\u0026#34;); command.add(\u0026#34;C:\\\\Users\\\\lenovo\\\\Desktop\\\\java2python.py\u0026#34;); ProcessBuilder pb = new ProcessBuilder(command); proc = pb.start(); Process 提供 InputStream 和 ErrorStream 接收返回结果；为方便起见，可以将错误流重定向到输入流。\n1 2 pb.redirectErrorStream(true);//错误流重定向 BufferedReader in = new BufferedReader(new InputStreamReader(proc.getInputStream())); 若需要立即获取其返回结果，需要用 waitFor() 方法。\n1 2 //导致当前线程等待，如有必要，直到由此Process对象表示的进程已终止 code = proc.waitFor(); 坑不坑：\nJava 自带，开发方便（这个很重要\u0026hellip;\u0026hellip;老板就给你两天时间你能怎么办）。 作为一个进程开启，效率低，好在是官方类，使用时感觉控制的还行。 返回结果难以接收。可以通过输入流或者写入文件读取，但是都很不好。 目前只尝试了 Java 调用 Python，反过来感觉很难。 完整代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Test public void java2pythonTest(){ int code = 1; Process proc; try { // Runtime.getRuntime() //\tString[] command = new String[]{\u0026#34;python\u0026#34;, \u0026#34;C:\\\\Users\\\\lenovo\\\\Desktop\\\\java2python.py\u0026#34;}; // proc = Runtime.getRuntime().exec(command);// 执行py文件 List\u0026lt;String\u0026gt; command = new ArrayList\u0026lt;\u0026gt;(); command.add(\u0026#34;python\u0026#34;); command.add(\u0026#34;C:\\\\Users\\\\lenovo\\\\Desktop\\\\java2python.py\u0026#34;); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true);//错误流重定向 proc = pb.start(); //用输入输出流来截取结果 BufferedReader in = new BufferedReader(new InputStreamReader(proc.getInputStream(), \u0026#34;gbk\u0026#34;)); String line = null; while ((line = in.readLine()) != null) { System.out.println(\u0026#34;python\u0026gt; \u0026#34; + line); } in.close(); //导致当前线程等待，如有必要，直到由此Process对象表示的进程已终止 code = proc.waitFor(); System.out.println(\u0026#34;python\u0026gt; wait for code: \u0026#34; + code); } catch (IOException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } 2. 通过 API 通信 两端都启动为一个 Web 项目，Java 端可以采用 Spring Boot 框架，Python 端用 Flask 框架。然后通过 Spring Cloud 来连接。\n由于 Spring Cloud 非本人搭建，就不讲了。当然，你也没必要非要使用。\n坑不坑：\n两端通信频繁的情况下，相对于第一种方案更好些 开发有点烦，尤其是数据传输，如果用 Spring Cloud 会好些 不适用于等待的情况，如 Python 端计算量大，没法立刻给出结果。Api 嘛，时间长肯定不太好 3. RabbitMQ 消息队列 因为实际公司开发中，Python 端计算量很大，需要等待很久，所以方法二也不太适用，后面有同事提出用消息队列的方式，可以不必等待，正好满足我的情况。\nRabbitMQ 不仅要有接收端、发送端，还需要安装管理器。\n管理系统 管理系统各平台都有，也有 docker 镜像。具体不赘述，看官网就行了。\n安装后会有 Web 端图形界面，使用很方便，不需要命令行就可以完成。\n大概介绍下用到的一些概念，后续深入的话再补充。\nVirtual Hosts\n相当于 Mysql 的库，相互独立，exchange、queue、message 不能互通。需要账户管理员权限。Web 端操作如下。\nQueue\n队列，基本通信模块，相当于一个消息缓冲区，消息进到队列里，再被发送出去。\nExchange\n生产者发送消息时由 Exchange 来决定要给哪个 Queue。具体有 Direct、Fanout、Topic、Headers 四种类型，我用默认的一对一也就没有仔细了解。\nJava 端实现 配置\n我采用 Spring Boot 框架，自带 Spring AMQP 兼容 RabbitMQ，很方便推荐使用。直接在 yml 文件中配置相关参数。\n1 2 3 4 5 6 7 spring: rabbitmq: host: 192.168.0.102 port: 5672 username: admin password: admin virtual-host: test 发送消息\nAmqpTemplate 类用于发送消息，我写了个定时 3 秒发送时间：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Component public class TestScheduled { //rabbitMQ Queue public static final String PYTHON2JAVA = \u0026#34;python2java\u0026#34;; public static final String JAVA2PYTHON = \u0026#34;java2python\u0026#34;; @Autowired private AmqpTemplate rabbitTemplate; @Scheduled(fixedDelay = 3000) public void errorSpiderRerun(){ String now = LocalDateTime.now().toString(); String content = \u0026#34;java2python and now \u0026#34; + now; System.out.println(content); rabbitTemplate.convertAndSend(JAVA2PYTHON, content); } } 如果没有客户端接收的话，会一直在队列中，Web 页面下可以看到：\n接收消息\n接收消息也非常简单，只需要添加 @RabbitListener 注解即可。\n1 2 3 4 @RabbitListener(queues = PYTHON2JAVA) public void listen(String message) { System.out.println(message); } Python 端实现 Python 端需要安装第三方库 pika。\n建立连接\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def get_connect(): \u0026#34;\u0026#34;\u0026#34; 连接 :return: \u0026#34;\u0026#34;\u0026#34; config_pika_username = \u0026#34;admin\u0026#34; cofig_pika_password = \u0026#34;admin\u0026#34; config_pika_host = \u0026#34;192.168.0.102\u0026#34; config_pika_port = 5672 config_pika_virtual_host = \u0026#34;test\u0026#34; cred = pika.PlainCredentials(config_pika_username, cofig_pika_password) connection = pika.BlockingConnection( pika.ConnectionParameters( host=config_pika_host, port=config_pika_port, virtual_host=config_pika_virtual_host, credentials=cred, heartbeat=0 )) return connection 定义回调函数\n1 2 def callback(ch, method, properties, body): print(body) 等待接收\n运行此方法，等待接收。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def build(): \u0026#34;\u0026#34;\u0026#34; start_consuming :return: \u0026#34;\u0026#34;\u0026#34; channel = get_connect().channel() channel.queue_declare(queue=\u0026#39;java2python\u0026#39;, durable=True) channel.basic_consume(queue=\u0026#39;java2python\u0026#39;, auto_ack=True, on_message_callback=callback) print(\u0026#39; consumer_start\u0026#39;) channel.start_consuming() if __name__ == \u0026#39;__main__\u0026#39;: build() 发送消息\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 def producer(): \u0026#39;\u0026#39;\u0026#39; 生产者 :param rabbit_body: 发送内容 \u0026#39;\u0026#39;\u0026#39; # 创建通道 connection = get_connect() channel = connection.channel() channel.queue_declare(queue=\u0026#39;python2java\u0026#39;, durable=True) # 发送内容 message = \u0026#34;python2java and now \u0026#34; + str(datetime.now()) print(message) channel.basic_publish(exchange=\u0026#39;\u0026#39;, routing_key=\u0026#39;python2java\u0026#39;, body=message) 坑不坑：\n非长连接方式，效率相对接口方式更高。 如果项目所用框架如 Spring Boot 已经集成的话，开发会很方便。 有担心长时间没有消息队列会断开连接的情况，也有遇到，但好像都已解决。 后续 只是简单介绍用到的类和方法，流于表面，后续如果频繁使用的话，需要深入了解。\n","date":"2020-05-15T00:00:00Z","permalink":"/post/2020/0515_rabbitmq.html","title":"RabbitMQ 实现跨语言通信"},{"content":"认识 常言道，不装扩展的浏览器只能发挥它 40% 的能力。而扩展中不得不提的神器就是 油猴脚本。\n相对扩展来说，它更轻量，只作用于指定网站，占用内存少。所以在脚本和扩展实现相同功能的情况下，我都是优先选用脚本。\n个人认为油猴脚本就是一串 Javascript 代码，因而前端工程师很容易上手。作为后端工程师的我。。。应该也比较容易上手吧。开干！\n脚本编写 开发环境 Chrome：可以更方便的定位 DOM。 暴力猴：有个为此站点查找脚本的功能，很方便。 需求 如果你像我一样，只是想在某个页面添加一个按钮，实现快速跳转的功能。而自己又非前端，后面的内容应该会帮到你。示例 js 很简单，前端大神的话直接无视看下元数据块的内容就行了。\n以 torrentz2 网站为例，该网站磁力链接无法跳转，现在实现点击某一按钮后跳转到谷歌或者弹出磁力链接。\n元数据块（Metadata Block） 用暴力猴新建脚本时，会默认创建基础的元数据块。\n1 2 3 4 5 6 7 8 9 // ==UserScript== // @name New script - torrentz2.eu // @namespace Violentmonkey Scripts // @match *://torrentz2.eu/* // @grant none // @version 1.0 // @author - // @description 2020/5/9 下午4:13:37 // ==/UserScript== 其中：\nname：名称 namespace：命名空间，正式发布时需要 match：脚本适用网站，另外有个 include 用法差不多，官网说这个匹配规则更安全些 grant：控制特定 api 的使用，不常用，none 即可 version：版本 author：作者 description：描述 其余我用到的有：\n1 2 3 4 // ==UserScript== // @require http://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js // @run-at document-idle // ==/UserScript== 其中：\nrequire：加载第三方库，比如 jquery run-at：控制脚本在页面哪个阶段运行，如 document-idle 为将在页面和所有资源加载之后运行 我只是将个人用到的介绍下，详细请参考 官方文档\n方案一：额外添加按钮 简单粗暴的在页面上添加一个很丑很丑的按钮。 1 2 3 4 5 var input=document.createElement(\u0026#34;input\u0026#34;); input.type=\u0026#34;button\u0026#34;; input.value=\u0026#34;Custom Button\u0026#34;; input.setAttribute(\u0026#34;style\u0026#34;, \u0026#34;font-size:18px;position:absolute;top:120px;right:40px;\u0026#34;); document.body.appendChild(input); 然后为按钮添加点击事件。 1 2 3 4 5 6 7 input.onclick = skipFunc; function skipFunc() { var info_hash = document.querySelector(\u0026#34;#wrap \u0026gt; div.trackersvotebox \u0026gt; div.trackers \u0026gt; div\u0026#34;).innerHTML window.open(\u0026#34;https://www.google.com/search?q=\u0026#34; + info_hash.replace(\u0026#34;info_hash: \u0026#34;, \u0026#34;\u0026#34;)) } 本示例中，需要获取 info_hash 内容，浏览器直接右键检查复制 Selector 选择器，用 querySelector 方法定位即可。\n另外，innerHTML、window.open 等其他方法就不赘述了，最后展示如下图，点击会跳转至谷歌。\n丑哭了有没有。。整个用户脚本如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // ==UserScript== // @name New script - torrentz2.eu // @namespace Violentmonkey Scripts // @match *://torrentz2.eu/* // @require http://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js // @grant none // @run-at document-idle // @version 1.0 // @author - // @description 2020/5/9 下午4:13:37 // ==/UserScript== // add button in top var input=document.createElement(\u0026#34;input\u0026#34;); input.type=\u0026#34;button\u0026#34;; input.value=\u0026#34;Button\u0026#34;; input.onclick = showAlert; input.setAttribute(\u0026#34;style\u0026#34;, \u0026#34;font-size:18px;position:absolute;top:120px;right:40px;\u0026#34;); document.body.appendChild(input); function showAlert() { var info_hash = document.querySelector(\u0026#34;#wrap \u0026gt; div.trackersvotebox \u0026gt; div.trackers \u0026gt; div\u0026#34;).innerHTML window.open(\u0026#34;https://www.google.com/search?q=\u0026#34; + info_hash.replace(\u0026#34;info_hash: \u0026#34;, \u0026#34;\u0026#34;)) } 方案二：页面原有元素添加点击 在 info_hash 文本上添加点击事件，点击文本会跳转至谷歌。\n1 2 var infoHash = document.querySelector(\u0026#34;#wrap \u0026gt; div.trackersvotebox \u0026gt; div.trackers \u0026gt; div\u0026#34;) infoHash.onclick = function(){window.open(\u0026#34;https://www.google.com/search?q=\u0026#34; + infoHash.innerHTML.replace(\u0026#34;info_hash: \u0026#34;, \u0026#34;\u0026#34;))} 此方案完美隐藏，没有破坏原有页面。\n方案三：按钮嵌入至页面 思路是在某一节点中，添加其子节点，以共用其子节点的样式。\n找到并复制兄弟节点的所有元素 找到父节点的 Selector 选择器 appendTo 方法添加到父节点中 1 2 3 4 5 var infoHash = document.querySelector(\u0026#34;#wrap \u0026gt; div.trackersvotebox \u0026gt; div.trackers \u0026gt; div\u0026#34;) var infoHashText = infoHash.innerHTML.replace(\u0026#34;info_hash: \u0026#34;, \u0026#34;\u0026#34;) $(` \u0026lt;a href=\u0026#34;https://www.google.com/search?q=${infoHashText}\u0026#34; target=\u0026#34;_blank\u0026#34; \u0026gt;Google\u0026lt;/a\u0026gt; `).appendTo(document.querySelector(\u0026#34;#votebox \u0026gt; span.replist\u0026#34;)); 如下，完美嵌入。\n推荐 网站链接 暴力猴 Greasy Fork：脚本搜索 Sleazy Fork：大人的 Greasy Fork 官方教程 自用脚本 豆瓣资源下载大师 CSDN页面浮窗广告完全过滤净化 ","date":"2020-05-07T00:00:00Z","image":"https://images.helloqb.xyz/onedrive/blog-imag-040.jpg","permalink":"/post/2020/0507_monkey.html","title":"油猴脚本初探"},{"content":"边转码边下载 下载通常都会遇到这种情况，代码里通常保存为 utf-8 格式，但是用户想要 gbk 格式，实现思路如下：\n方案一： PrintWriter + CharacterEncoding SpringMVC 框架的接口中 Response 有 ServletOutputStream 和 PrintWriter 两种输出，还有一个 setCharacterEncoding 方法。\nOutPutStream 视为写入二进制数据，容器不对二进制数据进行编码，即 setCharacterEncoding 无效。所以先采用 PrintWriter 搭配 setCharacterEncoding 方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @GetMapping(\u0026#34;/downloadFile1\u0026#34;) public void downloadFile1(HttpServletResponse response) throws IOException{ //读取本地文件 File file = new File(LOCAL_FILE_PATH); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file), \u0026#34;UTF-8\u0026#34;)); //设置下载 response.setHeader(\u0026#34;Content-Disposition\u0026#34;, \u0026#34;attachment;filename=file.csv\u0026#34;); response.setContentType(\u0026#34;application/octet-stream\u0026#34;); response.setCharacterEncoding(\u0026#34;GB2312\u0026#34;); //传输 BufferedWriter bufferedWriter = new BufferedWriter(response.getWriter()); String line; while ((line = bufferedReader.readLine()) != null) { bufferedWriter.write(line + \u0026#34;\\r\\n\u0026#34;); bufferedWriter.flush(); } bufferedReader.close(); bufferedWriter.close(); } 无用，没有仔细研究，应该 setCharacterEncoding 只是告诉浏览器是什么编码，没有解码功能。\n方案二：ServletOutputStream + OutputStreamWriter 将 response.getOutputStream() 获取到的流用 OutputStreamWriter 包装。结果可行。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @GetMapping(\u0026#34;/downloadFile2\u0026#34;) public void downloadFile2(HttpServletResponse response) throws IOException{ //读取本地文件 File file = new File(LOCAL_FILE_PATH); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file), \u0026#34;UTF-8\u0026#34;)); //设置下载 response.setHeader(\u0026#34;Content-Disposition\u0026#34;, \u0026#34;attachment;filename=file.csv\u0026#34;); response.setContentType(\u0026#34;application/octet-stream\u0026#34;); //传输 OutputStreamWriter writerStream = new OutputStreamWriter(response.getOutputStream(),\u0026#34;GBK\u0026#34;); String line; while ((line = bufferedReader.readLine()) != null) { writerStream.write(line + \u0026#34;\\r\\n\u0026#34;); writerStream.flush(); } bufferedReader.close(); writerStream.close(); } 方案三：ServletOutputStream + getBytes 在按行写入字符串时，使用 getBytes 方法将字符串转换为想要输出的编码格式。结果可行。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @GetMapping(\u0026#34;/downloadFile3\u0026#34;) public void downloadFile3(HttpServletResponse response) throws IOException{ //读取本地文件 File file = new File(LOCAL_FILE_PATH); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file), \u0026#34;UTF-8\u0026#34;)); //设置下载 response.setHeader(\u0026#34;Content-Disposition\u0026#34;, \u0026#34;attachment;filename=file.csv\u0026#34;); response.setContentType(\u0026#34;application/octet-stream\u0026#34;); //传输 OutputStream outputStream = response.getOutputStream(); String line; while ((line = bufferedReader.readLine()) != null) { outputStream.write((line + \u0026#34;\\r\\n\u0026#34;).getBytes(\u0026#34;GB2312\u0026#34;)); outputStream.flush(); } bufferedReader.close(); outputStream.close(); } 无需转换的写法 若不需要转码的话，就很简短了，用字节流就行了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @GetMapping(\u0026#34;/downloadFile4\u0026#34;) public void downloadFile4(HttpServletResponse response) throws IOException{ //读取本地文件 File file = new File(LOCAL_FILE_PATH); FileInputStream fis = new FileInputStream(file); //设置下载 response.setHeader(\u0026#34;Content-Disposition\u0026#34;, \u0026#34;attachment;filename=file.csv\u0026#34;); response.setContentType(\u0026#34;application/octet-stream\u0026#34;); //传输 OutputStream os = response.getOutputStream(); byte[] buffer = new byte[4096]; int b = -1; while ((b = fis.read(buffer)) != -1) { os.write(buffer, 0, b); } fis.close(); os.close(); } 简写的话可以用第三方包。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import org.springframework.util.FileCopyUtils; @GetMapping(\u0026#34;/downloadFile5\u0026#34;) public void downloadFile5(HttpServletResponse response) throws IOException{ //读取本地文件 File file = new File(LOCAL_FILE_PATH); InputStream inputStream= new BufferedInputStream(new FileInputStream(file)); //设置下载 response.setHeader(\u0026#34;Content-Disposition\u0026#34;, \u0026#34;attachment;filename=file.csv\u0026#34;); response.setContentType(\u0026#34;application/octet-stream\u0026#34;); //传输 FileCopyUtils.copy(inputStream, response.getOutputStream()); } 多文件打包下载 当需要下载多个文件时，可以将其打包成一个文件，然后下载。Java 自带的 ZipOutputStream 可以实现。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @Override public void download(OutputStream outputStream) throws IOException { // Create the ZIP file ZipOutputStream zipOut = new ZipOutputStream(outputStream); // image demo Map\u0026lt;String, String\u0026gt; imageMap = new HashMap\u0026lt;\u0026gt;; //add file to zip ZipEntry zipEntry; for (String imageName : imageMap.keySet()) { // http://192.168.0.102:13080/pcg/files/image/64280347-afa8-4057-8941-ccbaeebf3c4b File fileToZip = new File(StaticProperty.IMAGE_PATH, imageMap.get(imageName)); try (FileInputStream fis = new FileInputStream(fileToZip)) { zipEntry = new ZipEntry(imageName); zipOut.putNextEntry(zipEntry); byte[] bytes = new byte[1024]; int length; while((length = fis.read(bytes)) \u0026gt;= 0) { zipOut.write(bytes, 0, length); } }catch (FileNotFoundException fnfe){ LOG.warn(\u0026#34;file {} not found\u0026#34;, imageMap.get(imageName)); } zipOut.closeEntry(); } zipOut.flush(); zipOut.close(); 补充：\n使用 Nginx 可以不通过接口前端直接下载，后续可以尝试采用此方法。 ","date":"2020-03-04T00:00:00Z","permalink":"/post/2020/0304_java.html","title":"SpringMVC 下载文本实现边转码边下载"}]