-
1.设计来源 主旨: 简洁,干净,明亮,舒适的风格; 语言: 可以html+js+css,也可以html+css; 版块: 人信息,基本资料,专业技能,教育经历(支持更多扩展); 适用: 个人简介,个人简历,个人主页,个人博客,个人空间等方向; 1.1 主界面 主界面动态的朦胧天空背景图,可以配置自己的图标,个人名言,名字,职称,邮箱,手机号,工作地址等信息,也可以扩展新的。 1.2 基本资料 基本资料动态的朦胧天空背景图,可以配置个人职业技能,大图标,姓名,年龄,性别,手机号,邮箱,qq号,居住地,户籍,学历,学校,专业等信息,也可以扩展新的。 1.3 专业技能 专业技能动态的科技线路背景图,可以配置个人专业技能,按列表排序出来,个人的掌握的工具,及熟练度等信息,也可以扩展新的。 1.4 教育经历 教育经历动态的科技线路背景图,可以配置个人教育的学校,时间,几学到的东西等信息,也可以扩展新的。 1.5 工作经验 工作经验动态的科技线路背景图,可以配置个人工作过的公司,工作的时间,负责的项目,开发工具等信息,也可以扩展新的。 2.效果和源码 2.1 动态效果 下面咋们一起来看看这个个人简历的动态效果,改变图片,可以配置多种风格,灵活运用,效果酷炫。 html简洁漂亮的个人简历,个人主页,个人简介网页版 2.2 源代码 这里是主界面的代码,其他图片、js、css等代码,见下面的 源码下载 ,里面有所有代码资源和相关说明。 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>徐纯宇 - 个人简历,个人主页,个人介绍,个人简介</title> <link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon"> <link rel="stylesheet" type="text/css" href="css/main.css"> </head> <body oncontextmenu="return false;" onselectstart="return false;" unselectable="on" ondragstart="return false;"> <aside> <a class="cur_a"><span>徐纯宇</span></a> <a><span>基本资料</span></a> <a><span>专业技能</span></a> <a><span>教育经历</span></a> <a><span>工作经验</span></a> </aside> <section class="page_one" id="page1"> <div class="cen_con"> <div class="portrait"> <img onmousemove="this.src='images/user_c.jpg'" onmouseout="this.src='images/user.jpg'"/ src="images/user.jpg"> </div> <div class="cen_text"> <h2>业精于勤;荒于嬉;行成于思;毁于随。</h2> <hr> <h3>徐纯宇</h3> <h3>全栈工程师</h3> <h3> 1376174032@qq.com | 13300000000</h3> <h3>在职北京 ▣ 海淀</h3> </div> </div> <div class="down_arrow"> <a class="scroll"><span></span></a> </div> </section> <section class="page_two" id="page2"> <div class="con_wrap"> <div class="tit_wrap"> <h1 style="font-weight: bold;">基本资料</h1> <div class="scissors" style="border-top:1px dashed orange;"> </div> <h2> 毕业于北京理工大学的计算机应用专业。从业十年有余,熟悉C#和Java前后端开发,熟悉oracle和mysql数据库,熟悉Html和js及css前端开发,熟悉uniapp跨平台开发小程序和app。</h2> </div> <div class="myinfo"> <table> <tbody> <tr> <td rowspan="6"> <img src="images/user.jpg"> </td> <td>姓名 | 徐纯宇</td> <td>手机 | 13300000000</td> </tr> <tr> <td>性别 | 男</td> <td>邮箱 | 1376174032@qq.com</td> </tr> <tr> <td>出生 | 1992.02.20</td> <td>QQ号 | 1376174032</td> </tr> <tr> <td>居住 | 北京市海淀区</td> <td>户籍 | 北京市海淀区</td> </tr> <tr> <td>学历 | 本科</td> <td>学校 | 北京理工大学</td> </tr> <tr> <td>专业 | 计算机应用</td> <td></td> </tr> </tbody> </table> </div> </div> <div class="down_arrow"> <a class="scroll"><span></span></a> </div> </section> <section class="page_three" id="page3"> <div class="con_wrap"> <div class="tit_wrap"> <h1 style="font-weight: bold;">专业技能</h1> <div class="scissors" style="border-top:1px dashed orange;"> </div> <h2>长年累月后,精进又向前。唯有勤学练,擅长最优先。</h2> </div> <div class="skill_con"> <div class="canvas_wrap"> <div class="canvas_con"> <div class="text_con"> <p class="percent">98%</p> <p class="chart_title">C#</p> </div> <canvas id="html5" width=160 height=160></canvas> </div> <div class="canvas_con"> <div class="text_con"> <p class="percent">93%</p> <p class="chart_title">JAVA</p> </div> <canvas id="css3" width=160 height=160></canvas> </div> <div class="canvas_con"> <div class="text_con"> <p class="percent">96%</p> <p class="chart_title">HTML</p> </div> <canvas id="js" width=160 height=160></canvas> </div> <div class="canvas_con"> <div class="text_con"> <p class="percent">99%</p> <p class="chart_title">ORACLE</p> </div> <canvas id="jq" width=160 height=160></canvas> </div> </div> <div class="text_wrap"> <p>1. 熟练使用java,基于ssh能快速搭建系统框架。</p> <p>2. 熟练使用java,基于spring boot能快速搭建系统框架。</p> <p>3. 熟练使用HTML/CSS技术,精通js/jquery编程,能够熟练使用angularjs,vue,reactjs等前端框架。</p> <p>4. 熟悉oracle,mysql,sqlserver等各平台安装使用,熟练使用SQL语句增删改查,触发器,存储过程,索引,序列。</p> <p>5. 熟练的使用grunt,gulp等前端工具。</p> <p>6. 熟悉c#语言研发,基于asp.new mvc能快速搭建系统框架,新技术asp.net core做后台数据服务能快速搭建,跨平台使用。</p> <p>7. 熟悉c#语言研发,基于winform的c/s应用程序能快速搭建框架。</p> </div> </div> </div> <div class="down_arrow"> <a class="scroll"><span></span></a> </div> </section> <section class="page_four" id="page4"> <div class="con_wrap"> <div class="tit_wrap"> <h1 style="font-weight: bold;">教育经历</h1> <div class="scissors" style="border-top:1px dashed orange;"> </div> <h2>学则智,不学则愚;学则治,不学则乱。自古圣贤,成大业,未有不由学而成者。</h2> </div> <div class="work_con"> <div class="programe"> <div class="work_time">4年<br>北京理工大学</div> <div class="work_text"> <div class="triangle-left"></div> <div class="exCon"> <h4>学习时间:2008/06 -- 2012/06</h4> <p>学习技能:</p> <p>大学四年时间完成了所有的学业,并参与了很多项目研发工作,配合老师和同学攻关了很多难题。</p> <p> 1.掌握了办公五剑客,Word,PPT,excel,ps,viso。</p> <p> 2.掌握了C#和java的前后端开发,数据库基本应用。</p> </div> </div> </div> <div class="programe"> <div class="work_time">1年<br>Java培训班</div> <div class="work_text"> <div class="triangle-left"></div> <div class="exCon"> <h4>学习时间:2011/05 -- 2012/04</h4> <p>学习技能:</p> <p>参加了一年的JAVA培训班,跟着老师一起做了多个项目。积累了很多经验,有了很多经历。</p> <p>1.掌握了springboot框架下的api开发,并开发了自己的一套数据接口。</p> <p>2.掌握了ssm框架下的前后端开发,并开发了自己的博客。</p> </div> </div> </div> </div> </div> <div class="down_arrow"> <a class="scroll"><span></span></a> </div> </section> <section class="page_five" id="page5"> <div class="con_wrap"> <div class="tit_wrap"> <h1 style="font-weight: bold;">工作经验</h1> <div class="scissors" style="border-top:1px dashed orange;"> </div> <h2>生命,需要我们去努力。年轻时,我们要努力锻炼自己的能力,掌握知识、掌握技能、掌握必要的社会经验。</h2> </div> <div class="work_con"> <div class="programe"> <div class="work_time">22个月<br>汽车之家官网</div> <div class="work_text"> <div class="triangle-left"></div> <div class="exCon"> <h4>开发时间:2019 /12--2021 /10</h4> <h5>开发工具:VS2015,VSCode,ORACLE</h5> <p>项目描述:</p> <p>参与与客户交流需求,参与项目整体开发,主要负责首页面各方面数据综合显示,系统采用预加载,通过wcf对硬件控制,实时更新,一些页面设计,数据库的设计,数据测试。</p> <p> 1.数据信息展示块,2.软硬件交互块,3.数据解析块。</p> </div> </div> </div> <div class="programe"> <div class="work_time">8个月<br>联想后台研发</div> <div class="work_text"> <div class="triangle-left"></div> <div class="exCon"> <h4>开发时间:2021 /10--2022 /06</h4> <h5>开发工具:IDEA,VSCode,MYSQL</h5> <p>项目描述:</p> <p>参与与客户交流需求,参与项目整体开发,主要负责首页面各方面数据综合显示,系统采用预加载,实时更新,一些页面设计,数据库的设计,数据测试。 </p> <p>1.设备基础信息 2.部门信息 3.用户信息 4.数据报表 5.调试工具,6.数据监测,7.系统配置等7个大功能块 系统采用角色分配,操作分配等权限,实现各个角色的权限功能。精确划分各个部门的职责。</p> </div> </div> </div> </div> </div> </section> </body> <script type="text/javascript" src="js/main.js"></script> </html>
-
一.Spring Security安全框架实现密码加密方法简述 1.首先:Spring Security提供了强大的加密工具PasswordEncoder,PasswordEncoder接口的代码如下: package org.springframework.security.crypto.password; public interface PasswordEncoder { String encode(CharSequence var1);//是是对密码加密的方法 boolean matches(CharSequence var1, String var2);//是用来验证密码和加密后密码是否一致的如果一致则返回true } 2.其次:Spring Security提供了BCryptPasswordEncoder类,该类实现了Spring的PasswordEncoder接口,使用BCrypt强哈希方法来对密码进行加密,通过BCrypt强哈希方法每一次加密的结果都不一样:可以看看示例加密后的密码,需要说明的是这两种加密后的密码,其明文密码都是:123 3.加密的代码,可以看到每次加密产生的都是随机字符串: public String encode(CharSequence rawPassword) { String salt; if (this.strength > 0) { if (this.random != null) { salt = BCrypt.gensalt(this.strength, this.random); } else { salt = BCrypt.gensalt(this.strength); } } else { salt = BCrypt.gensalt(); } return BCrypt.hashpw(rawPassword.toString(), salt); } 二.加密的具体实现步骤 1.在配置文件中配置加密所需要的工具类 <!--配置加密工具类--> <bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/> 2.在service的实现层中注入BCryptPasswordEncoder @Autowired private BCryptPasswordEncoder passwordEncoder;//通过注解拿到加密对象 3.然后在在service的实现层中的需要加密的操作方法中添加比如保存操作 //保存一个用户,使用加密算法把加密后的密码保存入数据库 @Override public void save(SysUser sysUser) { //二.使用安全框架加密方式 //1.Security安全框架加密操作:加密密码并存入sysUser对象中 String encode = passwordEncoder.encode(sysUser.getPassword());//拿到加密后的密码 sysUser.setPassword(encode);//将加密后的密码设置到sysUser对象中 userDao.save(sysUser); } 4.以上配置结束你需要运行一下,添加一个用户(这个用户的密码是加密的),否则用之前没有加密的密码登录是会失败的 ;然后去除"{noop}" //2.Security安全框架加密操作:去除+"{noop}"+————————————非常重要 User user = new User(sysUser.getUsername(), sysUser.getPassword(), authorities); return user; 5.在配置文件中spring_security.xml 里面引用第一步配置的id <!-- Security安全框架加密操作:引入加密操作--> <security:password-encoder ref="passwordEncoder"/> ———————————————— 原文链接:https://blog.csdn.net/weixin_43330884/article/details/104632267
-
博主在之前已经介绍了Spring Security的用户UserDetails与用户服务UserDetailsService,本篇博客介绍Spring Security的密码编码器PasswordEncoder,它们是相互联系的,博主会带大家一步步深入理解Spring Security的实现原理,也会带来Spring Security的实战分享。 Spring Security:用户UserDetails源码与Debug分析 Spring Security:用户服务UserDetailsService源码分析 为什么是介绍而不是源码分析?博主虽然在研一上过密码学的课,但毕竟没有细致研究过密码学领域,因此不敢管中窥豹。再者,这些加密算法的实现原理、是否能抵御攻击、明文与密文(明文经过加密得到)的匹配方法以及时间成本等因素都不是学习Spring Security框架的核心内容,因此本篇博客只会简单介绍Spring Security的密码编码器PasswordEncoder及其实现类,以及密码编码器在Spring Security中的使用时机。 PasswordEncoder PasswordEncoder接口有很多实现类,也有被标记了@Deprecated注解的实现类,一般是该类表示的密码编码器(加密算法)不安全,比如可以在能接受的时间内被破解,比如彩虹表攻击。 这里不去分析每个密码编码器的实现原理,因为密码编码器的种类太多了,而且没有必要,密码编码器的主要作用无非就是对密码进行编码(加密),以及原始密码(客户端登录验证时输入的密码)与编码密码(正确原始密码通过密码编码器编码的结果)的正确匹配,因此密码编码器必定需要实现PasswordEncoder接口的两个方法,而其他方法的实现是服务于这两个方法。 package org.springframework.security.crypto.password; // 首选实现是BCryptPasswordEncoder public interface PasswordEncoder { /** * 对原始密码进行编码 */ String encode(CharSequence rawPassword); /** * 验证从存储(比如数据库或者内存等)中获取的编码密码是否与需要验证的密码匹配 * 如果密码匹配,则返回 true,否则返回 false * 存储的编码密码永远不会被解码 * 因此会将需要验证的密码进行编码,然后与编码密码进行匹配 */ boolean matches(CharSequence rawPassword, String encodedPassword); /** * 如果为了更好的安全性需要再次对编码的密码进行编码,则返回 true,否则返回 false * 默认实现始终返回 false */ default boolean upgradeEncoding(String encodedPassword) { return false; } } 很显然密码编码器的主要作用是为了编码与匹配,而有些加密算法需要经过多次迭代加密,因此也需要实现upgradeEncoding方法,比如BCryptPasswordEncoder类的实现(strength属性越大,需要做更多的工作来加密密码,默认值为10): @Override public boolean upgradeEncoding(String encodedPassword) { if (encodedPassword == null || encodedPassword.length() == 0) { logger.warn("Empty encoded password"); return false; } Matcher matcher = BCRYPT_PATTERN.matcher(encodedPassword); if (!matcher.matches()) { throw new IllegalArgumentException("Encoded password does not look like BCrypt: " + encodedPassword); } else { int strength = Integer.parseInt(matcher.group(2)); return strength < this.strength; } } PasswordEncoderFactories PasswordEncoderFactories类源码: package org.springframework.security.crypto.factory; import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import java.util.HashMap; import java.util.Map; /** * 用于创建PasswordEncoder实例 */ public class PasswordEncoderFactories { /** * 使用默认映射创建一个DelegatingPasswordEncoder * 可能会添加其他映射,并且将更新编码以符合最佳实践 * 但是,由于DelegatingPasswordEncoder的性质,更新不应影响用户 */ @SuppressWarnings("deprecation") public static PasswordEncoder createDelegatingPasswordEncoder() { // 默认bcrypt String encodingId = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put(encodingId, new BCryptPasswordEncoder()); encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder()); encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder()); encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5")); encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); encoders.put("scrypt", new SCryptPasswordEncoder()); encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", new Argon2PasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); } private PasswordEncoderFactories() {} } PasswordEncoderFactories类可以看作密码编码器工厂,它将已有的密码编码器存储在HashMap中,通过静态方法createDelegatingPasswordEncoder即可获取,该方法的返回值是一个DelegatingPasswordEncoder实例。 DelegatingPasswordEncoder DelegatingPasswordEncoder类源码: public class DelegatingPasswordEncoder implements PasswordEncoder { private static final String PREFIX = "{"; private static final String SUFFIX = "}"; private final String idForEncode; private final PasswordEncoder passwordEncoderForEncode; private final Map<String, PasswordEncoder> idToPasswordEncoder; private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder(); /** * 创建一个新实例 * idForEncode:用于查找应使用哪个PasswordEncoder进行encode * idToPasswordEncoder:id到PasswordEncoder的映射,用于确定应使用哪个PasswordEncoder进行matches */ public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) { if (idForEncode == null) { throw new IllegalArgumentException("idForEncode cannot be null"); } if (!idToPasswordEncoder.containsKey(idForEncode)) { throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder); } for (String id : idToPasswordEncoder.keySet()) { if (id == null) { continue; } // 如果id有'{'或者'}'字符则会出现问题,比如: // 基于prefixEncodedPassword获取id,是根据'{'和'}'字符对第一次出现的位置来截取 // 以及去除{id}得到encodedPassword,是根据'}'字符第一次出现的位置来截取 if (id.contains(PREFIX)) { throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX); } if (id.contains(SUFFIX)) { throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX); } } // 初始化 this.idForEncode = idForEncode; this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode); this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder); } /** * 设置defaultPasswordEncoderForMatches,默认为UnmappedIdPasswordEncoder实例 */ public void setDefaultPasswordEncoderForMatches( PasswordEncoder defaultPasswordEncoderForMatches) { if (defaultPasswordEncoderForMatches == null) { throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null"); } this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches; } // 编码,{id}前缀拼接委托的PasswordEncoder的编码结果 @Override public String encode(CharSequence rawPassword) { return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword); } // 匹配 @Override public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) { if (rawPassword == null && prefixEncodedPassword == null) { return true; } // 根据prefixEncodedPassword提取id String id = extractId(prefixEncodedPassword); // 根据id获取PasswordEncoder PasswordEncoder delegate = this.idToPasswordEncoder.get(id); // 是否有对应的PasswordEncoder if (delegate == null) { // 没有对应的PasswordEncoder // 则使用defaultPasswordEncoderForMatches进行匹配 return this.defaultPasswordEncoderForMatches .matches(rawPassword, prefixEncodedPassword); } // 有对应的PasswordEncoder // 提取encodedPassword,即去掉{id}前缀 String encodedPassword = extractEncodedPassword(prefixEncodedPassword); // 返回匹配结果 return delegate.matches(rawPassword, encodedPassword); } // 提取id private String extractId(String prefixEncodedPassword) { if (prefixEncodedPassword == null) { return null; } // 第一个'{'字符的位置 int start = prefixEncodedPassword.indexOf(PREFIX); if (start != 0) { return null; } // 从start开始的第一个'}'字符的位置 int end = prefixEncodedPassword.indexOf(SUFFIX, start); if (end < 0) { return null; } // 截取得到id return prefixEncodedPassword.substring(start + 1, end); } @Override public boolean upgradeEncoding(String prefixEncodedPassword) { // 提取id String id = extractId(prefixEncodedPassword); // id与idForEncode属性不匹配,则返回true if (!this.idForEncode.equalsIgnoreCase(id)) { return true; } else { // 提取encodedPassword String encodedPassword = extractEncodedPassword(prefixEncodedPassword); // 根据id获取PasswordEncoder // 返回该PasswordEncoder的upgradeEncoding方法基于encodedPassword的返回值 return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword); } } // 提取encodedPassword private String extractEncodedPassword(String prefixEncodedPassword) { // 第一个'}'字符的位置 int start = prefixEncodedPassword.indexOf(SUFFIX); // 截取得到encodedPassword return prefixEncodedPassword.substring(start + 1); } /** * 引发异常的默认PasswordEncoder */ private class UnmappedIdPasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { throw new UnsupportedOperationException("encode is not supported"); } @Override public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) { String id = extractId(prefixEncodedPassword); throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\""); } } } DelegatingPasswordEncoder是基于前缀标识符并委托给另一个PasswordEncoder的密码编码器,可以使用PasswordEncoderFactories类创建一个DelegatingPasswordEncoder实例,也可以创建自定义的DelegatingPasswordEncoder实例。 密码存储格式为 {id}encodedPassword(prefixEncodedPassword),id是用于查找应该使用哪个PasswordEncoder的标识符,encodedPassword是使用PasswordEncoder对原始密码进行编码的结果,id必须在密码的开头,以{开头,}结尾。 如果找不到id,则id将为空。 例如,以下可能是使用不同id(PasswordEncoder)编码的密码列表: {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG {noop}password {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc {scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 第一个密码的id为bcrypt,encodePassword为$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG,匹配时,将委托给BCryptPasswordEncoder。第二个密码的id为noop,encodePassword为password。匹配时,将委托给NoOpPasswordEncoder。以此类推。 传递给构造函数的idForEncode确定将使用哪个PasswordEncoder来编码原始密码。匹配是基于id和构造函数中提供的idToPasswordEncoder来完成的。matches方法可能使用带有未映射id(包括空id)的密码,调用matches方法将抛出IllegalArgumentException异常, 可以使用setDefaultPasswordEncoderForMatches方法自定义此行为,即设置defaultPasswordEncoderForMatches属性,当根据id获取不到PasswordEncoder时使用。 Debug分析 依赖: <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.kaven</groupId> <artifactId>security</artifactId> <version>1.0-SNAPSHOT</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.1.RELEASE</version> </parent> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies> </project> 接口: @RestController public class MessageController { @GetMapping("/message") public String getMessage() { return "hello kaven, this is security"; } } 启动类: @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class); } } Debug启动应用,访问接口,会被重定向到默认登录页。 使用Spring Security自动创建的用户(用户名为user,密码在启动日志中)进行登录验证。 用户验证时Spring Security会使用DelegatingPasswordEncoder类的matches方法进行密码匹配,提取的id为noop(prefixEncodedPassword有{noop}前缀,应用启动时,如果没有用户与用户源的相关配置,Spring Security会创建一个默认用户,即一个UserDetails实例,该实例的密码就是prefixEncodedPassword),因此委托给NoOpPasswordEncoder进行密码匹配。 NoOpPasswordEncoder类的matches方法只是简单的字符串匹配,上图的rawPassword和encodedPassword很显然是匹配的。 public boolean matches(CharSequence rawPassword, String encodedPassword) { return rawPassword.toString().equals(encodedPassword); } 验证成功。 配置PasswordEncoder 增加配置: package com.kaven.security.config; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { // 重写验证处理的配置 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(new UserDetailsServiceImpl()).passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); } // 自定义的用户服务 public static class UserDetailsServiceImpl implements UserDetailsService { // 使用PasswordEncoderFactories工厂创建DelegatingPasswordEncoder实例作为该用户服务的密码编码器 private static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // TODO 查找数据库 // 使用密码编码器对原始密码进行编码 String encodedPassword = PASSWORD_ENCODER.encode("itkaven"); // 默认存在该用户名的用户,并且原始密码都为itkaven,角色都为USER和ADMIN return User.withUsername(username).password(encodedPassword).roles("USER", "ADMIN").build(); } } } Debug启动应用,访问接口,然后进行验证登录,由于自定义的用户服务默认任意用户名的用户都存在,并且原始密码都为itkaven,角色都为USER和ADMIN,因此登录时用户名可任意,但密码必须为itkaven才能通过验证。 客户端进行验证登录时,Spring Security通过用户服务加载匹配用户名的UserDetails实例,而博主自定义的用户服务直接默认该UserDetails实例存在,并且设置默认的密码(编码后的密码,使用UserDetailsServiceImpl类中的PASSWORD_ENCODER进行编码)与角色(权限)。密码匹配使用在重写验证处理的配置时指定的密码编码器来完成(passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()))。 id为bcrypt,使用BCryptPasswordEncoder进行密码匹配,因为通过自定义的用户服务加载的UserDetails实例的密码就是DelegatingPasswordEncoder的{bcrypt}前缀与BCryptPasswordEncoder对原始密码(itkaven)的编码的拼接,最后会返回true。 所以,密码编码器在用户验证时用于密码的匹配,以及创建UserDetails实例时对密码进行编码(可选,如果是基于用户服务加载的UserDetails实例创建的新实例,新实例一般不更改该UserDetails实例的密码,因此,通过用户服务加载的UserDetails实例的密码应该是编码后的密码),因此密码的编码与匹配过程需要使用相同的密码编码器,不然一样的原始密码也有可能匹配不成功。 Spring Security的密码编码器PasswordEncoder的介绍与Debug分析就到这里,如果博主有说错的地方或者大家有不同的见解,欢迎大家评论补充。 ———————————————— 原文链接:https://blog.csdn.net/qq_37960603/article/details/122318269
-
在 Spring Security 中有一个加密的类 BCryptPasswordEncoder ,它的使用非常的简单而且也比较有趣。让我们来看看它的使用。 BCryptPasswordEncoder 的使用 首先创建一个 SpringBoot 的项目,在创建项目的时候添加 Spring Security 的依赖。然后我们添加一个测试类,写如下的代码: final private String password = "123456"; @Test public void TestCrypt() { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); String encode1 = bCryptPasswordEncoder.encode(password); System.out.println("encode1:" + encode1); String encode2 = bCryptPasswordEncoder.encode(password); System.out.println("encode2:" + encode2); } 上面的代码中,首先实例化了一个 BCryptPasswordEncoder 类,然后使用该类的 encode 方法对同一个明文字符串进行了加密,并输出。运行上面的代码,查看输出。 encode1:$2a$10$SqbQb0pD3KYrH7ZVTWdRZOhPAelQqa..lUnysXoWag6RvMkyC5SE6 encode2:$2a$10$0sjBLlwrrch2EjgYls197e9dGRCMbQ7KUIt/ODPTSU0W.mEPaGkfG 从上面的输出可以看出,同一个明文加密两次,却输出了不同的结果。是不是很神奇?但是这样有一个问题,如果使用 BCryptPasswordEncoder 去加密登录密码的话,还能进行验证么?当然是可以验证的。验证的话,使用的是 BCryptPasswordEncoder 的 matches 方法,代码如下。 final private String password = "123456"; @Test public void TestCrypt() { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); String encode1 = bCryptPasswordEncoder.encode(password); System.out.println("encode1:" + encode1); boolean matches1 = bCryptPasswordEncoder.matches(password, encode1); System.out.println("matches1:" + matches1); String encode2 = bCryptPasswordEncoder.encode(password); System.out.println("encode2:" + encode2); boolean matches2 = bCryptPasswordEncoder.matches(password, encode2); System.out.println("matches2:" + matches2); } 使用 matches 方法可以对加密前和加密后是否匹配进行验证。输出如下: encode1:$2a$10$qxU.rFLeTmZg47FyqJlZwu.QNX9RpEvqBUJiwUvUE0p4ENR.EndfS matches1:true encode2:$2a$10$NyGEOsQ1Hxv2gvYRmaEENueORlVDtSqoB/fHN76KkvQDeg7fbTy22 matches2:true 可以看到两次加密后的字符串虽然不同,但是通过 matches 方法都可以匹配出它们是 “123456” 这个明文加密的结果。同样很神奇,这是为什么呢? encode 和 matches 方法的原理 我们通过源码来看看它们的原理吧。 首先我们将依赖的源码下载到本地,方便我们进行调试。开始我使用 IDEA 进行调试时是没有源码的,后来下载了源码,发现没有源码时调试的是 Class 文件,IDEA 提供了 Class 文件与源码等价的反编译代码。而源码逻辑性更好一些。 首先在 encode 代码处下断点,然后我们单步步入,去查看 encode 的实现,代码如下: @Override public String encode(CharSequence rawPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } String salt = getSalt(); return BCrypt.hashpw(rawPassword.toString(), salt); } 直接运行到 return 语句处,查看一下 salt 的值,该值如下: $2a$10$H73jYFFLWWf.VV/mwonqru 然后继续单步步入到 BCrypt.hashpw 方法内,该方法代码如下: public static String hashpw(String password, String salt) { byte passwordb[]; passwordb = password.getBytes(StandardCharsets.UTF_8); return hashpw(passwordb, salt); } 该方法的重点同样是 hashpw 方法,没有做什么处理,继续进入 hashpw 方法中。代码如下: public static String hashpw(byte passwordb[], String salt) { BCrypt B; String real_salt; byte saltb[], hashed[]; char minor = (char) 0; int rounds, off; StringBuilder rs = new StringBuilder(); if (salt == null) { throw new IllegalArgumentException("salt cannot be null"); } int saltLength = salt.length(); if (saltLength < 28) { throw new IllegalArgumentException("Invalid salt"); } if (salt.charAt(0) != '$' || salt.charAt(1) != '2') { throw new IllegalArgumentException("Invalid salt version"); } if (salt.charAt(2) == '$') { off = 3; } else { minor = salt.charAt(2); if ((minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b') || salt.charAt(3) != '$') { throw new IllegalArgumentException("Invalid salt revision"); } off = 4; } // Extract number of rounds if (salt.charAt(off + 2) > '$') { throw new IllegalArgumentException("Missing salt rounds"); } if (off == 4 && saltLength < 29) { throw new IllegalArgumentException("Invalid salt"); } rounds = Integer.parseInt(salt.substring(off, off + 2)); real_salt = salt.substring(off + 3, off + 25); saltb = decode_base64(real_salt, BCRYPT_SALT_LEN); if (minor >= 'a') { passwordb = Arrays.copyOf(passwordb, passwordb.length + 1); } B = new BCrypt(); hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0); rs.append("$2"); if (minor >= 'a') { rs.append(minor); } rs.append("$"); if (rounds < 10) { rs.append("0"); } rs.append(rounds); rs.append("$"); encode_base64(saltb, saltb.length, rs); encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs); return rs.toString(); } 代码很长,但是真正关键的代码在上面第 43 、44 和 51 行的位置处,43 行处获取真正的 salt ,44 行是使用 base64 进行解码,然后 51 行用 密码、salt 进行处理。在来看看返回值是 rs,在第 63 行和 64 行,对 salt 进行 base64 编码后放入了 rs 中,然后对 hashed 进行 base64 编码后也放入了 rs 中,最后 rs.toString() 返回。 虽然上面代码很长,其实真正关键的就只有上面我提到的几句,其余的部分不用看。我们接着看 matches 的源码,同样单步进入,代码如下: @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } if (encodedPassword == null || encodedPassword.length() == 0) { this.logger.warn("Empty encoded password"); return false; } if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) { this.logger.warn("Encoded password does not look like BCrypt"); return false; } return BCrypt.checkpw(rawPassword.toString(), encodedPassword); } 单步到第 14 行的 return 处,这里调用 BCrypt.checkpw 的方法,rawPassword.toString() 是我们的密码,即 ”123456“, 后面的 encodePassword 是我们加密后的密码,单步步入进去,代码如下: public static boolean checkpw(String plaintext, String hashed) { return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed)); } 这里只有一行代码,但是代码中同样调用了前面的 hashpw 这个方法,传入的参数是 plaintext 和 hashed,plaintext 是我们的密码,即 “123456”, hashed 是加密后的密码。到这里基本就明白了。hashed 在进入 hashpw 函数后,会通过前面说到第 43 行代码取出真正的 salt,然后对通过 salt 和 我们的密码进行加密,这样流程就串联起来了。 总结 当时看到使用 BCryptPasswordEncoder 时,同样的密码可以生成不同的 密文 而且还可以通过 matches 方法进行匹配验证,觉得很神奇。后来经过调试发现,密文中本身包含了很多信息,包括 salt 和 使用 salt 加密后的 hash。因为每次的 salt 不同,因此每次的 hash 也不同。这样就可以使得相同的 明文 生成不同的 密文,而密文中包含 salt 和 hash,因此验证过程和生成过程也是相同的。 ———————————————— 原文链接:https://blog.csdn.net/easysec/article/details/121719949
-
BCryptPasswordEncoder 是一种使用 BCrypt 加密算法来加密密码的方法。它是在 Spring Security 中用来加密用户密码的一个类,其目的是为了防止密码被明文存储在数据库中。BCrypt 是一种强哈希算法,它能很好地防止被暴力破解。采用SHA-256 +随机盐+密钥对密码进行加密 首先导入依赖 <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-core</artifactId> <version>5.7.6</version> </dependency> 编写配置类 package com.qingyun.kunba.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.security.SecureRandom; @Data @Configuration @ConfigurationProperties(prefix = "encoder.crypt") public class PasswordConfig { /** * 加密强度 */ private int strength; /** * 干扰因子 */ private String secret; @Bean public BCryptPasswordEncoder passwordEncoder() { //System.out.println("secret = " + secret); //对干扰因子加密 SecureRandom secureRandom = new SecureRandom(secret.getBytes()); //对密码加密 return new BCryptPasswordEncoder(strength, secureRandom); } } 配置yml encoder: crypt: secret: ${random.uuid} # 随机的密钥,使用uuid strength: 6 # 加密强度4~31,决定盐加密时的运算强度,超过10以后加密耗时会显著增加 接着就可以测试了 @Autowired private BCryptPasswordEncoder encoder; @Test void savePassword() { // encode():对明文字符串进行加密 //注册用户时,使用SHA-256+随机盐+密钥把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中。 String pw = encoder.encode("abcd"); System.out.println(pw); // matches():对加密前和加密后是否匹配进行验证 //用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的), // 而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值,然后将其与从数据库中查询到的密码hash值进行比较。 // 如果两者相同,说明用户输入的密码正确。 System.out.println(encoder.matches("asdsad", pw)); System.out.println(encoder.matches("abcd", pw)); } 测试结果如下 ———————————————— 原文链接:https://blog.csdn.net/qq_52458633/article/details/130990910
-
在前段代码中引入 weui.css ,weuix.css 和js jquery-weui.min.js就可以使用weui的一些样式了按照官方的文档中 $.toast("我是文本","text");弹出的样式应该如下: 但是我在实际中使用弹出的结果却是这个样子: 为什么 原因肯定是weui的样式和其他的样式冲突了 我们看看 $.toast 这一串代码: t.toast=function(t,a,r) { "function"==typeof a&&(r=a); var o,s="weui-icon-success-no-circle", c=i.duration; "cancel"==a?(o="weui-toast_cancel",s="weui-icon-cancel"):"forbidden"==a?(o="weui-toast--forbidden",s="weui-icon-warn"):"text"==a?o="weui-toast--text":"number"==typeof a&&(c=a), e('<i class="'+s+' weui-icon_toast"></i><p class="weui-toast_content">'+(t||"已经完成")+"</p>",o),setTimeout(function(){n(r)},c)} 大概能看出来 cancel ,forbidden,text 这些都是一些选项,当选择而不同的时候,出现不同的样式 text 应该是没有图标,为什么有图标了 我在页面上设置100,来查看这个样式 可以看到的是图标来源于这一部分 我们选中 i 这个标签,看右侧使用的样式: 果然是其他的样式干扰的 如果我们引入的其他的css作用不大,我们可以找到直接删除了 或者将 important 删除了 最终结果: 因为这次的样式调试用了很长的时间,特此记录 ———————————————— 原文链接:https://blog.csdn.net/datouniao1/article/details/111311058
-
用到了wrapper,ge、le、ne、eq等的用法,及多表查询自写sql整理资料记录一下,以备后续复习。 目录------------(可点击相应目录直接跳转) 一、条件构造器关系介绍 条件构造器关系介绍 : wapper介绍 : 二、项目实例 1、根据主键或者简单的查询条件进行查询 2、MyBatis-Plus还提供了Wrapper条件构造器,具体使用看如下代码: 三、具体使用操作 1、ge、gt、le、lt、isNull、isNotNull 2、eq、ne 3、between、notBetween 4、allEq 5、like、notLike、likeLeft、likeRight 6、in、notIn、inSql、notinSql、exists、notExists 7、or、and 8、嵌套or、嵌套and 9、orderBy、orderByDesc、orderByAsc 10、last 11、指定要查询的列 12、set、setSql 四、项目中实际应用代码实例 实例1--包含 eq相等的比较方法 实例2--包含 ge le ge等比较方法,及分页查询方法 实例3--多表查询,手写sql示例,五表联查 先了解一下内外连接: SQL内连接(INNER JOIN) SQL外连接(OUTER JOIN 一、条件构造器关系介绍 条件构造器关系介绍 : 上图绿色框为抽象类abstract 蓝色框为正常class类,可new对象 黄色箭头指向为父子类关系,箭头指向为父类 wapper介绍 : Wrapper : 条件构造抽象类,最顶端父类 AbstractWrapper : 用于查询条件封装,生成 sql 的 where 条件 QueryWrapper : Entity 对象封装操作类,不是用lambda语法 UpdateWrapper : Update 条件封装,用于Entity对象更新操作 AbstractLambdaWrapper : Lambda 语法使用 Wrapper统一处理解析 lambda 获取 column。 LambdaQueryWrapper :看名称也能明白就是用于Lambda语法使用的查询Wrapper LambdaUpdateWrapper : Lambda 更新封装Wrapper 二、项目实例 1、根据主键或者简单的查询条件进行查询 /** * 通过单个ID主键进行查询 */ @Test public void selectById() { User user = userMapper.selectById(1094592041087729666L); System.out.println(user); } /** * 通过多个ID主键查询 */ @Test public void selectByList() { List<Long> longs = Arrays.asList(1094592041087729666L, 1094590409767661570L); List<User> users = userMapper.selectBatchIds(longs); users.forEach(System.out::println); } /** * 通过Map参数进行查询 */ @Test public void selectByMap() { Map<String, Object> params = new HashMap<>(); params.put("name", "张雨琪"); List<User> users = userMapper.selectByMap(params); users.forEach(System.out::println); } 2、MyBatis-Plus还提供了Wrapper条件构造器,具体使用看如下代码: /** * 名字包含雨并且年龄小于40 * <p> * WHERE name LIKE '%雨%' AND age < 40 */ @Test public void selectByWrapperOne() { QueryWrapper<User> wrapper = new QueryWrapper(); wrapper.like("name", "雨").lt("age", 40); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 名字包含雨 * 年龄大于20小于40 * 邮箱不能为空 * <p> * WHERE name LIKE '%雨%' AND age BETWEEN 20 AND 40 AND email IS NOT NULL */ @Test public void selectByWrapperTwo() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.like("name", "雨").between("age", 20, 40).isNotNull("email"); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 名字为王性 * 或者年龄大于等于25 * 按照年龄降序排序,年龄相同按照id升序排序 * <p> * WHERE name LIKE '王%' OR age >= 25 ORDER BY age DESC , id ASC */ @Test public void selectByWrapperThree() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.likeRight("name", "王").or() .ge("age", 25).orderByDesc("age").orderByAsc("id"); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 查询创建时间为2019年2月14 * 并且上级领导姓王 * <p> * WHERE date_format(create_time,'%Y-%m-%d') = '2019-02-14' AND manager_id IN (select id from user where name like '王%') */ @Test public void selectByWrapperFour() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.apply("date_format(create_time,'%Y-%m-%d') = {0}", "2019-02-14") .inSql("manager_id", "select id from user where name like '王%'"); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 查询王姓 * 并且年龄小于40或者邮箱不为空 * <p> * WHERE name LIKE '王%' AND ( age < 40 OR email IS NOT NULL ) */ @Test public void selectByWrapperFive() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.likeRight("name", "王").and(qw -> qw.lt("age", 40).or().isNotNull("email")); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 查询王姓 * 并且年龄大于20 、年龄小于40、邮箱不能为空 * <p> * WHERE name LIKE ? OR ( age BETWEEN ? AND ? AND email IS NOT NULL ) */ @Test public void selectByWrapperSix() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.likeRight("name", "王").or( qw -> qw.between("age", 20, 40).isNotNull("email") ); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * (年龄小于40或者邮箱不为空) 并且名字姓王 * WHERE ( age < 40 OR email IS NOT NULL ) AND name LIKE '王%' */ @Test public void selectByWrapperSeven() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.nested(qw -> qw.lt("age", 40).or().isNotNull("email")) .likeRight("name", "王"); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 查询年龄为30、31、32 * WHERE age IN (?,?,?) */ @Test public void selectByWrapperEight() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.in("age", Arrays.asList(30, 31, 32)); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } /** * 查询一条数据 * limit 1 */ @Test public void selectByWrapperNine() { QueryWrapper<User> wrapper = Wrappers.query(); wrapper.in("age", Arrays.asList(30, 31, 32)).last("limit 1"); List<User> users = userMapper.selectList(wrapper); users.forEach(System.out::println); } 三、具体使用操作 注意:以下条件构造器的方法入参中的 column 均表示数据库字段 1、ge、gt、le、lt、isNull、isNotNull @Test public void testDelete() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper .isNull("name") .ge("age", 12) .isNotNull("email"); int result = userMapper.delete(queryWrapper); System.out.println("delete return count = " + result); } SQL:UPDATE user SET deleted=1 WHERE deleted=0 AND name IS NULL AND age >= ? AND email IS NOT NULL 2、eq、ne 注意:seletOne返回的是一条实体记录,当出现多条时会报错 @Test public void testSelectOne() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("name", "Tom"); User user = userMapper.selectOne(queryWrapper); System.out.println(user); } 3、between、notBetween 包含大小边界 @Test public void testSelectCount() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.between("age", 20, 30); Integer count = userMapper.selectCount(queryWrapper); System.out.println(count); } SELECT COUNT(1) FROM user WHERE deleted=0 AND age BETWEEN ? AND ? 4、allEq @Test public void testSelectList() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); Map<String, Object> map = new HashMap<>(); map.put("id", 2); map.put("name", "Jack"); map.put("age", 20);9 queryWrapper.allEq(map); List<User> users = userMapper.selectList(queryWrapper); users.forEach(System.out::println); } SELECT id,name,age,email,create_time,update_time,deleted,version FROM user WHERE deleted=0 AND name = ? AND id = ? AND age = ? 5、like、notLike、likeLeft、likeRight selectMaps返回Map集合列表 @Test public void testSelectMaps() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper .notLike("name", "e") .likeRight("email", "t"); List<Map<String, Object>> maps = userMapper.selectMaps(queryWrapper);//返回值是Map列表 maps.forEach(System.out::println); } SELECT id,name,age,email,create_time,update_time,deleted,version FROM user WHERE deleted=0 AND name NOT LIKE ? AND email LIKE ? 6、in、notIn、inSql、notinSql、exists、notExists in、notIn: notIn("age",{1,2,3})--->age not in (1,2,3) notIn("age", 1, 2, 3)--->age not in (1,2,3) inSql、notinSql:可以实现子查询 例: inSql("age", "1,2,3,4,5,6")--->age in (1,2,3,4,5,6) 例: inSql("id", "select id from table where id < 3")--->id in (select id from table where id < 3) @Test public void testSelectObjs() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); //queryWrapper.in("id", 1, 2, 3); queryWrapper.inSql("id", "select id from user where id < 3"); List<Object> objects = userMapper.selectObjs(queryWrapper);//返回值是Object列表 objects.forEach(System.out::println); } SELECT id,name,age,email,create_time,update_time,deleted,version FROM user WHERE deleted=0 AND id IN (select id from user where id < 3) 7、or、and 注意:这里使用的是 UpdateWrapper 不调用or则默认为使用 and 连 @Test public void testUpdate1() { //修改值 User user = new User(); user.setAge(99); user.setName("Andy"); //修改条件 UpdateWrapper<User> userUpdateWrapper = new UpdateWrapper<>(); userUpdateWrapper .like("name", "h") .or() .between("age", 20, 30); int result = userMapper.update(user, userUpdateWrapper); System.out.println(result); } UPDATE user SET name=?, age=?, update_time=? WHERE deleted=0 AND name LIKE ? OR age BETWEEN ? AND ? 8、嵌套or、嵌套and 这里使用了lambda表达式,or中的表达式最后翻译成sql时会被加上圆括号 @Test public void testUpdate2() { //修改值 User user = new User(); user.setAge(99); user.setName("Andy"); //修改条件 UpdateWrapper<User> userUpdateWrapper = new UpdateWrapper<>(); userUpdateWrapper .like("name", "h") .or(i -> i.eq("name", "李白").ne("age", 20)); int result = userMapper.update(user, userUpdateWrapper); System.out.println(result); } UPDATE user SET name=?, age=?, update_time=? WHERE deleted=0 AND name LIKE ? OR ( name = ? AND age <> ? ) 9、orderBy、orderByDesc、orderByAsc @Test public void testSelectListOrderBy() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.orderByDesc("id"); List<User> users = userMapper.selectList(queryWrapper); users.forEach(System.out::println); } SELECT id,name,age,email,create_time,update_time,deleted,version FROM user WHERE deleted=0 ORDER BY id DESC 10、last 直接拼接到 sql 的最后 注意:只能调用一次,多次调用以最后一次为准 有sql注入的风险,请谨慎使用 @Test public void testSelectListLast() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.last("limit 1"); List<User> users = userMapper.selectList(queryWrapper); users.forEach(System.out::println); } SELECT id,name,age,email,create_time,update_time,deleted,version FROM user WHERE deleted=0 limit 1 11、指定要查询的列 @Test public void testSelectListColumn() { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.select("id", "name", "age"); List<User> users = userMapper.selectList(queryWrapper); users.forEach(System.out::println); } SELECT id,name,age FROM user WHERE deleted=0 12、set、setSql 最终的sql会合并 user.setAge(),以及 userUpdateWrapper.set() 和 setSql() 中 的字段 @Test public void testUpdateSet() { //修改值 User user = new User(); user.setAge(99); //修改条件 UpdateWrapper<User> userUpdateWrapper = new UpdateWrapper<>(); userUpdateWrapper .like("name", "h") .set("name", "老李头")//除了可以查询还可以使用set设置修改的字段 .setSql(" email = '123@qq.com'");//可以有子查询 int result = userMapper.update(user, userUpdateWrapper); } UPDATE user SET age=?, update_time=?, name=?, email = '123@qq.com' WHERE deleted=0 AND name LIKE ? 四、项目中实际应用代码实例 (此部分更新于2022年7月20日) 实例1--包含 eq相等的比较方法 实例2--包含 ge le ge等比较方法,及分页查询方法 实例3--多表查询,手写sql示例,五表联查 先了解一下内外连接: 什么是连接表? 多表查询原理:将多个表通过笛卡尔积形成一个虚表,再根据查询条件筛选符合条件的数据。 在关系数据库中,数据分布在多个逻辑表中。 要获得完整有意义的数据集,需要使用连接来查询这些表 中的数据。 SQL Server支持多种 连接包括 INNER JOIN:内连接,关键字在表中存在至少一个匹配时返回行。 left join : 左连接,返回左表中所有的记录以及右表中连接字段相等的记录。 right join : 右连接,返回右表中所有的记录以及左表中连接字段相等的记录。 inner join : 内连接,又叫等值连接,只返回两个表中连接字段相等的行。 full join : 外连接,返回两个表中的行:left join + right join。 cross join : 结果是笛卡尔积,就是第一个表的行数乘以第二个表的行数。 GROUP BY:全外连接, 子句必须放在 WHERE 子句中的条件之后,必须放在 ORDER BY 子句之前 每种连接类型指定SQL Server如何使用一个表中的数据来选择另一个表中的行 SQL内连接(INNER JOIN) 返回两张表中符合连接条件的数据行 内连接是从结果表中删除与被连接表中未匹配行的所有行,所以内连接可能会丢失信息 SQL外连接(OUTER JOIN) 外连接(OUTER JOIN)分 为左连接、右连接和全连接 左连接:返回左表中的所以行,如果左表中行在右表中没有匹配行,则结果中右表中的列返回空值NULL 语法:SELECT * FROM 表1 LEFT OUTER JOIN 表2 ON 条件 eg:我们左连接Student表、Score表查询学生的成绩,SQL 语句如下: SELECT * FROM Student LEFT OUTER JOIN Score ON Student.id = Score.studentID 右 连 接:返回右表中的所以行,如果右表中行在左表中没有匹配行,则结果中左表中的列返回空值NULL 语法:SELECT * FROM 表1 RIGHT OUTER JOIN 表2 ON 条件 eg:我们右连接Student表、Score表查询学生的成绩,SQL 语句如下: SELECT * FROM Student RIGHT OUTER JOIN Score ON Student.id = Score.studentID 全连接:返回左表和右表中的所有行,当某行在另一表中没有匹配行,则另一表中的补NULL 这个是mybatis-plus插件中mapper的一个表查询写法,由多个表内连接或外连接组成的数据。 用到的表结构如下: 分别为问题库表、数据字典、数据字典类型、部门表、用户表 数据示例如下:obdis_problem表里去查type字段内容并返回对应的汉字语义 查询每个字段都进行一次表连接,拿到对应的值,得出一个表结果值,即A\B\C\D等等结果,最后拼起来返回前端展示即可。搞懂了这个,所有的多表查询就基本迎刃而解。 <!-- 问题库列表返回与查询--> <select id="searchMoreProblem" resultType="com.hollysys.obdis.vo.problem.SearchObdisProblem"> select p.id , p.level, A.dict_value as level_text, p.dept_id_mgr, B.org_name as deptIdMgr_text, p.dept_id_work, C.org_name as deptIdWork_text, p.station, D.dict_value as station_text, p.source, E.dict_value as source_text, p.categroy, F.dict_value as categroy_text, p.type, G.dict_value as type_text, p.dev_code, H.dict_value as devCode_text, p.check_time, p.check_user, I.username as checkUser_text, p.deadline, p.deadline_real, p.duty_user, J.username as dutyUser_text, p.correct_user, K.username as correctUser_text, p.state, p.problem, p.requirement, p.solution from obdis_problem p left join t_dict A on p.level = A.id left join t_dept B on p.dept_id_mgr = B.org_id left join t_dept C on p.dept_id_work = C.org_id left join t_dict D on p.station = D.id left join t_dict E on p.source = E.id left join t_dict F on p.categroy = F.id left join t_dict G on p.type = G.id left join t_dict H on p.dev_code = H.id left join t_user I on p.check_user = I.user_id left join t_user J on p.duty_user = J.user_id left join t_user K on p.correct_user = K.user_id where 1=1 <if test="searchObdisProblem.levelText != null and searchObdisProblem.levelText != ''"> and A.dict_value like concat('%', #{searchObdisProblem.levelText}, '%') </if> <if test="searchObdisProblem.deptIdMgrText != null and searchObdisProblem.deptIdMgrText != ''"> and B.org_name like concat('%', #{searchObdisProblem.deptIdMgrText}, '%') </if> <if test="searchObdisProblem.deptIdWorkText != null and searchObdisProblem.deptIdWorkText != '' "> and C.org_name like concat('%', #{searchObdisProblem.deptIdWorkText}, '%') </if> <if test="searchObdisProblem.stationText != null and searchObdisProblem.stationText != ''"> and D.dict_value = #{searchObdisProblem.stationText} </if> <if test="searchObdisProblem.sourceText != null and searchObdisProblem.sourceText != ''"> and E.dict_value = #{searchObdisProblem.sourceText} </if> <if test="searchObdisProblem.categroyText != null and searchObdisProblem.categroyText != ''"> and F.dict_value = #{searchObdisProblem.categroyText} </if> <if test="searchObdisProblem.typeText != null and searchObdisProblem.typeText != ''"> and G.dict_value = #{searchObdisProblem.typeText} </if> <if test="searchObdisProblem.devCodeText != null and searchObdisProblem.devCodeText != ''"> and H.dict_value = #{searchObdisProblem.devCodeText} </if> <if test="searchObdisProblem.checkTimeStart != null "> and p.check_time >= #{searchObdisProblem.checkTimeStart} </if> <if test="searchObdisProblem.checkTimeEnd != null "> and p.check_time <= #{searchObdisProblem.checkTimeEnd} order by p.check_time </if> <if test="searchObdisProblem.checkUserText != null and searchObdisProblem.checkUserText != ''"> and I.username = #{searchObdisProblem.checkUserText} </if> <if test="searchObdisProblem.deadline != null "> and p.deadline = #{searchObdisProblem.deadline} </if> <if test="searchObdisProblem.deadlineReal != null "> and p.deadline_real = #{searchObdisProblem.deadlineReal} </if> <if test="searchObdisProblem.dutyUserText != null and searchObdisProblem.dutyUserText != ''"> and J.username = #{searchObdisProblem.dutyUserText} </if> <if test="searchObdisProblem.correctUserText != null and searchObdisProblem.correctUserText != ''"> and K.username = #{searchObdisProblem.correctUserText} </if> <if test="searchObdisProblem.state != null and searchObdisProblem.state != ''"> and state = #{searchObdisProblem.state} </if> <if test="searchObdisProblem.problem != null and searchObdisProblem.problem != ''"> and p.problem like concat('%', #{searchObdisProblem.problem}, '%') </if> <if test="searchObdisProblem.requirement != null and searchObdisProblem.requirement != ''"> and p.requirement like concat('%', #{searchObdisProblem.requirement}, '%') </if> <if test="searchObdisProblem.solution != null and searchObdisProblem.solution != ''"> and p.solution like concat('%', #{searchObdisProblem.solution}, '%') </if> </select> ———————————————— 原文链接:https://blog.csdn.net/qq_39715000/article/details/120090033
-
el-cascader(联机选择器)动态加载+编辑默认值回显 最近又在工作中遇到了一个问题,就是在我们使用el-cascader加载默认值的时候,如果我们无法拿到全部的options数据,cascader的输入框和联级选择框都会遇到回显问题(只能显示第一层的数据),这个时候我们要怎么做呢,首先我们来看一下我们想要的效果 效果展示 先来看一下效果(由于我不太会用截屏动图工具 所以分成2张): 输入框中的回显数据 联级选择框中的已选数据 解决思路 其实cascader归根结底也就是那么几个属性的事,我们首先来看一下文档,这里列出了一些我们要用到的: 参数 说明 value / v-model 选中项绑定值 options 可选项数据源,键名可通过 Props 属性配置 lazy 是否动态加载子节点,需与 lazyLoad 方法结合使用 lazyload 加载动态数据的方法,仅在 lazy 为 true 时有效 那么首先我们来分析一下问题,问题的原因是因为我们的接口无法提供给我们完全的树形结构options数据,导致即使我们将获取到的已选数据传给v-model也加载不出来,所以我们要做的就是以下几步: 1.获取预选值 需要注意的是,我们这里获取的预选值最好是我们通过el-cascader提交时的数组数据,即每一个数据都是带有选择路径的数组,例如图中的PMO,获取的数据最好是[‘58集团’,‘技术功能平台群’,‘研发管理部’,‘PMO’],如果做不到这样的形式,那我们无论如何也要从其他接口获取到之前的路径项(不然我们就只能从最基础部分遍历获取全部的树形结构了),而后我们需要将取得的集合合并为一个路径群数组,如果和我是一样的多选联机选择框,最好在获取预选值的时候就是用promise const queue = res.result.map(item => { return new Promise(resolve => { that.$axios.get('获取预选值接口').then(data => { resolve(data.单一路径数组) }) }) }) Promise.all(queue).then(result => { result.forEach(i => { that.路径群数组.push(i) }) }) 2.根据预选值制作直线结构数据的数组对象 在我们拿到了想要的数据之后,我们需要将预选值规整为一个数组,并进行去重操作 路径数组 = Array.from(new Set(that.路径群数组.flat())) 这样我们就得到了一个包含路径中所有项的数组,注意:如果是多选型的cascader,那么在这个数组里就会有同级若干项数据,我们不用在意,遍历数组并调用获取下层数据的接口获得下层数据res,并拼接成数组对象,这里要活用Promise,最后我们要的数据形式是(注意这里的pid是指每个数组上一层的父级id) result = [{id: value1, Name: label1, children:res1 , pid: pid1}, {id: value2, Name: label2, children:res2 , pid: pid2}, {id: value3, Name: label3, children:res3 , pid: pid3}] 3.将直线结构的数组对象转换为树形结构 之后再将我们得到的数组对象转化成树形结构,网上有很多方法,这里我随便贴一个: var data = [] this.toTree(result, data, 0) toTree (list, data, fatherId) { list.forEach(item => { if (item.pid === fatherId) { var child = { orgName: item.orgName, id: item.id, children: [] } this.toTree(list, child.children, item.id) data.push(child) } }) }, 这样一来我们就制作了一个包含预选项及其各个父级的树形结构 4.将树形结构赋值给options 将树形结构赋值给options,这样他就可以在最开始的文本框中加载出预选项,并且不影响其他选项动态加载的处理 Options = data 5.总结 最后要声明一下,完成这样效果的方法不止这一种,这是在我走了很多弯路之后做出来的,属于笨办法之一吧; 网上还有大佬说虚拟一个el-cascader的输入框,将预选值放进去,点击的时候再进行动态加载,这也是一种不错的方法; 总之我个人感觉表达的不是很清晰,不过真的尽力了,大家如果有建议或者问题请给我留言,谢谢观看 ———————————————— 原文链接:https://blog.csdn.net/yzy13521758223/article/details/108055393
-
微信分享网页不显示缩略图片的原因为规范自定义分享链接功能在网页上的使用,自2017年4月25日起,JSSDK“分享到朋友圈”及“发送给朋友”接口,自定义的分享链接,其域名或路径必须与当前页面对应的公众号JS安全域名一致,否则将调用失败。也就是说如果你想你的网站在被分享时显示缩略图,那么你要有一个公众号,并且在公众号内设置JS安全域名,也就是添加你网站的网址,这是必须的,如果这一步不能实现,那么你不用往下看了。一、最快的解决方法如果你不是专业的程序员,虽然微信官方给出了代码,以及我下面也会再次给出代码,你可能仍然无法实现。因为在微信官方代码与你的网站之间,还需要二次开发对接代码,把微信代码与你的网站连接起来,这个功能才能最终实现。同时因为每个网站的源程序不一样,所以这个对接代码也不能通用,需要根据你的网站程序单独开发。请在微信内打开链接分享测试。二、微信官方办法步骤一:绑定域名先登录微信公众平台进入“公众号设置”的“功能设置”里填写“JS接口安全域名”。步骤二:设置开发者密码和IP白名单登录微信公众平台,进入开发――基本配置――开发者密码(AppSecret)和IP白名单步骤三:引入JS文件在需要调用JS接口的页面引入如下JS文件,(支持https):http://res.wx.qq.com/open/js/jweixin-1.2.0.js备注:支持使用AMD/CMD 标准模块加载方法加载步骤四:通过config接口注入权限验证配置所有需要使用JS-SDK的页面必须先注入配置信息,否则将无法调用(同一个url仅需调用一次,对于变化url的SPA的web app可在每次url变化时进行调用)。wx.config({debug: true,// 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。appId: ‘‘,// 必填,公众号的唯一标识timestamp:,// 必填,生成签名的时间戳nonceStr: ‘‘,// 必填,生成签名的随机串signature: ‘‘,// 必填,签名,见附录1jsApiList: [] // 必填,需要使用的JS接口列表,所有JS接口列表见附录2});微信开发文档说明:https://mp.weixin.qq.com/advanced/wiki?t=t=resource/res_main&id=mp1421141115微信JS 接口签名校验工具https://mp.weixin.qq.com/debug/cgi-bin/sandBox?t=jsapisign
-
前言在平时的开发过程中,我们总是先写好一个组件,然后在需要的页面中用 import 引入即可,但如果是下面这种类型的组件呢上面这种类型的浮层提示有一个很大的特点,就是使用频率特别高,几乎每个页面都会用到它,于是乎我们就要在每个页面中去引入该组件,并且在每个页面都得通过一个变量来控制它的显隐,这显然不是我们想要的。。。那我们想要的是什么样呢?用过一些 UI 框架的同学们应该知道有这样一种用法:js复制代码this.$toast({ duration: 3000, content: '这是一条消息提示' }); 没错,就是这么简单的一句话就万事大吉了(就是用 js 调用组件而已啦)。那这种效果究竟是怎么实现的呢?今天就让我们来(手把手 )一探究竟吧!前置知识不知道小伙伴们有没有用过 Vue.extend() 这个东东,反正我是很少碰过,印象不深,所以这里我们先来短暂了解一下 Vue.extend() 主要是用来干嘛的。先来个官方说明(不多的,坚持下): 没怎么看懂?没关系,不重要,你只要记住(加少许理解)以下用法即可:js复制代码// 导入以往的普通组件 import Main from './main.vue'; // 用 Vue.extend 创建组件的模板(构造函数) let mainConstructor = Vue.extend(Main); // 实例化组件 let instance = new mainConstructor(); // 挂载到相应的元素上 instance.$mount('#app'); 不知道你看懂没有,上面的 Vue.extend(Main) 就是一个基于 main.vue 的组件模板(构造函数),instance 是实例化的组件,$mount() 是手动挂载的意思。其中 Vue.extend() 和 $mount() 就是我们通过 js 调用、渲染并挂载组件的精髓所在,相当于早前的 createElement 和 appendChild,有异曲同工之效。这个点需要我们好好熟悉一下,所以你可以先停下来屡屡思路。补充一下:$mount() 里面如果没有参数,说明组件只是渲染了但还没有挂载到页面上,如果有正确的(元素)参数则直接挂载到元素下面。写一个 toast 组件js 调用归调用,最原始的组件还是要有的,只是我们不通过 import 来引入到页面中而已。ok,我们就以最开始的那个 toast 图片来简单写一下这个 vue 组件(message 和 alert 也是一样的)。这里就直接上代码啦,毕竟它的结构简单到爆了,也不是本章节的重点:html复制代码<!-- main.vue --> <template> <div class="toast"> <p>服务器错误,请稍后重试</p> </div> </template> <script> export default { name: "Toast", mounted() { setTimeout(() => { // 3s 后通过父级移除子元素的方式来移除该组件实例和 DOM 节点 this.$destroy(true); this.$el.parentNode.removeChild(this.$el); }, 3000); } }; </script> <style lang="scss" scoped> .toast { display: flex; align-items: center; justify-content: center; position: fixed; top: 0; bottom: 0; left: 0; right: 0; color: #fff; z-index: 9999; background: transparent; > p { padding: 12px 22px; font-size: 18px; border-radius: 4px; background: rgba(17, 17, 17, 0.7); } } </style> 上面的内容想必大家应该都能看懂,所以这里就直接讲下面的重点了。写一个 main.js我们在 main.vue 的同级目录下新建一个 main.js 文件。我们先瞟一眼文件内容(也不多,已经是个最简版了):js复制代码// main.js import Vue from "vue"; // 引入 Vue 是因为要用到 Vue.extend() 这个方法 import Main from "./main.vue"; // 引入刚才的 toast 组件 let ToastConstructor = Vue.extend(Main); // 这个在前面的前置知识内容里面有讲到 let instance; const Toast = function() { instance = new ToastConstructor().$mount(); // 渲染组件 document.body.appendChild(instance.$el); // 挂载到 body 下 }; export default Toast; 上面的代码暴露了一个 Toast 函数。为什么要暴露一个函数呢?原因很简单:你想想,我们最终是不是要根据 this.$toast() 来调用一个组件,说白了,通过 js 调用,本质就是调用一个 函数。也就是说 this.$toast() 就是执行了上面代码中导出的 export default Toast,也就是执行了 Toast 函数(const Toast = function() {}),所以当我们调用 this.$toast() 的时候其实就是执行了 Toast() 函数。而 Toast() 函数只做了一件事情:就是通过手动挂载的方式把组件挂载到 body 下面。补充一下:一般来说我们常见的是 $mount("#app"),也就是把组件挂载到 #app 下面,<router-view /> 也包含在 #app 中,但是我们这种 toast 提示是放在 body 下面的,也就是说它不受 #app 和 <router-view /> 的管控,所以当我们切换页面(路由)的时候,这个 toast 组件是不会跟着立马消失的,这点要注意哦。这里顺便给个组件的目录结构,如下图所示: 开始调用调用方式很简单,首先我们在入口文件 main.js(和上面不是同一个) 里加上两行代码,这样我们就能在需要的地方直接用 js 调用它了,如下图所示: 然后在页面中测试一下,就像下面这样子: 运行一下代码: 嗯,挺好,小有成就的 feel 。支持可传参数别急,我们好像还漏了点什么。。。对了,现在还不支持传参呢,直接调用 this.$toast() 就只能显示————服务器错误,请稍后重试(这下全都是后端的锅了)。但我们可是个有追求的前端,不能局限于此,所以现在让我们来尝试增加下两个可配置参数,这里拿 duration 和 content 举个栗子。首先我们要修改 main.vue 组件里面的内容(其实没啥大变化),就像下面这样:html复制代码<!-- main.vue 可配置版 --> <template> <div class="toast"> <p>{{ content }}</p> </div> </template> <script> // 主要就改了 data export default { name: "Toast", data() { return { content: "", duration: 3000 }; }, mounted() { setTimeout(() => { this.$destroy(true); this.$el.parentNode.removeChild(this.$el); }, this.duration); } }; </script> 上面的代码应该算是浅显易懂了,接下来我们看下 main.js 里面改了啥:js复制代码// main.js 可配置版 import Vue from "vue"; import Main from "./main.vue"; let ToastConstructor = Vue.extend(Main); let instance; const Toast = function(options = {}) { // 就改了这里,加了个 options 参数 instance = new ToastConstructor({ data: options // 这里的 data 会传到 main.vue 组件中的 data 中,当然也可以写在 props 里 }); document.body.appendChild(instance.$mount().$el); }; export default Toast; 其实 main.js 也没多大变化,就是在函数里面加了个参数。要注意的是 new ToastConstructor({ data: options }) 中的 data 就是 main.vue 组件中的 data,不是随随便便取的字段名,传入的 options 会和组件中的 data 合并(Vue 的功劳)。em。。。是的,就这么简单,现在让我们继续来调用一下它:xml复制代码<script> export default { methods: { showToast() { this.$toast({ content: "哈哈哈哈,消失的贼快", duration: 500 }); } } }; </script> 运行一下就可以看到: 当然,这还没完,我们继续添加个小功能点。。。支持 this.$toast.error()这里我们打算支持 this.$toast.error() 和 this.$toast.success() 这两种方式,所以我们第一步还是要先去修改一下 main.vue 文件的内容(主要就是根据 type 值来修改组件的样式),就像下面这样:html复制代码<!--main.vue--> <template> <div class="toast" :class="type ? `toast--${type}` : ''"> <p>{{ content }}</p> </div> </template> <script> export default { ... data() { return { type: "", content: "", duration: 3000 }; }, ... }; </script> <style lang="scss" scoped> .toast { ... &--error p { background: rgba(255, 0, 0, 0.5); } &--success p { background: rgba(0, 255, 0, 0.5); } } </style> 其次,this.$toast.error() 其实就等价于 Toast.error(),所以我们现在的目的就是要给 Toast 函数扩充方法,也比较简单,就先看代码再解释吧:js复制代码// main.js const Toast = function(options = {}) { ... }; // 以下就是在 Toast 函数中拓展 ["success", "error"] 这两个方法 ["success", "error"].forEach(type => { Toast[type] = options => { options.type = type; return Toast(options); }; }); export default Toast; 我们可以看到 Toast.error() 和 Toast.success() 最终还是调用 Toast(options) 这个函数,只不过在调用之前需要多做一步处理,就是将 ["success", "error"] 作为一个 type 参数给合并进 options 里面再传递,仅此而已。那就试试效果吧:html复制代码<script> export default { methods: { showToast() { this.$toast({ content: "这是正常的" }); }, showErrorToast() { this.$toast.error({ content: "竟然失败了" }); }, showSuccessToast() { this.$toast.success({ content: "居然成功了" }); } } }; </script> 结语至此,一个通过 js 调用的简单 toast 组件就搞定啦,短短的几行代码还是挺考验 js 功底的。当然这只是个超简易版的 demo,显然不够完善和健壮,所以我们可以在此基础上扩充一下,比如当 duration <= 0 的时候,我们让这个 toast 一直显示,然后扩展一个 close 方法来关闭等等之类的。不过还是那句老话,实践才是检验真理的唯一标准。纸上得来终觉浅,绝知此事要躬行。step by step, day day up !作者:尤水就下链接:https://juejin.cn/post/6844903825711562766
-
在 Spring Boot 中,将 long 类型传输到前端时,会发现该类型的值可能会出现精度丢失的问题。 这是因为在 JavaScript 中,数字类型默认会被转换为双精度浮点数,而双精度浮点数的精度有限,只能精确表示 2 的 53 次方以内(即 Number.MAX_SAFE_INTEGER,约为 9 x 10^15)的整数。对于超过该范围的长整数,JavaScript 会发生精度丢失,导致值变得不准确。 解决方案一:将 long 转换为字符串 1:在后端将 long 类型的值转换为字符串类型,可以使用 String.valueOf() 方法或者 Long.toString() 方法,如下所示: long num = 123456789012345L; String str = String.valueOf(num); // 或者 String str = Long.toString(num); 2:在前端通过 AJAX 请求获取该字符串类型的值,并将其解析为数字类型。由于 JavaScript 中的数值类型默认使用 IEEE 754 标准的双精度浮点数表示,因此需要使用 JavaScript 的 BigInt() 方法将其转换为大整数类型。 let str = "123456789012345"; let num = BigInt(str); 解决方案二:使用第三方库进行高精度运算 1:在后端将 long 类型的值转换为 BigDecimal 类型(Java 中的高精度类型),并通过 JSON 序列化后传递到前端。这里以 Spring Boot 中使用 FastJSON 序列化为例,如下所示: BigDecimal num = new BigDecimal("123456789012345"); String jsonStr = JSON.toJSONString(num); 2:在前端使用第三方库 big.js 或 bignumber.js 进行高精度运算。这里以 big.js 为例,首先需要引入 big.min.js 文件,在代码中使用 Big() 类构造高精度对象,并进行相应的运算。 <script src="big.min.js"></script> let num = new Big("123456789012345"); let result = num.plus(1); 此外还可以使用注解来解决 long 类型的精度丢失问题 Spring Boot 中提供了 @JsonFormat 注解,可以对实体类中的属性进行序列化和反序列化格式化。对于 long 类型的属性,可以设置其格式为字符串类型,并在前端进行相应的处理,以保持其精度不丢失。 具体实现方式: 1:在实体类中添加 @JsonFormat 注解,设置其 shape 属性为 JsonFormat.Shape.STRING,如下所示: public class Example { @JsonFormat(shape = JsonFormat.Shape.STRING) private Long num; } 2:在前端获取该值时,直接使用字符串类型进行处理,如下所示: let numStr = data.num; Spring Boot 中可以通过配置文件来解决 long 类型的精度丢失问题。 在 Spring Boot 的配置文件 application.properties 中添加如下配置: spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false # 将 long 类型序列化为字符串类型 spring.jackson.serialization.WRITE_NUMBERS_AS_STRINGS=true 其中,WRITE_DATES_AS_TIMESTAMPS 表示是否将日期类型序列化为时间戳类型,默认为 true,这里设置为 false 如果需要将日期类型序列化为时间戳类型,则不需要设置此属性。而 WRITE_NUMBERS_AS_STRINGS 则表示是否将数字类型序列化为字符串类型,默认为 false,这里设置为 true 即可将 long 类型序列化为字符串类型。 ———————————————— 版权声明:本文为CSDN博主「源末coco」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/weixin_58724261/article/details/130974162
-
1 上传对象 1.1 PutObject 调用PutObject接口上传文件(Object) public ObjectWriteResponse putObject(PutObjectArgs args) 注意事项: 添加的Object大小不能超过5 GB。 默认情况下,如果已存在同名Object且对该Object有访问权限,则新添加的Object将覆盖原有的Object,并返回200 OK。 OSS没有文件夹的概念,所有资源都是以文件来存储,但您可以通过创建一个以正斜线(/)结尾,大小为0的Object来创建模拟文件夹。 示例1,InputStream上传: public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { putObject(new File("C:\\Users\\1\\Desktop\\minio.jpg"), "mall4cloud"); } /** * 上传文件 * * @param file 需要上传的文件 * @param bucket 桶名称 */ public static void putObject(File file ,String bucket) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream inputStream = Files.newInputStream(file.toPath()); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(file.getName()).stream( inputStream, inputStream.available(), -1) .build()); inputStream.close(); } 示例2,InputStream使用SSE-C加密上传(后续会介绍): minioClient.putObject( PutObjectArgs.builder().bucket("mall4cloud").object(file.getName()).stream( bais, bais.available(), -1) .sse(ssec) .build()); bais.close(); 示例3,InputStream上传文件,添加自定义元数据及消息头: /** * 上传文件(InputStream上传文件,添加自定义元数据及消息头) * * @param file 需要上传的文件 * @param bucket 桶名称 */ public static void putObject(File file ,String bucket, Map<String, String> headers) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream inputStream = Files.newInputStream(file.toPath()); Map<String, String> userMetadata = new HashMap<>(); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(file.getName()).stream( inputStream, inputStream.available(), -1) .headers(headers) .userMetadata(userMetadata) .build()); inputStream.close(); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { Map<String, String> headers = new HashMap<>(); headers.put("Content-Type", "application/octet-stream"); headers.put("X-Amz-Storage-Class", "REDUCED_REDUNDANCY"); putObject(new File("C:\\Users\\1\\Desktop\\minio.jpg"), "mall4cloud", headers); } 1.2 uploadObject 将文件中的内容作为存储桶中的对象上传。 public void uploadObject(UploadObjectArgs args) 示例: minioClient.uploadObject( UploadObjectArgs.builder() .bucket("mall4cloud") .object("minio.jpg") .filename("C:\\Users\\1\\Desktop\\minio.jpg") .build()); 2 获取对象 2.1 getObject GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。 获取对象的数据。InputStream使用后返回必须关闭以释放网络资源。 public InputStream getObject(GetObjectArgs args) 示例: /** * 获取文件 * * @param bucket 桶名称 * @param filename 文件名 * @param targetPath 存储的路径 */ public static void getObject(String bucket, String filename, String targetPath) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream stream = minioClient.getObject( GetObjectArgs.builder().bucket(bucket).object(filename).build()); // 读流 File targetFile = new File(targetPath); FileUtils.copyInputStreamToFile(stream, targetFile); stream.close(); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { getObject("mall4cloud", "minio.jpg", "C:\\Users\\1\\Desktop\\minio2.jpg"); } 2.2 downloadObject 将对象的数据下载到文件。 public void downloadObject(DownloadObjectArgs args) 示例: /** * 下载文件 * * @param bucket 桶名称 * @param filename 文件名 * @param targetPath 存储的路径 */ public static void downloadObject(String bucket, String filename, String targetPath) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.downloadObject( DownloadObjectArgs.builder() .bucket(bucket) .object(filename) .filename(targetPath) .build()); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { downloadObject("mall4cloud", "minio.jpg", "C:\\Users\\1\\Desktop\\minio2.jpg"); } 2.3 getPresignedObjectUrl 获取一个指定了 HTTP 方法、到期时间和自定义请求参数的对象URL地址,也就是返回带签名的URL,这个地址可以提供给没有登录的第三方共享访问或者上传对象。 public String getPresignedObjectUrl(GetPresignedObjectUrlArgs args) 1 示例: /** * 获取文件url * * @param bucket 桶名称 * @param filename 文件名 */ public static String getPresignedObjectUrl(String bucket, String filename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(filename) .expiry(60 * 60 * 24) .build()); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { System.out.println(getPresignedObjectUrl("mall4cloud", "minio.jpg")); } 2.4 selectObjectContent 通过 SQL 表达式选择对象的内容。 public SelectResponseStream selectObjectContent(SelectObjectContentArgs args) 示例: 上传一个文件,文件内容为: 示例: /** * 通过 SQL 表达式选择对象的内容 * * @param data 上传的数据 * @param bucket 桶名称 * @param filename 文件名 */ public static String selectObjectContent(byte[] data, String bucket, String filename) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // 1. 上传一个文件 ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(filename).stream( byteArrayInputStream, data.length, -1) .build()); // 调用SQL表达式获取对象 String sqlExpression = "select * from S3Object"; InputSerialization is = new InputSerialization(null, false, null, null, FileHeaderInfo.USE, null, null, null); OutputSerialization os = new OutputSerialization(null, null, null, QuoteFields.ASNEEDED, null); SelectResponseStream stream = minioClient.selectObjectContent( SelectObjectContentArgs.builder() .bucket(bucket) .object(filename) .sqlExpression(sqlExpression) .inputSerialization(is) .outputSerialization(os) .requestProgress(true) .build()); byte[] buf = new byte[512]; int bytesRead = stream.read(buf, 0, buf.length); return new String(buf, 0, bytesRead, StandardCharsets.UTF_8); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { List<String> stringList = FileUtil.readLines("C:\\Users\\1\\Desktop\\test.txt", StandardCharsets.UTF_8); System.out.println(selectObjectContent(String.join("\n", stringList).getBytes(), "mall4cloud", "test.txt")); } 2.5 getPresignedPostFormData 使用此方法,获取对象的上传策略(包含签名、文件信息、路径等),然后使用这些信息采用POST 方法的表单数据上传数据。也就是可以生成一个临时上传的信息对象,第三方可以使用这些信息,就可以上传文件。 一般可用于,前端请求一个上传策略,后端返回给前端,前端使用Post请求+访问策略去上传文件,这可以用于JS+SDK的混合方式集成minio public Map<String,String> getPresignedPostFormData(PostPolicy policy) 示例,首先我们创建一个Post 策略,然后,第三方就可以使用这些策略,直接使用POST上传对象,代码如下: /** * 设置并获取Post策略 * * @param bucket 桶名 */ public static Map<String, String> setPostPolicy(String bucket) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // 为存储桶创建一个上传策略,过期时间为7天 PostPolicy policy = new PostPolicy(bucket, ZonedDateTime.now().plusDays(7)); // 设置一个参数key,值为上传对象的名称 policy.addEqualsCondition("key", bucket); // 添加Content-Type以"image/"开头,表示只能上传照片 policy.addStartsWithCondition("Content-Type", "image/"); // 设置上传文件的大小 64kiB to 10MiB. policy.addContentLengthRangeCondition(64 * 1024, 10 * 1024 * 1024); return minioClient.getPresignedPostFormData(policy); } /** * 使用Post上传 * * @param formData Post策略 * @param url minio服务器地址 * @param bucket 桶名 * @param path 本地文件地址 */ public static boolean uploadByHttp(Map<String, String> formData, String url, String bucket, String path) throws IOException { // 创建MultipartBody对象 MultipartBody.Builder multipartBuilder = new MultipartBody.Builder(); multipartBuilder.setType(MultipartBody.FORM); for (Map.Entry<String, String> entry : formData.entrySet()) { multipartBuilder.addFormDataPart(entry.getKey(), entry.getValue()); } multipartBuilder.addFormDataPart("key", bucket); multipartBuilder.addFormDataPart("Content-Type", "image/png"); multipartBuilder.addFormDataPart( "file", "my-objectname", RequestBody.create(new File(path), null)); // 模拟第三方,使用OkHttp调用Post上传对象 Request request = new Request.Builder() .url(url + "/" + bucket) .post(multipartBuilder.build()) .build(); OkHttpClient httpClient = new OkHttpClient().newBuilder().build(); Response response = httpClient.newCall(request).execute(); return response.isSuccessful(); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { String bucket = "mall4cloud"; //第一步:设置并获取Post策略 Map<String, String> formData = setPostPolicy(bucket); //第二步:使用Post请求上传对象 uploadByHttp(formData, url, bucket, "C:\\Users\\1\\Desktop\\minio.jpg"); } 3 复制对象 3.1 copyObject 通过服务器端从另一个对象复制数据来创建一个对象 public ObjectWriteResponse copyObject(CopyObjectArgs args) 示例: /** * 对象拷贝 * * @param sourceBucket 源对象桶 * @param sourceFilename 源文件名 * @param targetBucket 目标对象桶 * @param targetFilename 目标文件名 */ public static void copyObject(String sourceBucket, String sourceFilename, String targetBucket, String targetFilename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.copyObject( CopyObjectArgs.builder() .bucket(targetBucket) .object(targetFilename) .source(CopySource.builder() .bucket(sourceBucket) .object(sourceFilename) .build()) .build()); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { copyObject("mall4cloud", "minio.jpg", "mall4cloud", "minioCopy.jpg"); } 4 删除对象 4.1 removeObject 移除一个对象 public void removeObject(RemoveObjectArgs args) 示例: /** * 删除单个对象 * * @param bucket 桶名称 * @param filename 文件名 */ public static void removeObject(String bucket, String filename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.removeObject( RemoveObjectArgs.builder().bucket(bucket).object(filename).build()); } /** * 删除指定版本号的对象 * * @param bucket 桶名称 * @param filename 文件名 * @param versionId 版本号 */ public static void removeObject(String bucket, String filename, String versionId) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // minioClient.removeObject( RemoveObjectArgs.builder() .bucket(bucket) .object(filename) .versionId(versionId) .build()); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { removeObject("mall4cloud", "minioCopy.jpg"); } 4.2 emoveObjects 懒惰地删除多个对象。它需要迭代返回的 Iterable 以执行删除。 public Iterable<Result<DeleteError>> removeObjects(RemoveObjectsArgs args) 示例: /** * 删除多个文件 * * @param bucket 桶名称 * @param filenames 文件名列表 * @return 返回每个文件的执行情况 */ public static Iterable<Result<DeleteError>> removeObjects(String bucket, List<String> filenames){ if (ObjectUtils.isEmpty(filenames)) { return new LinkedList<>(); } // 7. 删除多个文件 List<DeleteObject> objects = new LinkedList<>(); filenames.forEach(filename-> objects.add(new DeleteObject(filename))); return minioClient.removeObjects( RemoveObjectsArgs.builder().bucket(bucket).objects(objects).build()); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { List<String> filenames = new ArrayList<>(); filenames.add("minioCopy.jpg"); Iterable<Result<DeleteError>> resultList = removeObjects("mall4cloud", filenames); for (Result<DeleteError> result : resultList) { DeleteError error = result.get(); System.out.println( "Error in deleting object " + error.objectName() + "; " + error.message()); } } 5 对象信息查询及设置 5.1 桶的对象信息列表 listObjects列出桶的对象信息 public Iterable<Result<Item>> listObjects(ListObjectsArgs args) 示例1,查询存储桶下文件信息: Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket("my-bucketname").build()); for (Result<Item> result : results) { Item item = result.get(); System.out.println(item.lastModified() + "\t" + item.size() + "\t" + item.objectName()); } 示例2,递归查询存储桶下文件信息: Iterable<Result<Item>> results = minioClient.listObjects( ListObjectsArgs.builder().bucket("my-bucketname").recursive(true).build()); for (Result<Item> result : results) { Item item = result.get(); System.out.println(item.lastModified() + "\t" + item.size() + "\t" + item.objectName()); } 示例3, 条件查询,指定前缀、后缀、最大数量: // 条件查询,指定前缀、后缀、最大数量 Iterable<Result<Item>> results = minioClient.listObjects( ListObjectsArgs.builder() .bucket("my-bucketname") .startAfter("ExampleGuide.pdf") .prefix("E") .maxKeys(100) .build()); for (Result<Item> result : results) { Item item = result.get(); System.out.println(item.lastModified() + "\t" + item.size() + "\t" + item.objectName()); 5.2 保留配置 获取对象的保留配置 public Retention getObjectRetention(GetObjectRetentionArgs args) 示例: // 获取对象保留配置 Retention retention = minioClient.getObjectRetention( GetObjectRetentionArgs.builder() .bucket("my-bucketname-in-eu-with-object-lock") .object("k3s-arm64") .build()); System.out.println("Mode: " + retention.mode()); System.out.println("Retainuntil Date: " + retention.retainUntilDate()); 添加对象的保留配置,存储桶需要设置为对象锁定模式,并且没有开启版本控制,否则会报错收蠕虫保护。 public void setObjectLockRetention(SetObjectRetentionArgs) 1 // 对象保留配置,保留至当前日期后3天。 ZonedDateTime retentionUntil = ZonedDateTime.now(Time.UTC).plusDays(3).withNano(0); Retention retention1 = new Retention(RetentionMode.COMPLIANCE, retentionUntil); minioClient.setObjectRetention( SetObjectRetentionArgs.builder() .bucket("my-bucketname-in-eu-with-object-lock") .object("k3s-arm64") .config(retention1) .bypassGovernanceMode(true) .build()); 5.3 标签 为对象设置标签 public void setObjectTags(SetObjectTagsArgs args) 示例: Map<String, String> map = new HashMap<>(); map.put("Project", "Project One"); map.put("User", "jsmith"); minioClient.setObjectTags( SetObjectTagsArgs.builder() .bucket("my-bucketname") .object("my-objectname") .tags(map) .build()); 获取对象的标签。 public Tags getObjectTags(GetObjectTagsArgs args) 示例: Tags tags = minioClient.getObjectTags( GetObjectTagsArgs.builder().bucket("my-bucketname").object("my-objectname").build()); System.out.println("Object tags: " + tags.get()); 删除对象的标签。 private void deleteObjectTags(DeleteObjectTagsArgs args) 示例: minioClient.deleteObjectTags( DeleteObjectTagsArgs.builder().bucket("my-bucketname").object("my-objectname").build()); System.out.println("Object tags deleted successfully"); 5.4 合法保留对象 启用对对象的合法保留 public void enableObjectLegalHold(EnableObjectLegalHoldArgs args) 案例: minioClient.enableObjectLegalHold( EnableObjectLegalHoldArgs.builder() .bucket("my-bucketname") .object("my-objectname") .versionId("object-versionId") .build()); System.out.println("Legal hold enabled on object successfully "); 禁用对对象的合法保留 public void disableObjectLegalHold(DisableObjectLegalHoldArgs args) 示例: minioClient.disableObjectLegalHold( DisableObjectLegalHoldArgs.builder() .bucket("my-bucketname") .object("my-objectname") .build()); System.out.println("Legal hold disabled on object successfully "); 5.5 组合对象 通过使用服务器端副本组合来自不同源对象的数据来创建对象,比如可以将文件分片上传,然后将他们合并为一个文件。 public ObjectWriteResponse composeObject(ComposeObjectArgs args) 示例: List<ComposeSource> sources = new ArrayList<ComposeSource>(); sources.add( ComposeSource.builder() .bucket("my-bucketname-one") .object("my-objectname-one") .build()); sources.add( ComposeSource.builder() .bucket("my-bucketname-two") .object("my-objectname-two") .build()); minioClient.composeObject( ComposeObjectArgs.builder() .bucket("my-destination-bucket") .object("my-destination-object") .sources(sources) .build()); System.out.println("Object Composed successfully"); 5.6 元数据 获取对象的对象信息和元数据 public ObjectStat statObject(StatObjectArgs args) 示例: StatObjectResponse stat = minioClient.statObject( StatObjectArgs.builder() .bucket("my-bucketname") .object("start.sh") .build()); System.out.println(stat.toString()); 6 工具类参考 import io.minio.*; import io.minio.errors.*; import io.minio.http.Method; import io.minio.messages.*; import okhttp3.*; import org.apache.commons.io.FileUtils; import org.springframework.util.ObjectUtils; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.ZonedDateTime; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; /** * minio对象操作工具类 * * @author wuKeFan * @date 2023-09-19 09:45:43 */ public class MinioObjectUtil { /** * minio地址(自己填写) */ private static final String url = "url"; /** * minio用户名(自己填写) */ private static final String accessKey = "accessKey"; /** * minio密码(自己填写) */ private static final String secretKey = "secretKey"; private static final MinioClient minioClient; static { minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); } /** * 上传文件(InputStream上传) * * @param file 需要上传的文件 * @param bucket 桶名称 */ public static void putObject(File file ,String bucket) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream inputStream = Files.newInputStream(file.toPath()); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(file.getName()).stream( inputStream, inputStream.available(), -1) .build()); inputStream.close(); } /** * 上传文件(InputStream使用SSE-C加密上传) * * @param file 需要上传的文件 * @param bucket 桶名称 */ public static void putObject(File file ,String bucket, ServerSideEncryption ssec) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream inputStream = Files.newInputStream(file.toPath()); minioClient.putObject( PutObjectArgs.builder().bucket("mall4cloud").object(file.getName()).stream( inputStream, inputStream.available(), -1) .sse(ssec) .build()); inputStream.close(); } /** * 上传文件(InputStream上传文件,添加自定义元数据及消息头) * * @param file 需要上传的文件 * @param bucket 桶名称 */ public static void putObject(File file ,String bucket, Map<String, String> headers) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream inputStream = Files.newInputStream(file.toPath()); Map<String, String> userMetadata = new HashMap<>(); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(file.getName()).stream( inputStream, inputStream.available(), -1) .headers(headers) .userMetadata(userMetadata) .build()); inputStream.close(); } /** * 获取文件 * * @param bucket 桶名称 * @param filename 文件名 * @param targetPath 存储的路径 */ public static void getObject(String bucket, String filename, String targetPath) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { InputStream stream = minioClient.getObject( GetObjectArgs.builder().bucket(bucket).object(filename).build()); // 读流 File targetFile = new File(targetPath); FileUtils.copyInputStreamToFile(stream, targetFile); stream.close(); } /** * 下载文件 * * @param bucket 桶名称 * @param filename 文件名 * @param targetPath 存储的路径 */ public static void downloadObject(String bucket, String filename, String targetPath) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.downloadObject( DownloadObjectArgs.builder() .bucket(bucket) .object(filename) .filename(targetPath) .build()); } /** * 获取文件url * * @param bucket 桶名称 * @param filename 文件名 */ public static String getPresignedObjectUrl(String bucket, String filename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(filename) .expiry(60 * 60 * 24) .build()); } /** * 通过 SQL 表达式选择对象的内容 * * @param data 上传的数据 * @param bucket 桶名称 * @param filename 文件名 */ public static String selectObjectContent(byte[] data, String bucket, String filename) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // 1. 上传一个文件 ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data); minioClient.putObject( PutObjectArgs.builder().bucket(bucket).object(filename).stream( byteArrayInputStream, data.length, -1) .build()); // 调用SQL表达式获取对象 String sqlExpression = "select * from S3Object"; InputSerialization is = new InputSerialization(null, false, null, null, FileHeaderInfo.USE, null, null, null); OutputSerialization os = new OutputSerialization(null, null, null, QuoteFields.ASNEEDED, null); SelectResponseStream stream = minioClient.selectObjectContent( SelectObjectContentArgs.builder() .bucket(bucket) .object(filename) .sqlExpression(sqlExpression) .inputSerialization(is) .outputSerialization(os) .requestProgress(true) .build()); byte[] buf = new byte[512]; int bytesRead = stream.read(buf, 0, buf.length); return new String(buf, 0, bytesRead, StandardCharsets.UTF_8); } /** * 设置并获取Post策略 * * @param bucket 桶名 */ public static Map<String, String> setPostPolicy(String bucket) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // 为存储桶创建一个上传策略,过期时间为7天 PostPolicy policy = new PostPolicy(bucket, ZonedDateTime.now().plusDays(7)); // 设置一个参数key,值为上传对象的名称 policy.addEqualsCondition("key", bucket); // 添加Content-Type以"image/"开头,表示只能上传照片 policy.addStartsWithCondition("Content-Type", "image/"); // 设置上传文件的大小 64kiB to 10MiB. policy.addContentLengthRangeCondition(64 * 1024, 10 * 1024 * 1024); return minioClient.getPresignedPostFormData(policy); } /** * 使用Post上传 * * @param formData Post策略 * @param url minio服务器地址 * @param bucket 桶名 * @param path 本地文件地址 */ public static boolean uploadByHttp(Map<String, String> formData, String url, String bucket, String path) throws IOException { // 创建MultipartBody对象 MultipartBody.Builder multipartBuilder = new MultipartBody.Builder(); multipartBuilder.setType(MultipartBody.FORM); for (Map.Entry<String, String> entry : formData.entrySet()) { multipartBuilder.addFormDataPart(entry.getKey(), entry.getValue()); } multipartBuilder.addFormDataPart("key", bucket); multipartBuilder.addFormDataPart("Content-Type", "image/png"); multipartBuilder.addFormDataPart( "file", "my-objectname", RequestBody.create(new File(path), null)); // 模拟第三方,使用OkHttp调用Post上传对象 Request request = new Request.Builder() .url(url + "/" + bucket) .post(multipartBuilder.build()) .build(); OkHttpClient httpClient = new OkHttpClient().newBuilder().build(); Response response = httpClient.newCall(request).execute(); return response.isSuccessful(); } /** * 对象拷贝 * * @param sourceBucket 源对象桶 * @param sourceFilename 源文件名 * @param targetBucket 目标对象桶 * @param targetFilename 目标文件名 */ public static void copyObject(String sourceBucket, String sourceFilename, String targetBucket, String targetFilename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.copyObject( CopyObjectArgs.builder() .bucket(targetBucket) .object(targetFilename) .source(CopySource.builder() .bucket(sourceBucket) .object(sourceFilename) .build()) .build()); } /** * 删除单个对象 * * @param bucket 桶名称 * @param filename 文件名 */ public static void removeObject(String bucket, String filename) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient.removeObject( RemoveObjectArgs.builder().bucket(bucket).object(filename).build()); } /** * 删除指定版本号的对象 * * @param bucket 桶名称 * @param filename 文件名 * @param versionId 版本号 */ public static void removeObject(String bucket, String filename, String versionId) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // minioClient.removeObject( RemoveObjectArgs.builder() .bucket(bucket) .object(filename) .versionId(versionId) .build()); } /** * 删除多个文件 * * @param bucket 桶名称 * @param filenames 文件名列表 * @return 返回每个文件的执行情况 */ public static Iterable<Result<DeleteError>> removeObjects(String bucket, List<String> filenames){ if (ObjectUtils.isEmpty(filenames)) { return new LinkedList<>(); } // 7. 删除多个文件 List<DeleteObject> objects = new LinkedList<>(); filenames.forEach(filename-> objects.add(new DeleteObject(filename))); return minioClient.removeObjects( RemoveObjectsArgs.builder().bucket(bucket).objects(objects).build()); } public static void main(String[] args) { } } ———————————————— 版权声明:本文为CSDN博主「吴名氏.」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/qq_37284798/article/details/133014533
-
1 前言 1.1 官方文档和SDK 官方文档:https://min.io/docs/minio/kubernetes/upstream/index.html?ref=docs-redirect SDK:https://github.com/minio/minio-java Minio 提供了多种语言的SDK,比如java、go、python等。JAVA开发平台可以选择JS和java SDK,也就是前端和后端都可以直接集成minio。 1.2 技术方案 每个OSS的用户都会用到上传服务。Web端常见的上传方法是用户在浏览器或App端上传文件到应用服务器,应用服务器再把文件上传到OSS。具体流程如下图所示。 和数据直传到OSS相比,以上方法有三个缺点: 上传慢:用户数据需先上传到应用服务器,之后再上传到OSS。网络传输时间比直传到OSS多一倍。如果用户数据不通过应用服务器中转,而是直传到OSS,速度将大大提升。而且OSS采用BGP带宽,能保证各地各运营商之间的传输速度。 扩展性差:如果后续用户多了,应用服务器会成为瓶颈。 费用高:需要准备多台应用服务器。由于OSS上传流量是免费的,如果数据直传到OSS,不通过应用服务器,那么将能省下几台应用服务器。 目前通过Web前端技术上传文件到OSS,有三种技术方案: 利用OSS js SDK将文件上传到OSS,也就是前端直连OSS,但是容易暴露认证信息,安全性不太高。 使用表单上传方式,将文件上传到OSS。利用OSS提供的接口临时接口,使用表单上传方式将文件上传到OSS。然后请求后端,告知上传完成,进行后续处理。 先上传到应用服务器,再请求OSS上传,这种安全性较高,可以对数据和认证进行管控,但是性能最差。 2 集成 JAVA SDK 因为一般的非互联网项目,对性能要求不高,所以采用JAVA SDK集成MInio,然后提供接口给Web端调用就行了。 2.1 环境搭建 首先搭建一个Maven基础工程,引入相关依赖,这里引入的是最新的8.3.1版本。还引入了okhttp的最新包,不然某些API会提示版本太低。 <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.3.1</version> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.9.2</version> </dependency> 2.2 初始化客户端 可以看到现在minio都是采用Builder构建者模式来构造对象,和之前有很大的区别,所以需要注意。 //url为地址,accessKey和secretKey为用户名和密码 MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); 2.3 存储桶基础操作 2.3.1 存储桶是否存在 检查存储桶是否存在。 public boolean bucketExists(BucketExistsArgs args) 示例代码: /** * 判断桶是否存在 */ public static boolean bucketExists(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); } 2.3.2 创建存储桶 创建一个启用给定区域和对象锁定功能的存储桶。 public void makeBucket(MakeBucketArgs args) 示例代码: /** * 添加存储桶 */ public static void makeBucket(String url, String accessKey, String secretKey, String bucketName, String region, boolean objectLock) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).region(region).objectLock(objectLock).build()); } 创建后,就可以在控制台看到这些存储桶了,最后那个被锁定的存储桶,上传文件及删除后,发现还是会显示存在这些对象,实际磁盘上的文件并没有删除 2.3.3 查询存储桶信息列表 列出所有桶的桶信息。 public List<Bucket> listBuckets() 示例代码: /** * 查询存储桶信息列表 */ public static List<Bucket> listBuckets(String url, String accessKey, String secretKey) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); return minioClient.listBuckets(); } public static void main(String[] args) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { List<Bucket> buckets = listBuckets("url", "accessKey", "secretKey"); for (Bucket bucket : buckets) { System.out.println(bucket.creationDate() + ", " + bucket.name()); } } 打印信息如下,返回的创建时间是美国时间,需要注意。 2.3.4 删除存储桶 删除一个空桶。 public void removeBucket(RemoveBucketArgs args) 示例代码: /** * 删除存储桶 */ public static void removeBucket(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build()); } 注意:要确保存储桶存在,否则会报错,删除时最好调用bucketExists()方法判断是否存在 2.4 设置存储桶操作 2.4.1 加密配置 设置桶的加密配置,以允许在该桶中上传对象时,采用对应加密配置对数据进行加密。当前支持配置的服务端加密方式为KMS托管密钥的服务端加密(SSE-KMS),及AES256加密。 设置桶的加密配置: public void setBucketEncryption(SetBucketEncryptionArgs args) 获取桶的加密配置: public SseConfiguration getBucketEncryption(GetBucketEncryptionArgs args) 2.4.2 生命周期 生命周期管理可适用于以下典型场景: 周期性上传的日志文件,可能只需要保留一个星期或一个月。到期后要删除它们。 某些文档在一段时间内经常访问,但是超过一定时间后便可能不再访问了。这些文档需要在一定时间后转化为低频访问存储,归档存储或者删除。 存储桶生命周期配置: public void setBucketLifecycle(SetBucketLifecycleArgs args) 获取桶的生命周期配置: public LifecycleConfiguration getBucketLifecycle(GetBucketLifecycleArgs args) 示例代码: // 5. 生命周期 List<LifecycleRule> rules = new LinkedList<>(); // 配置生命周期规则 rules.add( new LifecycleRule( Status.ENABLED, // 开启状态 null, new Expiration((ZonedDateTime) null, 365, null), // 保存365天 new RuleFilter("logs/"), // 目录配置 "rule2", null, null, null)); LifecycleConfiguration lifecycleConfiguration = new LifecycleConfiguration(rules); // 添加生命周期配置 minioClient.setBucketLifecycle( SetBucketLifecycleArgs.builder().bucket("my-bucketname").config(lifecycleConfiguration).build()); // 获取配置 LifecycleConfiguration lifecycleConfiguration1111 = minioClient.getBucketLifecycle( GetBucketLifecycleArgs.builder().bucket("my-bucketname").build()); List<LifecycleRule> rules1 = lifecycleConfiguration1111.rules(); for (int i = 0; i < rules1.size(); i++) { System.out.println("Lifecycle status is " + rules1.get(i).status()); System.out.println("Lifecycle prefix is " + rules1.get(i).filter().prefix()); System.out.println("Lifecycle expiration days is " + rules1.get(i).expiration().days()); } 打印结果如下: 2.4.3 通知配置 可以使用存储桶事件通知来监控存储桶中对象上发生的事件。 MinIO 服务器支持的各种事件类型有: 存储桶配置通知: public void setBucketPolicy(SetBucketPolicyArgs args) 获取桶的通知配置: public NotificationConfiguration getBucketNotification(GetBucketNotificationArgs args) 代码示例: // 6. 通知配置 // Add a new SQS configuration. NotificationConfiguration notificationConfiguration = new NotificationConfiguration(); List<QueueConfiguration> queueConfigurationList = notificationConfiguration.queueConfigurationList(); QueueConfiguration queueConfiguration = new QueueConfiguration(); queueConfiguration.setQueue("arn:minio:sqs::1:webhook"); List<EventType> eventList = new LinkedList<>(); eventList.add(EventType.OBJECT_CREATED_PUT); eventList.add(EventType.OBJECT_CREATED_COPY); queueConfiguration.setEvents(eventList); queueConfiguration.setPrefixRule("images"); queueConfiguration.setSuffixRule("pg"); queueConfigurationList.add(queueConfiguration); notificationConfiguration.setQueueConfigurationList(queueConfigurationList); // Set updated notification configuration. minioClient.setBucketNotification( SetBucketNotificationArgs.builder().bucket("my-bucketname").config(notificationConfiguration).build()); System.out.println("Bucket notification is set successfully"); NotificationConfiguration minioClientBucketNotification = minioClient.getBucketNotification( GetBucketNotificationArgs.builder().bucket("my-bucketname").build()); System.out.println(minioClientBucketNotification); 2.4.4 策略配置 添加存储桶策略配置。 public void setBucketPolicy(SetBucketPolicyArgs args) 获取桶的桶策略配置。 public String getBucketPolicy(GetBucketPolicyArgs args) 2.4.5 复制配置 存储桶复制旨在将存储桶中的选定对象复制到目标存储桶,内容较多,后续补上 添加存储桶的复制配置 public void setBucketReplication(SetBucketReplicationArgs args) 获取桶的桶复制配置: public ReplicationConfiguration getBucketReplication(GetBucketReplicationArgs args) 2.4.6 存储桶标签 当为桶添加标签时,该桶上所有请求产生的计费话单里都会带上这些标签,从而可以针对话单报表做分类筛选,进行更详细的成本分析。例如:某个应用程序在运行过程会往桶里上传数据,我们可以用应用名称作为标签,设置到被使用的桶上。在分析话单时,就可以通过应用名称的标签来分析此应用的成本。 setBucketTags可以为存储桶设置标签。 public void setBucketTags(SetBucketTagsArgs args) getBucketTags获取桶的标签。 public Tags getBucketTags(GetBucketTagsArgs args) 示例代码: // 1. 存储桶标签 Map<String, String> map = new HashMap<>(); map.put("Project", "Project One"); map.put("User", "jsmith"); // 设置标签 minioClient.setBucketTags(SetBucketTagsArgs.builder().bucket("my-bucketname").tags(map).build()); // 查询标签 Tags bucketTags = minioClient.getBucketTags(GetBucketTagsArgs.builder().bucket("my-bucketname").build()); System.out.println(bucketTags.get().toString()); 返回结果: 2.4.7 多版本设置 若开启了多版本控制,上传对象时,OBS自动为每个对象创建唯一的版本号。上传同名的对象将以不同的版本号同时保存在OBS中。 若未开启多版本控制,向同一个文件夹中上传同名的对象时,新上传的对象将覆盖原有的对象。 某些功能(例如版本控制、对象锁定和存储桶复制)需要使用擦除编码分布式部署 MinIO。开启了版本控制后,允许在同一密钥下保留同一对象的多个版本。 设置存储桶的版本控制配置。 public void setBucketVersioning(SetBucketVersioningArgs args) 获取存储桶的版本控制配置。 public VersioningConfiguration getBucketVersioning(GetBucketVersioningArgs args) 代码示例: // 2. 版本配置 // 'my-bucketname'启用版本控制 minioClient.setBucketVersioning( SetBucketVersioningArgs.builder() .bucket("my-bucketname") .config(new VersioningConfiguration(VersioningConfiguration.Status.ENABLED, null)) .build()); System.out.println("Bucket versioning is enabled successfully"); // 'my-bucketname'暂停版本控制 minioClient.setBucketVersioning( SetBucketVersioningArgs.builder() .bucket("my-bucketname") .config(new VersioningConfiguration(VersioningConfiguration.Status.SUSPENDED, null)) .build()); System.out.println("Bucket versioning is suspended successfully"); 2.4.8 对象锁定配置 对象锁定设置后,删除对象后,会仍然存在磁盘中。 在存储桶中设置对象锁定配置。 public void setObjectLockConfiguration(SetObjectLockConfigurationArgs args) 获取存储桶中的对象锁配置。 public ObjectLockConfiguration getObjectLockConfiguration(GetObjectLockConfigurationArgs args) 需要先设置存储桶为对象锁定模式,示例代码: // 3. 将保留模式设置为Compliance,且持续时间为100天 // 设置锁定对象的保留模式及时限 ObjectLockConfiguration config = new ObjectLockConfiguration(RetentionMode.COMPLIANCE, new RetentionDurationDays(100)); minioClient.setObjectLockConfiguration( SetObjectLockConfigurationArgs.builder() .bucket("my-bucketname-in-eu-with-object-lock") .config(config) .build()); System.out.println("object-lock configuration is set successfully"); // 获取锁定配置 ObjectLockConfiguration objectLockConfiguration = minioClient.getObjectLockConfiguration( GetObjectLockConfigurationArgs.builder() .bucket("my-lock-enabled-bucketname") .build()); System.out.println("Object-lock configuration of bucket"); System.out.println("Mode: " + objectLockConfiguration.mode()); System.out.println("Duration: " + objectLockConfiguration.duration()); 2.5 删除配置 minio提供了一些列的delete方法用于删除配置,比较简单,就不举例说明了。 2.5.1 删除桶的加密配置 public void deleteBucketEncryption(DeleteBucketEncryptionArgs args) 2.5.2 删除存储桶的生命周期配置 public void deleteBucketLifecycle(DeleteBucketLifecycleArgs args) 2.5.3 删除桶的标签 public void deleteBucketTags(DeleteBucketTagsArgs args) 2.5.4 删除桶的桶策略配置 public void deleteBucketPolicy(DeleteBucketPolicyArgs args) 2.5.5 删除存储桶的存储桶复制配置 public void deleteBucketReplication(DeleteBucketReplicationArgs args) 2.5.6 删除桶的通知配置 public void deleteBucketNotification(DeleteBucketNotificationArgs args) 3 相关工具类 import io.minio.*; import io.minio.errors.*; import io.minio.messages.Bucket; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.List; /** * minio工具类 * * @author wuKeFan * @date 2023-09-08 14:08:10 */ public class MinioUtil { /** * 判断桶是否存在 */ public static boolean bucketExists(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); } /** * 添加存储桶 */ public static void makeBucket(String url, String accessKey, String secretKey, String bucketName, String region, boolean objectLock) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).region(region).objectLock(objectLock).build()); } /** * 指定地区添加存储桶 */ public static void makeBucket(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } /** * 指定地区添加存储桶并锁定对象 */ public static void makeBucket(String url, String accessKey, String secretKey, String bucketName, String region) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).region(region).build()); } /** * 删除存储桶 */ public static void removeBucket(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build()); } /** * 设置桶公有 */ public static void setBucketPublicPolicy(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); String sb = "{\"Version\":\"2012-10-17\"," + "\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":" + "{\"AWS\":[\"*\"]},\"Action\":[\"s3:ListBucket\",\"s3:ListBucketMultipartUploads\"," + "\"s3:GetBucketLocation\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\",\"s3:ListMultipartUploadParts\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}"; minioClient.setBucketPolicy( SetBucketPolicyArgs.builder() .bucket(bucketName) .config(sb) .build()); } /** * 设置桶私有 */ public static void setBucketPrivatePolicy(String url, String accessKey, String secretKey, String bucketName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); minioClient.setBucketPolicy( SetBucketPolicyArgs.builder().bucket(bucketName) .config( "{\"Version\":\"2012-10-17\",\"Statement\":[]}" ) .build()); } /** * 查询存储桶信息列表 */ public static List<Bucket> listBuckets(String url, String accessKey, String secretKey) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { MinioClient minioClient = MinioClient.builder().endpoint(url) .credentials(accessKey, secretKey).build(); return minioClient.listBuckets(); } } ———————————————— 版权声明:本文为CSDN博主「吴名氏.」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/qq_37284798/article/details/132964198
-
1 前言 大家平时的工作中,可能也在很多地方用到了加密、解密,比如: 用户的密码不能明文存储,要存储加密后的密文 用户的银行卡号、身份证号之类的敏感数据,需要加密传输 还有一些重要接口,比如支付,客户端要对请求生成一个签名,服务端要对签名进行验证 …… 那么上面提到的这些能力,我们都可以利用哪些加密算法来实现呢?咱们接着往下看。 2 常见加密算法 算法整体上可以分为不可逆加密,以及可逆加密,可逆加密又可以分为对称加密和非对称加密。 2.1 不可逆算法 不可逆加密的算法的加密是不可逆的,密文无法被还原成原文。 散列算法,就是一种不可逆算法。散列算法中,明文通过散列算法生成散列值,散列值是长度固定的数据,和明文长度无关。 散列算法的具体实现有很多种,常见的包括MD5、SHA1、SHA-224、SHA-256等等。 散列算法常用于数字签名、消息认证、密码存储等场景。 散列算法是不需要密钥的,当然也有一些不可逆算法,需要密钥,例如HMAC算法。 2.1.1 MD5 MD5,全称为“Message-Digest Algorithm 5”,翻译过来叫“信息摘要算法”。它可以将任意长度的数据通过散列算法,生成一个固定长度的散列值。MD5算法的输出长度为128位,通常用32个16进制数表示。 我们来看下MD5算法的Java代码实现: public class MD5 { private static final String MD5_ALGORITHM = "MD5"; public static String encrypt(String data) throws Exception { // 获取MD5算法实例 MessageDigest messageDigest = MessageDigest.getInstance(MD5_ALGORITHM); // 计算散列值 byte[] digest = messageDigest.digest(data.getBytes()); Formatter formatter = new Formatter(); // 补齐前导0,并格式化 for (byte b : digest) { formatter.format("%02x", b); } return formatter.toString(); } public static void main(String[] args) throws Exception { String data = "Hello World"; String encryptedData = encrypt(data); System.out.println("加密后的数据:" + encryptedData); }}MD5有一些优点,比如计算速度快、输出长度固定、应用广泛等等。 但是作为一个加密算法,它有一个天大的缺点,那就是不安全。 MD5算法已经被攻破,而且MD5算法的输出长度有限,攻击者可以通过暴力破解或彩虹表攻击等方式,找到与原始数据相同的散列值,从而破解数据。 虽然可以通过加盐,也就是对在原文里再加上一些不固定的字符串来缓解,但是完全可以用更安全的SHA系列算法替代。 2.1.2 SHA-256 SHA(Secure Hash Algorithm)系列算法是一组密码散列函数,用于将任意长度的数据映射为固定长度的散列值。SHA系列算法由美国国家安全局(NSA)于1993年设计,目前共有SHA-1、SHA-2、SHA-3三种版本。 其中SHA-1系列存在缺陷,已经不再被推荐使用。 SHA-2算法包括SHA-224、SHA-256、SHA-384和SHA-512四种散列函数,分别将任意长度的数据映射为224位、256位、384位和512位的散列值。 我们来看一下最常用的SHA-256的Java代码实现: public class SHA256 { private static final String SHA_256_ALGORITHM = "SHA-256"; public static String encrypt(String data) throws Exception { //获取SHA-256算法实例 MessageDigest messageDigest = MessageDigest.getInstance(SHA_256_ALGORITHM); //计算散列值 byte[] digest = messageDigest.digest(data.getBytes()); StringBuilder stringBuilder = new StringBuilder(); //将byte数组转换为15进制字符串 for (byte b : digest) { stringBuilder.append(Integer.toHexString((b & 0xFF) | 0x100), 1, 3); } return stringBuilder.toString(); } public static void main(String[] args) throws Exception { String data = "Hello World"; String encryptedData = encrypt(data); System.out.println("加密后的数据:" + encryptedData); }}SHA-2算法之所以比MD5强,主要有两个原因: 散列值长度更长:例如SHA-256算法的散列值长度为256位,而MD5算法的散列值长度为128位,这就提高了攻击者暴力破解或者彩虹表攻击的难度。 更强的碰撞抗性:SHA算法采用了更复杂的运算过程和更多的轮次,使得攻击者更难以通过预计算或巧合找到碰撞。 当然,SHA-2也不是绝对安全的,散列算法都有被暴力破解或者彩虹表攻击的风险,所以,在实际的应用中,加盐还是必不可少的。 2.2 对称加密算法 对称加密算法,使用同一个密钥进行加密和解密。 加密和解密过程使用的是相同的密钥,因此密钥的安全性至关重要。如果密钥泄露,攻击者可以轻易地破解加密数据。 常见的对称加密算法包括DES、3DES、AES等。其中,AES算法是目前使用最广泛的对称加密算法之一,具有比较高的安全性和加密效率。 2.2.1 DES DES(Data Encryption Standard)算法是一种对称加密算法,由IBM公司于1975年研发,是最早的一种广泛应用的对称加密算法之一。 DES算法使用56位密钥对数据进行加密,加密过程中使用了置换、替换、异或等运算,具有较高的安全性。 我们来看下DES算法的Java代码实现: public class DES { private static final String DES_ALGORITHM = "DES"; /** * DES加密 * * @param data 待加密的数据 * @param key 密钥,长度必须为8位 * @return 加密后的数据,使用Base64编码 */ public static String encrypt(String data, String key) throws Exception { // 根据密钥生成密钥规范 KeySpec keySpec = new DESKeySpec(key.getBytes()); // 根据密钥规范生成密钥工厂 SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(DES_ALGORITHM); // 根据密钥工厂和密钥规范生成密钥 SecretKey secretKey = secretKeyFactory.generateSecret(keySpec); // 根据加密算法获取加密器 Cipher cipher = Cipher.getInstance(DES_ALGORITHM); // 初始化加密器,设置加密模式和密钥 cipher.init(Cipher.ENCRYPT_MODE, secretKey); // 加密数据 byte[] encryptedData = cipher.doFinal(data.getBytes()); // 对加密后的数据进行Base64编码 return Base64.getEncoder().encodeToString(encryptedData); } /** * DES解密 * * @param encryptedData 加密后的数据,使用Base64编码 * @param key 密钥,长度必须为8位 * @return 解密后的数据 */ public static String decrypt(String encryptedData, String key) throws Exception { // 根据密钥生成密钥规范 KeySpec keySpec = new DESKeySpec(key.getBytes()); // 根据密钥规范生成密钥工厂 SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(DES_ALGORITHM); // 根据密钥工厂和密钥规范生成密钥 SecretKey secretKey = secretKeyFactory.generateSecret(keySpec); // 对加密后的数据进行Base64解码 byte[] decodedData = Base64.getDecoder().decode(encryptedData); // 根据加密算法获取解密器 Cipher cipher = Cipher.getInstance(DES_ALGORITHM); // 初始化解密器,设置解密模式和密钥 cipher.init(Cipher.DECRYPT_MODE, secretKey); // 解密数据 byte[] decryptedData = cipher.doFinal(decodedData); // 将解密后的数据转换为字符串 return new String(decryptedData); } public static void main(String[] args) throws Exception { String data = "Hello World"; String key = "12345678"; String encryptedData = encrypt(data, key); System.out.println("加密后的数据:" + encryptedData); String decryptedData = decrypt(encryptedData, key); System.out.println("解密后的数据:" + decryptedData); }}DES的算法速度较快,但是在安全性上面并不是最优选择,因为DES算法的密钥长度比较短,被暴力破解和差分攻击的风险比较高,一般推荐用一些更安全的对称加密算法,比如3DES、AES。 2.2.2 AES AES(Advanced Encryption Standard)即高级加密标准,是一种对称加密算法,被广泛应用于数据加密和保护领域。AES算法使用的密钥长度为128位、192位或256位,比DES算法的密钥长度更长,安全性更高。 我们来看下AES算法的Java代码实现: public class AES { private static final String AES_ALGORITHM = "AES"; // AES加密模式为CBC,填充方式为PKCS5Padding private static final String AES_TRANSFORMATION = "AES/CBC/PKCS5Padding"; // AES密钥为16位 private static final String AES_KEY = "1234567890123456"; // AES初始化向量为16位 private static final String AES_IV = "abcdefghijklmnop"; /** * AES加密 * * @param data 待加密的数据 * @return 加密后的数据,使用Base64编码 */ public static String encrypt(String data) throws Exception { // 将AES密钥转换为SecretKeySpec对象 SecretKeySpec secretKeySpec = new SecretKeySpec(AES_KEY.getBytes(), AES_ALGORITHM); // 将AES初始化向量转换为IvParameterSpec对象 IvParameterSpec ivParameterSpec = new IvParameterSpec(AES_IV.getBytes()); // 根据加密算法获取加密器 Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); // 初始化加密器,设置加密模式、密钥和初始化向量 cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); // 加密数据 byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); // 对加密后的数据使用Base64编码 return Base64.getEncoder().encodeToString(encryptedData); } /** * AES解密 * * @param encryptedData 加密后的数据,使用Base64编码 * @return 解密后的数据 */ public static String decrypt(String encryptedData) throws Exception { // 将AES密钥转换为SecretKeySpec对象 SecretKeySpec secretKeySpec = new SecretKeySpec(AES_KEY.getBytes(), AES_ALGORITHM); // 将AES初始化向量转换为IvParameterSpec对象 IvParameterSpec ivParameterSpec = new IvParameterSpec(AES_IV.getBytes()); // 根据加密算法获取解密器 Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION); // 初始化解密器,设置解密模式、密钥和初始化向量 cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); // 对加密后的数据使用Base64解码 byte[] decodedData = Base64.getDecoder().decode(encryptedData); // 解密数据 byte[] decryptedData = cipher.doFinal(decodedData); // 返回解密后的数据 return new String(decryptedData, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { String data = "Hello World"; String encryptedData = encrypt(data); System.out.println("加密后的数据:" + encryptedData); String decryptedData = decrypt(encryptedData); System.out.println("解密后的数据:" + decryptedData); }}AES算法采用的密钥长度更长,密钥空间更大,安全性更高,能够有效地抵抗暴力破解攻击。 当然,因为密钥长度较长,需要的存储也更多。 对于对称加密算法而言,最大的痛点就在于密钥管理困难,相比而言,非对称加密就没有这个担忧。 2.3 非对称加密算法 非对称加密算法需要两个密钥,这两个密钥互不相同,但是相互匹配,一个称为公钥,另一个称为私钥。 使用其中的一个加密,则使用另一个进行解密。例如使用公钥加密,则需要使用私钥解密。 2.3.1 RSA RSA算法是是目前应用最广泛的非对称加密算法,由Ron Rivest、Adi Shamir和Leonard Adleman三人在1978年发明,名字来源三人的姓氏首字母。 我们看下RSA算法的Java实现: public class RSA { private static final String RSA_ALGORITHM = "RSA"; /** * 生成RSA密钥对 * * @return RSA密钥对 */ public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM); keyPairGenerator.initialize(2048); // 密钥大小为2048位 return keyPairGenerator.generateKeyPair(); } /** * 使用公钥加密数据 * * @param data 待加密的数据 * @param publicKey 公钥 * @return 加密后的数据 */ public static String encrypt(String data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedData); } /** * 使用私钥解密数据 * * @param encryptedData 加密后的数据 * @param privateKey 私钥 * @return 解密后的数据 */ public static String decrypt(String encryptedData, PrivateKey privateKey) throws Exception { byte[] decodedData = Base64.getDecoder().decode(encryptedData); Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedData = cipher.doFinal(decodedData); return new String(decryptedData, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { KeyPair keyPair = generateKeyPair(); PublicKey publicKey = keyPair.getPublic(); PrivateKey privateKey = keyPair.getPrivate(); String data = "Hello World"; String encryptedData = encrypt(data, publicKey); System.out.println("加密后的数据:" + encryptedData); String decryptedData = decrypt(encryptedData, privateKey); System.out.println("解密后的数据:" + decryptedData); }}RSA算法的优点是安全性高,公钥可以公开,私钥必须保密,保证了数据的安全性;可用于数字签名、密钥协商等多种应用场景。 缺点是加密、解密速度较慢,密钥长度越长,加密、解密时间越长;密钥长度过短容易被暴力破解,密钥长度过长则会增加计算量和存储空间的开销。 3 总结 这一期就给大家简单盘点了一下最常用的5种加密算法。 ———————————————— 版权声明:本文为CSDN博主「吴名氏.」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/qq_37284798/article/details/131856488
-
H2数据库在单元测试中的作用及使用方法单元测试是软件开发过程中的一种重要测试方法,用于验证代码的各个模块是否能够正常工作。在Java开发中,常用的单元测试框架有JUnit、TestNG等。本文将介绍H2数据库在单元测试中的作用以及如何使用。一、H2数据库简介H2是一个开源的嵌入式数据库,采用纯Java编写,不需要安装和配置,体积小、速度快、支持事务处理等特点。在Java项目中,经常使用H2数据库作为内存数据库进行开发和测试。二、H2数据库在单元测试中的作用模拟真实数据库环境:在进行单元测试时,需要模拟实际生产环境中的数据存储和访问方式。H2数据库可以作为一个轻量级的内存数据库,方便地创建和管理数据表,为测试提供真实的数据环境。隔离测试数据:使用H2数据库可以避免测试数据与生产数据相互干扰,保证测试的准确性和可靠性。同时,H2数据库支持多线程访问,可以在多线程环境下进行单元测试。便于调试和维护:H2数据库提供了丰富的日志和性能分析工具,可以帮助开发人员快速定位和解决单元测试中出现的问题,提高开发效率。三、如何使用H2数据库进行单元测试添加H2依赖在Java项目的pom.xml文件中添加H2数据库的依赖:<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>1.4.200</version> </dependency>初始化H2数据库在使用H2数据库之前,需要先创建一个数据源并初始化:import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import javax.sql.DataSource; import org.h2.tools.Server; import org.h2.tools.ServerSetup; public class H2Database { private static final String DB_URL = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"; private static final String USER = "sa"; private static final String PASSWORD = ""; public static DataSource getDataSource() throws SQLException { ServerSetup setup = new ServerSetup().setPort(8082).setHttpEnabled(false).setAllowCreate(true); org.h2.tools.Console console = Server.createWebServer("", 8082, setup); console.start(); System.out.println("Opening database"); Server server = Server.createWebServer("-webAllowOthers").start(); return new DataSource() { @Override public Connection getConnection() throws SQLException { return DriverManager.getConnection(DB_URL, USER, PASSWORD); } }; } }使用H2数据库进行单元测试在单元测试中,可以通过JUnit或TestNG等框架获取到数据源,然后使用H2数据库进行数据的增删改查操作。例如:```java import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.test.context.junit4.SpringRunner; import org.junit.runner.RunWith; import javax.sql.DataSource; import java.util.ArrayList; import java.util.List; import static org.junit.Assert.; import static org.mockito.Mockito.; @RunWith(SpringRunner.class) @SpringBootTest(classes = {YourApplicationClass}) // 替换为你的应用类名 public class YourTestClass { // 替换为你的测试类名 @Autowired private JdbcTemplate jdbcTemplate; @Autowired private DataSource dataSource; // 这里注入上面创建的数据源对象即可获取到H2数据库实例 private List users = new ArrayList<>(); // 假设有一个User实体类和对应的repository接口用于操作数据库,这里只是示例数据结构而已。在实际项目中可能会有所不同。注意:此处仅为示例代码,具体实现需要根据实际情况进行调整。)
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签