注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

盘点那些国际知名黑客(上篇)

iOS
电影中的黑客仅靠一部电脑就可以窃取别人的信息,利用自己高超的技术让公司甚至国家都胆战心惊。“黑客”原指热心于计算机技术、水平高超的电脑高手,但逐渐区分为黑帽、白帽、灰帽。这些术语源自美国流行文化的老式西部电影,其中主角戴白色或浅色帽子,反派戴黑色帽子。黑帽黑客...
继续阅读 »

电影中的黑客仅靠一部电脑就可以窃取别人的信息,利用自己高超的技术让公司甚至国家都胆战心惊。“黑客”原指热心于计算机技术、水平高超的电脑高手,但逐渐区分为黑帽、白帽、灰帽。这些术语源自美国流行文化的老式西部电影,其中主角戴白色或浅色帽子,反派戴黑色帽子。

  • 黑帽黑客以“利欲”为目标,通过破解、入侵去获取不法利益或发泄负面情绪。
    • 灰帽黑客以“昭告”为目标,透过破解、入侵炫耀自己所拥有的高超技术。
    • 白帽黑客以“改善”为目标,破解某个程序作出修改,透过入侵去提醒设备的系统管理者其安全漏洞,有时甚至主动予以修补。


白帽黑客大多是电脑安全公司的雇员,抑或响应招测单位的悬赏,通常是在合法的情况下攻击某系统,而黑帽黑客同时也被称作“Cracker”(溃客),打着黑客的旗帜做不光彩的事情。接下来我们为大家介绍一下世界上非常厉害的顶级黑客。


“互联网之子”亚伦·斯沃茨 



2013 年 1 月 11 日,年仅26 岁的互联网奇才亚伦斯沃茨自杀身亡。他的一生都在为互联网的信息自由而努力。亚伦·斯沃茨 被称作计算机天才、互联网时代的普罗米修斯。但在这些光环的背后,是美国政府为他定下的 13 项重罪指控和最高 35 年的监禁。



1986年亚伦出生在一个程序员之家。3岁学会编程,12岁创建了一个知识共享网站,叫做 The info,功能和维基百科一样,但比维基百科早了 5 年。15岁参与制订了CC协议。18岁入学斯坦福,20岁辍学创业与Reddit项目的两位创始人合伙开公司,并创建了Reddit网站。 Reddit在当时的影响力不断扩大,成为最受欢迎的网站之一。后来,雅伦卖掉Reddit网站,赚了 100 万美元,在他 20 岁那年成为百万富翁。



亚伦参与构建了RSS,这是博客时代的工具,能让用户订阅自己感兴趣的博客,当订阅更新的时候,用户会收到邮件提醒。彼时的亚伦沉浸在互联网程序世界的理想主义美梦里,他希望自己能像他的偶像万维网的发明人蒂姆·博纳斯·李那样,让互联网回归自由、共享的初心。亚伦对赚钱并不感兴趣,他的梦想是追求一个更宏大的目标——互联网知识的自由和共享。



一次机会,亚纶了解到一个名为PACER的网站,它是一个存放法庭电子记录的系统,每看一页里面的内容,联邦政府需要收取 8 美分的管理费用。这项业务每年能带给政府超过 100亿美元的收入。亚纶认为,这些联邦法庭记录的材料本就属于公众,应当免费向公众开放。于是他编写了一个程序,抓取了超过 2000 万页的PACER资料,并将它们投放到公共资源网上,供大家免费阅读,这一举动相当于直接减少了美国司法系统200万美元的收入,PACER也在巨大的舆论压力下逐渐免费。



亚伦有一个“开放图书馆”的梦想,他认为实体的图书馆限制了知识的传播,而互联网是连接书籍、读者、作者、纸张与思想最好的载体。他在08年发表的《开放获取游击队宣言》中写道:信息就是力量,但就像所有力量一样,有些人只想将其占为己有。世界上大多数的期刊都被类似Elsevier、JSTOR这样的巨头垄断,每阅读一篇文献都需要支付一定数量的费用。亚伦想帮助更多的人平等地享受这些知识。于是他通过自己高超的黑客技术,利用麻省理工学院的校园网络免费端口从JSTOR下载了 480 万篇论文,相当于整个文献数据库的80% 。



亚伦毫无意外地被警察逮捕,但由于并未用论文牟利,JSTOR放弃了对他的指控。但马萨诸塞州检察长坚持起诉雅伦违反了1986年的计算机欺诈与滥用法。若罪名成立,亚伦将面临35年的监禁和100万美元的巨额罚款。亚伦拒绝认罪他选择与美国政府斗争。在这期间,他积极参与到各种推动知识共享的运动中,传播他关于知识共享的理念。



2012 年9月 12 日,联邦检察官提出了一份替换起诉书,增加了电子欺诈、非经授权访问计算机等罪名,从原来的 4 项重罪指控变成了 13 项。2013 年1月,雅伦在布鲁克林的公寓中上吊自杀,结束了自己的生命。这一年,他26岁。他死后,超过5万人在白宫网站上请愿,要是起诉亚伦的检察官辞职,维基百科以黑屏为他悼念。



亚伦认为知识共享能提高全人类的智慧,信息共享、言论自由才是真正的平等。在他死后,黑客入侵了麻省理工官网,抗议这个被视为黑客起源地的学府对于亚伦的无所作为。麻省理工的标题页被改为亚伦在2008 年写下的《开放获取游击队宣言》宣言中鼓励每一个网络用户行动起来,阻止商人与政客将网络私有化。



2013 年3月,亚伦被追授詹姆斯麦迪逊奖,用以表彰他捍卫公众的知情权所作出的贡献。


“世界头号黑客”凯文·米特尼克



凯文·米特尼克曾说:“巡游五角大楼,登录克里姆林宫,进出全球所有计算机系统,摧垮全球金融秩序和重建新的世界格局,谁也阻挡不了我们的进攻,我们才是世界的主宰。”



如果说谁的人生像小说一样精彩,那一定当属凯文·米特尼克。他出生于美国洛杉矶,是第一个被美国联邦调查局通缉的黑客,号称“世界头号黑客”。



20世纪80年代,他因多次入侵美国联邦调查局的中央电脑系统等而被逮捕三次。米特尼克的所作所为与人们所熟知的犯罪不同,他所做的一切似乎都不是为了钱,他曾破坏了40多家的安全系统,只是为了表明他“有能力做到”。



2000年,米特改邪归正,成为了一名白帽黑客,成功创办了米特尼克安全咨询公司,专门世界500强企业做网络咨询工作。2023年7月16日去世,享年59岁。


“C语言之父”丹尼斯·里奇



“丹尼斯·里奇一点也不家喻户晓,但是如果你有一台显微镜,能在电脑里看到他的作品,你会发现里面到处都是他的作品。”



丹尼斯·里奇(Dennis Ritchie)是美国计算机科学家,被称为“C语言之父”“Unix之父”。20世纪60年代,丹尼斯·里奇和肯·汤普逊参与了贝尔实验室Multics系统的开发。在开发期间,肯·汤普逊开发了游戏【空间旅行】,但当时的系统不给力,游戏运行速度很慢。



然而不久之后贝尔实验室撤出了Multics计划,里奇和汤普逊利用一台旧的迷你计算机Digital PDP-7,1969年的圣诞节Unix系统诞生了。最初的Unix内核使用B语言编写,为了更好开发Unix,1973年,里奇以B语言为基础发展出C语言,在它的主体设计完成后,他和汤普森又用它完全重写了Unix。



随着计算机的发展,编程语言层出不穷,但无论如何翻涌,都无法改变C语言在编程界德高望重的地位,C++、Java、C#都是在C语言的基础上衍生出来的。而如今诸多流行的操作系统也是在Unix的基础上开发的,如Linux、MacOS甚至最流行的手机系统Android。


丹尼斯·里奇发明的C语言联合Unix操作系统,构建了当代计算机世界的钢筋水泥。正是因为C语言和Unix系统这两项成就,里奇成为了许多编程爱好者膜拜的对象。


“Linux之父”林纳斯·托瓦兹



“Given enough eyeballs,all bugs are shallow.”【很多双眼睛盯着的代码,bug无处藏身】


1991年Linus开发了Linux操作系统,在最初几年里,Linux并没有得到太多关注。但随着互联网的普及,如今的linux已经成为全球最受欢迎的操作系统之一,被广泛应用于服务器、移动设备、家庭电脑和超级计算机等领域。



Linux的诞生充满了偶然,林纳斯经常用他的终端仿真器去访问大学主机上的新闻组和邮件,为了方便读写和下载文件,他自己编写了磁盘驱动程序和文件系统。这些在后来成为了Linux第一个内核的雏形,那时的他年仅21岁。



我们能够看到如今日渐壮大的Linux,但也不难发现,在成功的Linux背后,有着几十年如一日的持之以恒,有着对高质量代码的坚持,更是有着合作的。林纳斯没有建立组织,仅仅通过吸引全球数以万计的自由开发者免费贡献就完成了项目。Linux不仅仅是一个代码项目,也是一种互联网出现以后的新的协作方式——开源模式。


写在最后


现在国家很重视网络安全建设,网络安全已经成为了很多高校的一级学科,因此通过正常学习即可进入网络安全行业,大家一定要遵纪守法,效仿黑客们的行为做一些非法的黑客攻击行为,下期我们将继续为大家送上其他几位世界著名黑客的传奇故事,请大家保持关注哦。


作者:禅道程序猿
链接:https://juejin.cn/post/7273125596951478284
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

智能门锁临时密码的简单实现~~

引子 话说新房子装修,安装了遥遥领先智能门锁PRO,最近到了家具进场的阶段。 某日,接到一通电话:“哥,你现在家里有人吗?你的书桌到了。” 原来是快递小哥,我回复他:“家里没人,但是有智能锁,嗯,因为临时密码有时间限制,等下到了再给我回下电话,我把临时密码给你...
继续阅读 »

引子


话说新房子装修,安装了遥遥领先智能门锁PRO,最近到了家具进场的阶段。


某日,接到一通电话:“哥,你现在家里有人吗?你的书桌到了。”


原来是快递小哥,我回复他:“家里没人,但是有智能锁,嗯,因为临时密码有时间限制,等下到了再给我回下电话,我把临时密码给你。”


“好嘞,那到时候联系”


挂断电话,我随手打开手机上的花粉生活APP,但是感觉有点不对劲,我去,设备咋都离线了(后来发现是网络欠费)?我顿时虎躯一震,脑海中浮现了快递小哥到了后发现自己白跑一趟,带着满头大汗、气喘吁吁并且嘴里一顿C语言输出的尴尬场景...


但是我惊喜的发现,门锁的卡片虽然离线但还可以正常进入,我抱着试一试的心态点进去,临时密码竟然可以正常生成,真牛!


于是我点击了生成临时密码...


电话又响起:“哥我到了,把密码给我吧”


我将临时密码给小哥开了门,一切顺利...




实现


这是前段时间亲身经历的一件事,原本以为智能门锁临时密码的功能需要网络支持,服务器生成临时密码给用户,同时下发到门锁里面。现在发现,并不需要门锁联网也可以执行密码验证的操作。
脑海中思考了下,临时密码离线验证这个功能可能是类似这样实现的:



  • 门锁端和服务器端采用相同的规则生成临时密码,并且密码生成规则里面包含了时间这个因素

  • 用户请求临时密码,服务端按照规则生成临时密码返回给用户

  • 用户输入临时密码解锁,门锁按照同样的规则进行校验
    以上实现是一个直觉性的思考,实际编码落地根据不同的需求会有更多的考虑,以我在使用的遥遥领先牌智能门锁Pro为例,下面来做一个简单的实现...


首先,让来看看这款门锁的临时密码有哪些限制条件:


limit12.png


lim22.png


限制条件有:



  • 单个密码有效期为30分钟

  • 有效期内只能使用一次

  • 一分钟内只能添加一个临时密码


根据这些限制条件和前面的思考,密码生成规则可以这样设置:



  • 拼接产品序列号+当前时间字符串,获取拼接后字符串的hashcode,然后对1000000(百万)取余,得到6位数字作为临时密码。并且时间字符串按照yyyy-MM-dd HH:mm 格式,精确到分钟

  • 加入产品序列号的原因是为了让不同门锁在相同时间产生不同的密码,如果只以时间为变量肯定是不安全的

  • 由于门锁生成的限制条件里面约定了一分钟只能添加一个临时密码,因此时间变量也精确到分钟,保证每分钟的临时密码不同,分钟内相同。


然后是实现思路:



  • 用户请求服务端,服务端根据密码生成规则返回一个临时密码

  • 快递小哥拿着临时密码在门锁现场输入

  • 门锁按照临时密码输入的时间点,计算时间点前30分内每一分钟对应的密码,30分钟对应30个临时密码。为什么是30分钟?因为密码30分钟内有效

  • 门锁将快递小哥输入的密码与生成的30个密码进行一一比对,如果有匹配的密码,说明临时密码有效

  • 将输入的临时密码缓存,每次输入密码时都要去缓存里面判断临时密码是否在30分钟内使用过,如果使用过就不能开锁。为什么要判断是否30分钟内使用过?因为有效期内只能使用一次




有了以上思路,下面代码的编写工作就比较简单了,开整...


首先创建三个类:OtherTerminal、SmartLock、PasswordUtils 分别,表示其他可获取密码的终端、门锁以及跟密码相关的工具类


首先是OtherTerminal类,相当于可获取密码的终端,例如我们的手机或者平板,主要功能是调用PasswordUtils工具类根据门锁的序列号和当前时间来获取有效临时密码。



public class OtherTerminal {
private final static String serialNumber = "XiaoHuaSmartLock001";
public static void main(String[] args) {
System.out.println("当前开锁密码:"+PasswordUtils.generate(serialNumber, PasswordUtils.localDateTimeToStr(LocalDateTime.now())));
}
}


接着是SmartLock类


SmartLock的main方法里面等待控制台的输入,并对输入的密码进行验证。验证调用了verify方法。


verify方法的执行逻辑:调用PasswordUtils工具类,获取过去30分钟内每分钟对应的临时密码,判断输入的密码是否在这些临时密码当中。如果存在说明临时密码有效,还需对当前密码在过去30分钟内是否使用进行判断,保证密码只能使用一次。这个判断是通过调用PasswordUtils工具类的getAndSet方法实现的。


如果认证成功,则开锁。否则开锁失败。


// 智能门锁
public class SmartLock {

private final static String serialNumber = "XiaoHuaSmartLock001";
private final static Integer expirationTime = 30;


public static void main(String[] args) {
// 步骤:首先生成过去30分钟内的所有数字

Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
int password = scanner.nextInt();
if (verify(password)) {
System.out.println("开锁成功,当前时间:" + LocalDateTime.now());
} else {
System.out.println("开锁失败,当前时间:" + LocalDateTime.now());
}
}
scanner.close();

}

private static boolean verify(Integer inputPassword) {
// 获取当前时间点以前30分钟内的所有密码
LocalDateTime now = LocalDateTime.now();
LocalDateTime validityPeriod = now.minusMinutes(expirationTime);
List<Integer> validityPeriodPasswords = new ArrayList<>();

while (validityPeriod.isBefore(now.plusMinutes(1L))) {
validityPeriodPasswords.add(PasswordUtils.generate(serialNumber, PasswordUtils.localDateTimeToStr(validityPeriod)));
validityPeriod = validityPeriod.plusMinutes(1L);
}
System.out.println(validityPeriodPasswords);
return validityPeriodPasswords.contains(inputPassword) && PasswordUtils.getAndSet(inputPassword);
}
}

再来看下PasswordUtils工具类,这个类内容较多,分步解释:
首先是生成6位临时密码的generate方法,比较简单。但是这样生成的密码不能以0开头,是缺点!


/**
* 生成一个密码
*
* @return 返回一个六位正整数
*/

public static Integer generate(String serialNumber, String time) {
String toHash = time + serialNumber;
return Math.abs(toHash.hashCode() % 1000000);
}

接着是一个格式化时间的方法,将时间格式化为:yyyy-MM-dd HH:mm。精确到分钟,generate方法的第二个参数time需要调用此方法来保证时间以分钟为单位,这样分钟内生成的密码都是相同的


public static String localDateTimeToStr(LocalDateTime localDateTime) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
return formatter.format(localDateTime);
}

最后是门锁对临时密码的管理:



  • 临时密码存储在一个map对象中:usedPasswordMap

  • 有一个标记对象clearTag用于标记是否应当对usedPasswordMap进行清理操作,用于清理已过期的临时密码

  • 临时密码存在时间大于30分钟,判断为已过期


下面是临时密码过期判断和过期清理的方法


/**
* @param current 当前时间
* @param compare 比较时间
* @return 是否过期
*/

private static boolean expired(long current, long compare) {
Instant endInstant = Instant.ofEpochMilli(current);
LocalDateTime end = LocalDateTime.ofInstant(endInstant, ZoneId.systemDefault());
Instant beginInstant = Instant.ofEpochMilli(compare);
LocalDateTime begin = LocalDateTime.ofInstant(beginInstant, ZoneId.systemDefault());

Duration duration = Duration.between(begin, end);
long actualInterval = switch (PasswordUtils.expirationUnit) {
case SECONDS -> duration.toSeconds();
case MINUTES -> duration.toMinutes();
case HOURS -> duration.toHours();
case DAYS -> duration.toDays();
default -> throw new IllegalArgumentException("输入时间类型不支持");
};
return actualInterval >= (long) PasswordUtils.expirationTime;
}

/**
* 清理过期的密码
*/

private static void clearExpired() {
Iterator<Map.Entry<Integer, Long>> iterator = usedPasswordMap.entrySet().iterator();
Long currentTimestamp = System.currentTimeMillis();
while (iterator.hasNext()) {
Map.Entry<Integer, Long> item = iterator.next();
if (expired(currentTimestamp, item.getValue())) {
iterator.remove();
}
}
}

getAndSet方法:



  • 首先判断是否达到了清理阈值,从而执行是否清理的操作,用于节省资源消耗

  • 从usedPasswordMap中获取当前输入密码是否存在,如果不存在说明密码未使用过,则将当前密码设置到map里面并返回true,否则还要进行进一步的判断,因为可能存在历史密码但是已过期和当前密码重复的情况

  • 若usedPasswordMap中存在当前密码,调用expired方法,如果历史密码过期了说明当前密码有效,并刷新时间戳,否则说明有效期内当前密码已经使用过一次


/**
*
* @param password
* @return false说明密码已经使用过,true则表示密码可以使用
*/

public static boolean getAndSet(Integer password) {
// usedPasswordMap存储的过期密码可能会越来越多,需要定期清理
if (clearTag > clearThreshold) {
if (!usedPasswordMap.isEmpty()) {
clearExpired();
}
clearTag = 0;
}
clearTag++;
Long usedPasswordTimestamp = usedPasswordMap.get(password);
Long currentTimestamp = System.currentTimeMillis();
if (ObjectUtils.isEmpty(usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
return true;
}
// 到了这里说明密码已经使用过(有效期内,或之前),若使用时间距今在有效期内,说明当期已经使用过,否则是以前使用的
if (expired(currentTimestamp, usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
System.out.println("密码虽然已使用,但为历史使用,因此当前密码有效");
return true;
}
System.out.println("密码有效期内已使用一次");
return false;
}



验证


我将门锁程序部署到我的服务器上面,并运行。随便输入一个数字,例如123456,返回开锁失败。


image.png


然后本地运行OtherTerminal类获取临时密码:974971


image.png
再去门锁上验证试试:开锁成功!


image.png


最后完整的PasswordUtil工具类的代码贴在这里:


// 密码工具类

public class PasswordUtils {
private static Map<Integer, Long> usedPasswordMap = new HashMap<>();
private final static Integer expirationTime = 30;
private final static TimeUnit expirationUnit = TimeUnit.MINUTES;
private final static Integer clearThreshold = 30;
private static Integer clearTag = 0;

/**
* 获取code状态,并设置到使用code里面
*
* @param password
* @return false说明密码已经使用过,true则表示密码可以使用
*/

public static boolean getAndSet(Integer password) {
// usedPasswordMap存储的过期密码可能会越来越多,需要定期清理
if (clearTag > clearThreshold) {
if (!usedPasswordMap.isEmpty()) {
clearExpired();
}
clearTag = 0;
}
clearTag++;
Long usedPasswordTimestamp = usedPasswordMap.get(password);
Long currentTimestamp = System.currentTimeMillis();
if (ObjectUtils.isEmpty(usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
return true;
}
// 到了这里说明密码已经使用过(有效期内,或之前),若使用时间距今在有效期内,说明当期已经使用过,否则是以前使用的
if (expired(currentTimestamp, usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
System.out.println("密码虽然已使用,但为历史使用,因此当前密码有效");
return true;
}
System.out.println("密码有效期内已使用一次");
return false;
}


/**
* @param current 当前时间
* @param compare 比较时间
* @return 是否过期
*/

private static boolean expired(long current, long compare) {
Instant endInstant = Instant.ofEpochMilli(current);
LocalDateTime end = LocalDateTime.ofInstant(endInstant, ZoneId.systemDefault());
Instant beginInstant = Instant.ofEpochMilli(compare);
LocalDateTime begin = LocalDateTime.ofInstant(beginInstant, ZoneId.systemDefault());

Duration duration = Duration.between(begin, end);
long actualInterval;
switch (PasswordUtils.expirationUnit) {
case SECONDS:
actualInterval = duration.toSeconds();
break;
case MINUTES:
actualInterval = duration.toMinutes();
break;
case HOURS:
actualInterval = duration.toHours();
break;
case DAYS:
actualInterval = duration.toDays();
break;
default:
throw new IllegalArgumentException("输入时间类型不支持");
}
return actualInterval >= (long) PasswordUtils.expirationTime;
}

/**
* 清理过期的密码
*/

private static void clearExpired() {
Iterator<Map.Entry<Integer, Long>> iterator = usedPasswordMap.entrySet().iterator();
Long currentTimestamp = System.currentTimeMillis();
while (iterator.hasNext()) {
Map.Entry<Integer, Long> item = iterator.next();
if (expired(currentTimestamp, item.getValue())) {
iterator.remove();
}
}
}

/**
* 生成一个密码
*
* @return 返回一个六位正整数
*/

public static Integer generate(String serialNumber, String time) {
String toHash = time + serialNumber;
return Math.abs(toHash.hashCode() % 1000000);
}

public static String localDateTimeToStr(LocalDateTime localDateTime) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
return formatter.format(localDateTime);
}

}

最后的最后,这种方法生成的密码有个bug,就是30分钟内生成的30个密码里面会有重复的可能性,不过想来发生概率很低,看后续如何优化了。


作者:持剑的青年
来源:juejin.cn/post/7280459667129188387
收起阅读 »

外甥女问我什么是代码洁癖,我是这么回答的...

1. 引言 哈喽,大家好,我是小 ❤,一个在二进制世界起舞的探险家,幻想有一天可以将代码作诗的后台开发。 今天,我要和大家聊聊程序员的神秘技能——重构!别担心,我会用通俗易懂的语言和一些趣味对话来帮助你理解和掌握这个技能,我 8 岁的外甥女听了都说懂。 1.1...
继续阅读 »

1. 引言


哈喽,大家好,我是小 ❤,一个在二进制世界起舞的探险家,幻想有一天可以将代码作诗的后台开发。


今天,我要和大家聊聊程序员的神秘技能——重构!别担心,我会用通俗易懂的语言和一些趣味对话来帮助你理解和掌握这个技能,我 8 岁的外甥女听了都说懂。


1.1 背景


代码开发:



一个月后:



后面有时间了改一改吧(放心,不会有时间的,有时间了也不会改)。


六个月后:



如上,是任何一个开发者都会经历的场景:早期的代码根本不能回顾,不然一定会陷入深深的怀疑,这么烂的代码真是出自自己的手吗?


更何况,目前大部分系统都是协同开发,每个程序员的命名规范、编码习惯都不尽相同,就导致了一个系统代码,多个味道的情况。


重构是什么


妍妍:嘿,舅舅,听说你要分享重构,这又是什么新鲜事?



❤:嗨,妍妍!重构就是改进既有代码的设计,让它更好懂、更容易维护,而不改变它的功能。想象一下,它就像是给代码来了个变美的化妆术,但内在还是那个代码,不会变成"不认识的人"。


为什么要重构


露露:哇,听起来好厉害,那为什么我们要重构呢?



❤:哈哈,好问题,露露!因为代码是活的,一天天在变大,当代码变得难以理解、难以修改时,它就像是一头头重的大象,拖慢了我们前进的步伐。重构就像是给大象减肥,使它更轻盈、更灵活,开发速度也能提升不少!


这和你们有小洁癖,爱收拾房间一样,有代码洁癖的程序员也会经常重构 Ta 们的代码呢!


什么时候要重构


妍妍:听起来有道理,但什么时候才应该使用重构呢?



❤:好问题,妍妍!有以下几种情况:




  • 当你看到代码中有好几处长得一模一样的代码,这时候可以考虑把它们合并成一个,减少冗余。




  • 当你的函数或方法看上去比词典还厚重时,可以把它拆成一些小的部分,更好地理解。




  • 当你要修复一个 bug,但却发现原来的代码结构太复杂,修复变得像解迷一样难时,先重构再修复就是个好主意。




  • 当你要添加新功能,但代码不让你轻松扩展时,也可以先重构,然后再扩展。




重构的步骤


露露:明白了舅舅,那重构的具体步骤是什么呢?



❤:问得好,露露,看来你有认真在思考!接下来让我给你介绍一下重构的基本步骤吧!


2. 如何重构


重构之前,我们需要识别出代码里面的坏味道代码。


所谓坏味道,就是指代码的表面的混乱,和深层次的腐化现象。简单来说,就是感觉不太对劲的代码。


2.1 坏味道代码



在《重构-改善既有代码的设计》一书中,讲述了这二十多种坏味道情况,我们下面将挑选最常见的几种来介绍。


1)方法过长


方法过长是指在一个方法里面做了太多的工作,常常伴随着方法中的语句不在同一个抽象层级,比如 dto 和 service 层代码混合在一起,即逻辑分散。


除此之外,方法过长还容易带来一些额外的问题。


问题1:过多的注释


方法太长会导致逻辑难以理解,需要大量的注释,如果 10 行代码需要 20 行注释,代码很难阅读。特别是读代码的时候,常常需要记住大量的上下文。


问题2:面向过程


面向过程的问题在于当逻辑复杂以后,代码会很难维护。


相反地,我们在代码开发时常常用面向对象的设计思想,即把事物抽象成具有共同特征的对象。


解决思路


解决方法过长时,我们遵循这样一条原则:每当感觉要写注释来说明代码时,就把这部分代码写进一个独立的方法里,并根据这段代码的意图来命名。



方法命名原则:可以概括要做的事,而非怎么做。



2)过大的类


一个类做了太多的事情,比如一个类的实现既包含商品逻辑,又包含订单逻辑。在创建时就会出现太多的实例变量和方法,难以管理。


除此之外,过大的类还容易带来两个问题。


问题1:冗余重复


当一个类里面包含两个模块的逻辑时,两个模块容易产生依赖。这在代码编写的过程中,很容易发生 “你带着我,我看着你” 的问题。


即在两个模块中,都看到了和另一个模块相关的程序结构或相同意图的方法。


问题2:耦合结构不良


当类的命名不足以描述所做的事情时,大概率产生了耦合结构不良的问题,这和我们想要编写 “高内聚,低耦合” 的代码目标相悖而行了。


解决思路


将大类根据业务逻辑拆分成小类,如果两个类之间有依赖,则通过外键等方式关联。当出现重复代码时,尽量合并提出来,程序会变得更简洁可维护。


3)逻辑分散


逻辑分散是由于代码架构层次或者对象层次上有不合理的依赖,通常会导致两个问题:


发散式变化


某个类经常因为不同的原因,在不同的方向上修改。


散弹式修改


发生某种变化时,需要多个类中修改。


4)其它坏味道


数据泥团


数据泥团是指很多数据项混乱地融合在一起,不易复用和扩展。


当许多数据项总是一起出现,并且一起出现时更容易分类。我们就可以考虑将数据按业务封装成数据对象。反例如下:


func AddUser(age int, gender, firstName, lastName string) {}

重构之后:


type AddUserRequest struct {
   Age int
   Gender string
   FirstName string
   LastName string
}
func AddUser(req AddUserRequest) {}

基本类型偏执


在大多数高级编程语言里面,都有基本类型和结构类型。在 Go 语言里面,基本类型就是 int、string、bool 等。


基本类型偏执是指我们在定义对象的变量时,常常不考虑变量的实际业务含义,直接使用基本类型。


反例如下:


type QueryMessage struct {
Role        int         `json:"role"`
Content  string    `json:"content"`
}

重构之后:


// 定义对话角色类型
type MessageRole int

const (
HUMAN     MessageRole = 0
ASSISTANT MessageRole = 1
)

type QueryMessage struct {
Role        MessageRole   `json:"role"`
Content  string               `json:"content"`
}

这是 ChatGPT 问答时的请求字段,我们可以看到对话角色为 int 类型,且 0 表示人类,1 表示聊天助手。


当直接使用 int 来表示对话 Role 时,没办法直接从定义里知道更多信息。


但是用 type MessageRole int 定义后,我们就可以根据常量值很清晰地看出对话角色分为两种:HUMAN & ASSISTANT.


混乱的代码层次调用


我们一般的系统都会根据业务 service、中转控制 controller 和数据库访问 dao 等进行分层。一般 controller 调用 service,service 调用 dao。


如果我们在 controller 直接调用 dao,或者 dao 调用 controller,就会出现层次混乱的问题,就可以进行优化了。


5)坏味道带来的问题


妍妍:舅舅,这些坏味道都需要解决吗,你说的这些坏味道代码会带来什么样的影响呢?


❤:是的,代码里如果坏味道代码太多,会带来四个 “难以”



  • 难以理解:新来的开发同学压根看不懂看人的代码,一个模块看了两个周还不知道啥意思。或许不是开发者的水平不够,可能是代码写的太一言难尽。



  • 难以复用:要么是读都读不懂,或者勉强读懂了却不敢用,担心有什么暗坑。或者系统耦合性严重,难以分离可重用部分。



  • 难以变化:牵一发而动全身,即散弹式修改。动了一处代码,整个模块都快没了。




  • 难以测试:改了不好测,难以进行功能验证。命名杂乱,结构混乱,在测试时可能测出新的问题。




3. 重构技巧


露露:哦,原来是这样啊,那我们可以去除它们吗?


❤:当然可以了!就像你们爱收拾房间一样,每一个有责任心(代码洁癖)的程序员,都会考虑代码重构。


而对于重构问题,业界已经有比较好的思路:通过持续不断地重构将代码中的 "坏味道" 清除掉。


1)命名规范


一个好的命名规范应该符合:



  • 精准描述所做的事情

  • 格式符合通用惯例


约定俗成的惯例


我们拿华为公司内部的 Go 语言的开发规范来举例:


场景约束示例
项目名全部小写,多个单词时用中划线 '-' 分隔user-order
包名全部小写,多个单词时用中划线 '-' 分隔config-sit
结构体名首字母大写Student
接口采用 Restful API 的命名方式,路径最后一部分是资源名词如 [get] api/v1/student
常量名首字母大写,驼峰命名CacheExpiredTime
变量名首字母小写,驼峰命名userName,password

2)重构手法


妍妍:哇,这么多成熟的规范可以用啊!那除了规范,我们还需要注意什么吗?


❤:好问题妍妍!接下来我还会介绍一些常见的重构手法:




  • 提取函数:将一个长长的函数分成小块,更容易理解和复用。




  • 改名字:给变量、函数、类等改个名字,更有意义。




  • 消除冗余:找到相似的代码块,合并它们,减少重复。




  • 搬家:把函数或字段移到更合适的地方,让代码更井然有序。




  • 抽象通用类:把通用功能抽出来,变成一个类,增加代码的可重用性。




  • 引入参数对象:当变量过多时,传入对象,消除数据泥团。




  • 使用卫语句:减少 else 的使用,让代码结构更加清晰。




4. 小结


露露:舅舅,你讲得太有趣了,我感觉我也会重构了!


❤:露露真棒,我相信你!重构的思想无处不在,就像生活中都应该留白一样,你们的人生也会非常精彩的。在编程里,重构可以让代码更美观、更容易读懂,提高开发效率,是程序员都应该掌握的技能。


妍妍:我也会了,我也会了!以后我也要写代码,做代码重构,我还要给舅舅的文章点赞。



❤:哈哈哈,好哒,你们都很棒!就像你们喜欢打扫卫生,爱好画画读诗一样,如果以后你们想写代码,它们也会十分的干净整洁,充满诗情画意。



最后,如果你觉得有所收获,别忘了点赞和在看,让更多的人了解重构的神奇之处,一起进步,一起写出更好的代码!


希望这篇文章对你有所帮助,也希望你能在编程的路上越走越远。感谢大家的支持,我们下次再见!🚀✨


最后


妍妍说:看完的你还不赶紧分享、点赞、加入在看吗?



作者:xin猿意码
来源:juejin.cn/post/7277836718760771636
收起阅读 »

全方位对比 Postgres 和 MySQL (2023 版)

根据 2023 年 Stack Overflow 调研,Postgres 已经取代 MySQL 成为最受敬仰和渴望的数据库。 随着 Postgres 的发展势头愈发强劲,在 Postgres 和 MySQL 之间做选择变得更难了。 如果看安装数量,MySQL...
继续阅读 »

根据 2023 年 Stack Overflow 调研,Postgres 已经取代 MySQL 成为最受敬仰和渴望的数据库。




随着 Postgres 的发展势头愈发强劲,在 Postgres 和 MySQL 之间做选择变得更难了。


如果看安装数量,MySQL 可能仍是全球最大的开源数据库。




Postgres 则自诩为全球最先进的开源关系型数据库。




因为需要与各种数据库及其衍生产品集成,Bytebase 和各种数据库密切合作,而托管 MySQL 和 Postgres 最大的云服务之一 Google Cloud SQL 也是 Bytebase 创始人的杰作之一。


我们对 Postgres 和 MySQL 在以下几个维度进行了比较:


  • 许可证 License
  • 性能 Performance
  • 功能 Features
  • 可扩展性 Extensibility
  • 易用性 Usability
  • 连接模型 Connection Model
  • 生态 Ecosystem
  • 可运维性 Operability



除非另有说明,下文基于最新的主要版本 Postgres 15 和 MySQL 8.0 (使用 InnoDB)。在文章中,我们使用 Postgres 而不是 PostgreSQL,尽管 PostgreSQL 才是官方名称,但被认为是一个错误的决定




许可证 License


  • MySQL 社区版采用 GPL 许可证。
  • Postgres 发布在 PostgreSQL 许可下,是一种类似于 BSD 或 MIT 的自由开源许可。

即便 MySQL 采用了 GPL,仍有人担心 MySQL 归 Oracle 所有,这也是为什么 MariaDB 从 MySQL 分叉出来。


性能 Performance


对于大多数工作负载来说,Postgres 和 MySQL 的性能相当,最多只有 30% 的差异。无论选择哪个数据库,如果查询缺少索引,则可能导致 x10 ~ x1000 的降级。
话虽如此,在极端的写入密集型工作负载方面,MySQL 确实比 Postgres 更具优势。可以参考下文了解更多:



除非你的业务达到了 Uber 的规模,否则纯粹的数据库性能不是决定因素。像 Instagram, Notion 这样的公司也能够在超大规模下使用 Postgres。


功能 Features


对象层次结构


MySQL 采用了 4 级结构:


  1. 实例
  2. 数据库

Postgres 采用了 5 级结构:


  • 实例(也称为集群)
  • 数据库
  • 模式 Schema

ACID 事务


两个数据库都支持 ACID 事务,Postgres 提供更强大的事务支持。




安全性


Postgres 和 MySQL 都支持 RBAC。


Postgres 支持开箱即用的附加行级安全 (RLS),而 MySQL 需要创建额外的视图来模拟此行为。


查询优化器


Postgres 的查询优化器更优秀,详情参考此吐槽


复制


Postgres 的标准复制使用 WAL 进行物理复制。MySQL 的标准复制使用 binlog 进行逻辑复制。


Postgres 也支持通过其发布/订阅模式进行逻辑复制。


JSON


Postgres 和 MySQL 都支持 JSON。 Postgres 支持的功能更多:


  • 更多操作符来访问 JSON 功能。
  • 允许在 JSON 字段上创建索引。

CTE (Common Table Expression)


Postgres 对 CTE 的支持更全面:


  • 在 CTE 内进行 SELECT, UPDATE, INSERT, DELETE 操作
  • 在 CTE 之后进行 SELECT, UPDATE, INSERT, DELETE 操作

MySQL 支持:


  • 在 CTE 内进行 SELECT 操作
  • 在 CTE 之后进行 SELECT, UPDATE, DELETE 操作

窗口函数 (Window Functions)


窗口帧类型:MySQL 仅支持 Row Frame 类型,允许定义由固定数量行组成的帧;而 Postgres 同时支持 Row Frame 和范围帧类型。


范围单位:MySQL 仅支持 UNBOUNDED PRECEDING 和 CURRENT ROW 这两种范围单位;而 Postgres 支持更多范围单位,包括 UNBOUNDED FOLLOWING 和 BETWEEN 等。


性能:一般来说,Postgres 实现的 Window Functions 比 MySQL 实现更高效且性能更好。


高级函数:Postgres 还支持更多高级 Window Functions,例如 LAG(), LEAD(), FIRST_VALUE(), and LAST_VALUE()。


可扩展性 Extensibility


Postgres 支持多种扩展。最出色的是 PostGIS,它为 Postgres 带来了地理空间能力。此外,还有 Foreign Data Wrapper (FDW),支持查询其他数据系统,pg_stat_statements 用于跟踪规划和执行统计信息,pgvector 用于进行 AI 应用的向量搜索。


MySQL 具有可插拔的存储引擎架构,并诞生了 InnoDB。但如今,在 MySQL 中,InnoDB 已成为主导存储引擎,因此可插拔架构只作为 API 边界使用,而不是用于扩展目的。


在认证方面,Postgres 和 MySQL 都支持可插拔认证模块 (PAM)。


易用性 Usability


Postgres 更加严格,而 MySQL 更加宽容:


  • MySQL 允许在使用 GROUP BY 子句的 SELECT 语句中包含非聚合列;而 Postgres 则不允许。
  • MySQL 默认情况下是大小写不敏感的;而 Postgres 默认情况下是大小写敏感的。
  • MySQL 允许 JOIN 来自不同数据库的表;而 Postgres 只能连接单个数据库内部的表,除非使用 FDW 扩展。

连接模型 Connection Model


Postgres 采用在每个连接上生成一个新进程的方式工作。而 MySQL 则在每个连接上生成一个新线程。因此,Postgres 提供了更好的隔离性,例如,一个无效的内存访问错误只会导致单个进程崩溃,而不是整个数据库服务器。另一方面,进程模型消耗更多资源。因此,在部署 Postgres 时建议通过连接池(如 PgBouncer 或 pgcat)代理连接。


生态 Ecosystem


常见的 SQL 工具都能很好地支持 Postgres 和 MySQL。由于 Postgres 的可扩展架构,并且仍被社区拥有,近年来 Postgres 生态系统更加繁荣。对于提供托管数据库服务的应用平台,每个都选择了 Postgres。从早期的 Heroku 到更新的 Supabase, render 和 Fly.io。


可运维性 Operability


由于底层存储引擎设计问题,在高负载下,Postgres 存在臭名昭著的 XID wraparound 问题。


对于 MySQL,在 Google Cloud 运营大规模 MySQL 集群时,我们遇到过一些复制错误。


这些问题只会在极端负载下发生。对于正常工作负载而言,无论是 Postgres 还是 MySQL 都是成熟且可靠的。数据库托管平台也提供集成备份/恢复和监控功能。


Postgres 还是 MySQL


2023 年了,在 Postgres 和 MySQL 之间做选择仍然很困难,并且经常引起激烈讨论




总的来说,Postgres 有更多功能、更繁荣的社区和生态;而 MySQL 则更易学习并且拥有庞大的用户群体。
我们观察到与 Stack Overflow 结果相同的行业趋势,即 Postgres 在开发者中变得越来越受欢迎。但根据我们的实际体验,精密的 Postgres 牺牲了一些便利性。如果你对 Postgres 不太熟悉,最好从云服务提供商那里启动一个实例,并运行几个查询来上手。有时候,这些额外好处可能并不值得,选择 MySQL 会更容易一些。


同时,在一个组织内部共存 Postgres 和 MySQL 也是很常见的情况。如果需要同时管理 Postgres 和 MySQL 的开发生命周期,可以来了解一下 Bytebase。






💡 你可以访问官网,免费注册云账号,立即体验 Bytebase。


作者:Bytebase
链接:https://juejin.cn/post/7254944931386228796
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

听说你会架构设计?来,解释一下为什么错不在李佳琦

1. 引言 大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1.1 带货风波 近几天,“带货一哥” 李佳琦直播事件闹得沸沸扬扬,稳占各大新闻榜单前 10 名。 图来源:微博热点,侵删 虽然小❤...
继续阅读 »

1. 引言


大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1.1 带货风波


近几天,“带货一哥” 李佳琦直播事件闹得沸沸扬扬,稳占各大新闻榜单前 10 名。



图来源:微博热点,侵删


虽然小❤平时很少看直播,尤其是带货直播。


但奈何不住吃瓜的好奇心重啊!于是就趁着休息的功夫了解了一下,原来这场风波事件起源于前几天的一场直播。


当时,李佳琦在直播间介绍合作产品 “花西子” 眉笔的价格为 79 元时,有网友在评论区吐槽越来越贵了。他直言:哪里贵了?这么多年都是这个价格,不要睁着眼睛乱说,国货品牌很难的,哪里贵了?



图来源:网络,侵删


之后,李佳琦接着表示:有的时候找找自己原因,这么多年了工资涨没涨,有没有认真工作?



图来源:互联网,侵删


小❤觉得,这件事评论区网友说的没错,吐槽一下商品的价格有什么问题呢?我自己平时买菜还挑挑拣拣的,能省一毛是一毛。


毕竟,这个商品的价格也摆在那是不?



图来源:微博热点,侵删


1.2 身份决定立场,立场决定言论


但是,有一说一,从主播的角度呢,我也能理解。毕竟,不同的消费能力,说着自己立场里认可的大实话,也没啥问题。


那问题出在哪呢?


咳咳,两边都没问题,那肯定是评论系统有问题!


一边是年收入十多亿的带货主播,一边是普普通通的老百姓,你评论区为啥不甄别出用户画像,再隔离一下评论?


俗话说:“屁股决定脑袋”,立场不同,言论自然不一样。所以,这个锅,评论系统背定了!


2. 评论系统的特点


正巧,前几天在看关于评论系统的设计方案,且这类架构设计在互联网大厂的面试里出现的频率还是挺高的。所以我们今天就来探讨一下这个热门话题——《海量评论系统的架构设计》。


2.1 需求分析


首先,让我们来了解一下评论系统的特点和主要功能需求。评论系统是网站和应用中不可或缺的一部分,主要分为两种:



  • 一种是列表平铺式,只能发起评论,不能回复;

  • 一种是盖楼式评论,支持无限盖楼回复,可以回复用户的评论。


为了迎合目前大部分网站和应用 App 的需求,我们设计的评论系统采用盖楼式评论


需要满足以下几个功能需求:



评论系统中的观众和主播相当于用户和管理员的角色,其中观众用户可以:



  • 评论发布和回复:用户可以轻松发布评论,回复他人的评论。

  • 点赞和踩:用户可以给评论点赞或踩,以表达自己的喜好。

  • 评论拉取:评论需要按照时间或热度排序,并且支持分页显示。


主播可以:




  • 管理评论:主播可以根据直播情况以及当前一段时间内的总评论数,来判断是否打开 “喜好开关”。




  • 禁言用户:当用户发布了不当言论,或者恶意引流时,主播可以禁言用户一段时间。




  • 举报/删除:系统需要支持主播举报不当评论,并允许主播删除用户的评论。




2.2 非功能需求


除了功能需求,评论系统还需要满足一系列非功能需求,例如应对高并发场景,在海量数据中如何保证系统的稳定运行是一个巨大的挑战。




  • 海量数据:拿抖音直播举例,10 亿级别的用户量,日活约 2 亿,假设平均每 10 个人/天发一条评论,总评论数约 2 千万/天;




  • 高并发量:每秒十万级的 QPS 访问,每秒万级的评论发布量;




  • 用户分布不均匀:某个直播间的用户或者评论区数量,超出普通用户几个数量级;




  • 时间分布不均匀:某个主播可能突然在某个时间点成为热点用户,其评论数量也可能陡增几个数量级。




3. 系统设计


评论系统也具有一个典型社交类系统的特征,可归结为三点:海量数据,高访问量,非均匀性,接下来我们将对评论系统的关键特点和需求做功能设计。


3.1 功能设计


在直播平台或评论系统里,观众可以接收开通提醒,并且评论被回复之后也可以通过手机 App 收到回复消息,所以需要和系统建立 TCP 长连接。


同样地,主播由于要实时上传视频直播流,所以也需要 TCP 连接。架构图如下:



用户或主播上线时,如果是第一次登录,需要从用户长连接管理系统申请一个 TCP 服务器地址信息,然后进行 TCP 连接



不了解 TCP 连接的同学可以看我之前写的这篇文章:听说你会架构设计?来,弄一个打车系统



当观众或主播(统称用户)第一次登录,或者和服务器断开连接(比如服务器宕机、用户切换网络、后台关闭手机 App 等),需要重连时,用户可以通过用户长连接管理系统重新申请一个 TCP 服务器地址(可用地址存储在 Zookeeper 中),拿到 TCP 地址后再发起请求连接到集群的某一台服务器上。


用户系统


用户系统的用户表记录了主播和观众的个人信息,包括用户名、头像和地理位置等信息。


除此之外,用户还需要记录关注信息,比如某个用户关注了哪些直播间。


用户表(user)设计如下:




  • user_id:用户唯一标识




  • name:用户名




  • portrait:头像压缩存储




  • addr:地理位置




  • role:用户角色,观众或主播




直播系统


每次开播后,直播系统通过拉取直播流,和主播设备建立 TCP 长连接。这时,直播系统会记录直播表(live)信息,包括:




  • live_id:一场直播的唯一标识




  • live_room_id:直播间的唯一标识




  • user_id:主播用户ID




  • title:直播主题




参考微博的关注系统,我们可以引入用户关注表(attention),以便用户可以关注直播间信息,并接收其动态和评论通知:



  • user_id:关注者的用户ID。

  • live_room_id:被关注者的直播间ID。


这个表可以用于构建用户和主播之间的社交网络,并实现评论的动态通知。


用户关系表的设计可以支持关注、取消关注和获取关注列表等功能。


在数据库中,使用索引可以提高关系查询的性能。同时,可以定期清理不活跃的关系,以减少存储和维护成本。


评论系统


参考微博的评论系统,我们可以支持多级嵌套评论,让用户能够回复特定评论。


对于嵌套评论的存储,我们可以使用递归结构或层次结构的数据库设计,也可以使用关系型数据库表结构。评论表(comment)字段如下:



  • comment_id:评论唯一标识符,主键。

  • user_id:评论者的用户ID。

  • content:评论内容,可以是文本或富文本。

  • timestamp:评论时间戳。

  • parent_comment_id:如果是回复评论,记录被回复评论的comment_id。

  • live_id:评论所属的直播ID。

  • level:评论级别,用于标识评论的嵌套层级。


除此之外,我们可以根据业务需求添加一些额外字段:如点赞数、踩数、举报数等,以支持更多功能。


推送系统


为了提供及时的评论通知,我们可以设计消息推送系统,当用户收到关注直播间开播,或者有新评论或回复时,系统可以向其发送通知。


通知系统需要支持消息的推送和处理,当直播间关注人数很多或者用户发出了热点评论时,为了保证系统稳定,可以使用消息队列来处理异步任务


此外,在推送时需要考虑消息的去重、过期处理和用户偏好设置等方面的问题。


3.2 性能和安全


除了最基本的功能设计以外,我们还需要结合评论系统的数据量和并发量,考虑如何解决高并发、高性能以及数据安全的问题。


1)高并发处理


评论系统面临着巨大的并发压力,数以万计的用户可能同时发布和查看评论。为了应对这个挑战,我们可以采取以下策略。


分布式架构



采用分布式集群架构,将流量分散到多个服务器上,降低单点故障风险,提升用户的性能体验。


消息队列


引入消息队列,如 Kafka,来处理异步任务。



当直播间开播时,首先获取到关注该直播间的用户,然后将直播间名称、直播主题等信息,放入消息队列。


消息推送系统实时监听消息队列,当获取到开播提醒的 Topic 时,首先从 Redis 获取和用户连接的 TCP 服务器信息,然后将开播消息推送到用户手机上


同样地,当用户评论被回复时,将评论用户名和评论信息通过消息推送系统,也推送到用户手机上。


使用消息队列一方面可以减轻服务器的流量负担,另一方面可以根据用户离线情况,消息推送系统可以将历史消息传入延时队列,当用户重新上线时去拉取这些历史消息,以此提升用户体验。


数据缓存


引入缓存层,如 Redis,用于缓存最新的评论数据,以此减轻数据库负载并提升响应速度。例如,可以根据 LRU 策略缓存直播间最热的评论、用户地理位置等信息,并定时更新。


2)安全和防护


评论系统需要应对敏感词汇、恶意攻击等安全威胁。我们可以采取以下防护措施:


文字过滤


使用文字过滤技术,过滤垃圾评论和敏感词汇。实现时,可以用 Redis 缓存或者布隆过滤器。对比性能,我们这里采用布隆过滤器来实现。


布隆过滤器(Bloom Filter)是一个巧妙设计的数据结构,它的原理是将一个值多次哈希,映射到不同的 bit 位上并记录下来。


当新的值使用时,通过同样的哈希函数,比对各个 bit 位上是否有值:如果这些 bit 位上都没有值,说明这个数不存在;否则,就大概率是存在的。



以上图为例,具体操作流程为:



  1. 假设敏感词汇有 3 个元素{菜狗,尼玛,撒币},哈希函数的个数也设置为 3。我们首先将位数组初始化,将每个位都置为 0。



  1. 然后将集合里的敏感词语通过 3 个哈希函数进行映射,每次映射都会产生一个哈希值,即位数组里的 1.



  1. 当查询词语是否为敏感文字时,用相同的哈希函数进行映射,如果映射的位置有一个不为 1,说明该文字一定不存在于集合元素中。反之,如果 3 个点都为 1,则判定元素存在于集合中。


当然,这可能会产生误判,布隆过滤器一定可以发现重复的值,但也可能将不重复的值判断为重复值。如上图中的 “天气”,虽然都命中了 1,但是它并没有存在于敏感词集合里。


布隆过滤器在处理大量数据时非常有用,比如网页缓存、拼写检查、黑名单过滤等。虽然它有一定的误判率(约为 0.05%),但是其判重的速度和节省空间的优点足以瑕不掩瑜。


用户限制


除了从评论信息上加以限制,我们也可以从用户侧来限制:



  • 用户认证:要求用户登录后才能发布评论,降低匿名评论的风险。

  • 评论限制:根据用户 ID 和直播 ID 进行限流,比如让用户在一分钟之内最多只能发送 10 条的评论。



不知道如何限流的,可以看小❤之前的这篇文章:若我问到高可用,阁下又该如何应对呢?



4. 李佳琦该如何应对?


4.1 文本分析和情感分析


除了可以用布隆过滤器检测出恶意攻击和敏感内容,我们还可以引入文本分析和情感分析技术,使用自然语言处理(NLP)算法来检测不当评论。


并且,通过分析用户的评论内容,可以进行情感分析,以了解用户的情感倾向。



除了算法模块,我们还需要新增一个评论采集系统,定期(比如每天)从数据库里拉取用户的评论数据,传入对象存储服务。


算法模块监听对象存储服务,每天实时拉取训练数据,并获取可用的情感分析和语义理解模型。


当有新的评论出现时,会先调用算法模型,然后根据情感的分析结果来存储评论信息。我们可以在评论表(comment)里面新增一个表示情感正负倾向的字段 emotion,当主播打开喜好开关后,只拉取 emotion 为 TRUE 的评论信息,将“嫌贵的用户”或者 “评价为负面” 的评论设置为不可见。


这样,每次直播时,主播看到的都是情感正向且说话好听的评论,不仅能提升直播激情,还能增加与 “真爱粉” 的互动效果,可谓一箭三雕 🐶


但是,评论调用算法模型势必会牺牲一定的实时性与互动效果,主播也可以在开启直播时可以自己决定是否要打开评论喜好设置,并告知打开后评论会延时一段时间。


4.2 机器学习和推荐算法


除了从主播的角度,评论系统还可以引入机器学习算法来分析用户行为,根据用户的历史评论和喜好。


从观众来说,这可以提高观众的参与度和留存率,增强用户粘性。


从主播来说,可以筛选出真爱粉,脑残粉,甚至死亡芭比粉 🐶。这样,每次主播在直播时,只筛选一部分用户可以发表评论,其余的统统禁言,或者设置为不看用户评论。



除了直播领域,社交领域也经常使用推荐算法来获取评论内容。比如之前有 B 站 UP 主爆出:小红书在同一个帖子下,对女性用户和男性用户展示的评论区是不一样的,甚至评论区是截然相反的观点。


这个小❤没有试验过,大家不妨去看一下😃


5. 小结


目前,评论系统随着移动互联网的直播和社交平台规模不断扩大,许多网站和应用已经实现了社交媒体集成,允许用户使用他们的社交媒体帐户进行评论,增加了互动性和用户参与度。


一些平台也开始使用机器学习和人工智能技术来提供个性化评论推荐,以改善用户体验。


总的来说,评论系统是在线社交和内容互动的重要组成部分,希望看过这篇文章之后,大家以后知道如何应对类似的公关危机,到时候记得回来给我点赞。


什么?你想现在就分享、点赞,加入在看啊!


那你一定是社交领域的优质用户,如果直播间都是你这样的观众,评论系统设计成什么样已经不重要了!Love And Peace ❤



当然,前提是老板们都得时刻反思找找自己的原因,这么多年了有没有认真工作,有没有给打工人涨涨工资 🐶



作者:xin猿意码
来源:juejin.cn/post/7278592935468924963
收起阅读 »

一个烂分页,踩了三个坑!

你好呀,我是歪歪。 前段时间踩到一个比较无语的生产 BUG,严格来说其实也不能算是 BUG,只能说开发同事对于业务同事的需求理解没有到位。 这个 BUG 其实和分页没有任何关系,但是当我去排查问题的时候,我看了一眼 SQL ,大概是这样的: select *...
继续阅读 »

你好呀,我是歪歪。


前段时间踩到一个比较无语的生产 BUG,严格来说其实也不能算是 BUG,只能说开发同事对于业务同事的需求理解没有到位。


这个 BUG 其实和分页没有任何关系,但是当我去排查问题的时候,我看了一眼 SQL ,大概是这样的:



select * from table order by priority limit 1;



priority,就是优先级的意思。


按照优先级 order by 然后 limit 取优先级最高(数字越小,优先级越高)的第一条 ,结合业务背景和数据库里面的数据,我立马就意识到了问题所在。


想起了我当年在写分页逻辑的时候,虽然场景和这个完全不一样,但是踩过到底层原理一模一样的坑,这玩意印象深刻,所以立马就识别出来了。


借着这个问题,也盘点一下我遇到过的三个关于分页查询有意思的坑。


职业生涯的第一个生产 BUG


歪师傅职业生涯的第一个生产 BUG 就是一个小小的分页查询。


当时还在做支付系统,接手的一个需求也很简单就是做一个定时任务,定时把数据库里面状态为初始化的订单查询出来,调用另一个服务提供的接口查询订单的状态并更新。


由于流程上有数据强校验,不用考虑数据不存在的情况。所以该接口可能返回的状态只有三种:成功,失败,处理中。


很简单,很常规的一个需求对吧,我分分钟就能写出伪代码:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);    
    } catch (Exception e){
        //打印异常
    }
}

来,你说上面这个程序有什么问题?



其实在绝大部分情况下都没啥大问题,数据量不多的情况下程序跑起来没有任何毛病。


但是,如果数据量多起来了,一次性把所有初始化状态的订单都拿出来,是不是有点不合理了,万一把内存给你撑爆了怎么办?


所以,在我已知数据量会很大的情况下,我采取了分批次获取数据的模式,假设一次性取 100 条数据出来玩。


那么 SQL 就是这样的:



select * from order where order_status=0 order by create_time limit 100;



所以上面的伪代码会变成这样:


while(true){
    //获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
    //select * from order where order_status=0 order by create_time limit 100;
    ArrayList initOrderInfoList = queryInitOrderInfoList();
    //循环处理这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        //捕获异常以免一条数据错误导致循环结束
        try{
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId,orderStatus);    
        } catch (Exception e){
            //打印异常
        }
    }
}

来,你又来告诉我上面这一段逻辑有什么问题?



作为程序员,我们看到 while(true) 这样的写法立马就要警报拉满,看看有没有死循环的风险。


那你说上面这段代码在什么时候退不出来?


当有任何一条数据的状态没有从初始化变成成功、失败或者处理中的时候,就会导致一直循环。


而虽然发起 RPC 调用的地方,服务提供方能确保返回的状态一定是成功、失败、处理中这三者之中的一个,但是这个有一个前提是接口调用正常的情况下。


如果接口调用一旦异常,那么按照上面的写法,在抛出异常后,状态并未发生变化,还会是停留在“初始化”,从而导致死循环。


当年,测试同学在测试阶段直接就测出了这个问题,然后我对其进行了修改。


我改变了思路,把每次分批次查询 100 条数据,修改为了分页查询,引入了 PageHelper 插件:


//是否是最后一页
while(pageInfo.isLastPage){
    pageNum=pageNum+1;
    //获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
    //select * from order where order_status=0 order by create_time limit pageNum*100,100;
    PageHelper.startPage(pageNum,100);
    ArrayList initOrderInfoList = queryInitOrderInfoList();
    pageInfo = new PageInfo(initOrderInfoList);
    //循环处理这批数据
    for(OrderInfo orderInfo : initOrderInfoList){
        //捕获异常以免一条数据错误导致循环结束
        try{
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId,orderStatus);    
        } catch (Exception e){
            //打印异常
        }
    }
}

跳出循环的条件为判断当前页是否是最后一页。


由于每循环一次,当前页就加一,那么理论上讲一定会是翻到最后一页的,没有任何毛病,对不对?


我们可以分析一下上面的代码逻辑。


假设,我们有 120 条 order_status=0 的数据。


那么第一页,取出了 100 条数据:



SELECT * from order_info WHERE order_status=0 LIMIT 0,100;



这 100 条处理完成之后,第二页还有数据吗?


第二页对应的 sql 为:



SELECT * from order_info WHERE order_status=0 LIMIT 100,100;



但是这个时候,状态为 0 的数据,只有 20 条了,而分页要从第 100 条开始,是不是获取不到数据,导致遗漏数据了?


确实一定会翻到最后一页,解决了死循环的问题,但又有大量的数据遗漏怎么办呢?



当时我苦思冥想,想到一个办法:导致数据遗漏的原因是因为我在翻页的时候,数据状态在变化,导致总体数据在变化。


那么如果我每次都从后往前取数据,每次都固定取最后一页,能取到数据就代表还有数据要处理,循环结束条件修改为“当前页即是第一页,也是最后一页时”就结束,这样不就不会遗漏数据了?


我再给你分析一下。


假设,我们有 120 条 order_status=0 的数据,从后往前取了 100 天出来进行出来,有 90 条处理成功,10 条的状态还是停留在“处理中”。


第二次再取的时候,会把剩下的 20 条和这次“处理中”的 10 条,共计 30 条再次取出来进行处理。


确保没有数据遗漏。


后来测试环节验收通过了,这个方案上线之后,也确实没有遗漏过数据了。


直到后来又一天,提供 queryOrderStatus 接口的服务异常了,我发过去的请求超时了。


导致我取出来的数据,每一条都会抛出异常,都不会更新状态。从而导致我每次从后往前取数据,都取到的是同一批数据。


从程序上的表现上看,日志疯狂的打印,但是其实一直在处理同一批,就是死循环了。


好在我当时还在新手保护期,领导帮我扛下来了。


最后随着业务的发展,这块逻辑也完全发生了变化,逻辑由我们主动去调用 RPC 接口查询状态变成了,下游状态变化后进行 MQ 主动通知,所以我这一坨骚代码也就随之光荣下岗。


我现在想了一下,其实这个场景,用分页的思想去取数据真的不好做。


还不如用最开始的分批次的思想,只不过在会变化的“状态”之外,再加上另外一个不会改变的限定条件,比如常见的创建时间:



select * from order where order_status=0 and create_time>xxx order by create_time limit 100;



最好不要基于状态去做分页,如果一定要基于状态去做分页,那么要确保状态在分页逻辑里面会扭转下去。


这就是我职业生涯的第一个生产 BUG,一个低级的分页逻辑错误。


还是分页,又踩到坑


这也是在工作的前两年遇到的一个关于分页的坑。


最开始在学校的时候,大家肯定都手撸过分页逻辑,自己去算总页数,当前页,页面大小啥的。


当时功力尚浅,觉得这部分逻辑写起来是真复杂,但是扣扣脑袋也还是可以写出来。


后来参加工作了之后,在项目里面看到了 PageHelper 这个玩意,了解之后发了“斯国一”的惊叹:有了这玩意,谁还手写分页啊。



但是我在使用 PageHelper 的时候,也踩到过一个经典的“坑”。


最开始的时候,代码是这样的:


PageHelper.startPage(pageNum,100);
List<OrderInfo> list = orderInfoMapper.select(param1);

后来为了避免不带 where 条件的全表查询,我把代码修改成了这样:


PageHelper.startPage(pageNum,100);
if(param != null){
    List<OrderInfo> list = orderInfoMapper.select(param);
}

然后,随着程序的迭代,就出 BUG 了。因为有的业务场景下,param 参数一路传递进来之后就变成了 null。


但是这个时候 PageHelper 已经在当前线程的 ThreadLocal 里面设置了分页参数了,但是没有被消费,这个参数就会一直保留在这个线程上,也就是放在线程的 ThreadLocal 里面。


当这个线程继续往后跑,或者被复用的时候,遇到一条 SQL 语句时,就可能导致不该分页的方法去消费这个分页参数,产生了莫名其妙的分页。


所以,上面这个代码,应该写成下面这个样子:


if(param != null){
    PageHelper.startPage(pageNum,100);
    List<OrderInfo> list = orderInfoMapper.select(param);
}

也是这次踩坑之后,我翻阅了 PageHelper 的源码,了解了底层原理,并总结了一句话:需要保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,否则会污染线程。


在正确使用 PageHelper 的情况下,其插件内部,会在 finally 代码段中自动清除了在 ThreadLocal 中存储的对象。


这样就不会留坑。


这次翻页源码的过程影响也是比较深刻的,虽然那个时候经验不多,但是得益于 MyBatis 的源码和 PageHelper 的源码写的都非常的符合正常人的思维,阅读起来门槛不高,再加上我有具体的疑问,所以那是一次古早时期,尚在新手村时,为数不多的,阅读源码之后,感觉收获满满的经历。


分页丢数据


关于这个 BUG 可以说是印象深刻了。


当年遇到这个坑的时候排查了很长时间没啥头绪,最后还是组里的大佬指了条路。


业务需求很简单,就是在管理页面上可以查询订单列表,查询结果按照订单的创建时间倒序排序。


对应的分页 SQL 很简单,很常规,没有任何问题:



select * from table order by create_time desc limit 0,10;



但是当年在页面上的表现大概是这样的:



订单编号为 5 的这条数据,会同时出现在了第一页和第二页。


甚至有的数据在第二页出现了之后,在第五页又出现一次。


后来定位到产生这个问题的原因是因为有一批数量不小的订单数据是通过线下执行 SQL 的方式导入的。


而导入的这一批数据,写 SQL 的同学为了方便,就把 create_time 都设置为了同一个值,比如都设置为了 2023-09-10 12:34:56 这个时间。


由于 create_time 又是我作为 order by 的字段,当这个字段的值大量都是同一个值的时候,就会导致上面的一条数据在不同的页面上多次出现的情况。


针对这个现象,当时组里的大佬分析明白之后,扔给我一个链接:



dev.mysql.com/doc/refman/…



这是 MySQL 官方文档,这一章节叫做“对 Limit 查询的优化”。


开篇的时候人家就是这样说的:



如果将 LIMIT row_count 和 ORDER BY 组合在一起,那么 MySQL 在找到排序结果的第一行 count 行时就停止排序,而不是对整个结果进行排序。


然后给了这一段补充说明:



如果多条记录的 ORDER BY 列中有相同的值,服务器可以自由地按任何顺序返回这些记录,并可能根据整体执行计划的不同而采取不同的方式。


换句话说,相对于未排序列,这些记录的排序顺序是 nondeterministic 的:



然后官方给了一个示例。


首先,不带 limit 的时候查询结果是这样的:



基于这个结果,如果我要取前五条数据,对应的 id 应该是 1,5,3,4,6。


但是当我们带着 limit 的时候查询结果可能是这样的:



对应的 id 实际是 1,5,4,3,6。


这就是前面说的:如果多条记录的 ORDER BY 列中有相同的值,服务器可以自由地按任何顺序返回这些记录,并可能根据整体执行计划的不同而采取不同的方式。


从程序上的表现上来看,结果就是 nondeterministic。


所以看到这里,我们大概可以知道我前面遇到的分页问题的原因是因为那一批手动插入的数据对应的 create_time 字段都是一样的,而 MySQL 这边又对 Limit 参数做了优化,运行结果出现了不确定性,从而页面上出现了重复的数据。


而回到文章最开始的这个 SQL,也就是我一眼看出问题的这个 SQL:



select * from table order by priority limit 1;



因为在我们的界面上,只是约定了数字越小优先级越高,数字必须大于 0。


所以当大家在输入优先级的时候,大部分情况下都默认自己编辑的数据对应的优先级最高,也就是设置为 1,从而导致数据库里面有大量的优先级为 1 的数据。


而程序每次处理,又只会按照优先级排序只会,取一条数据出来进行处理。


经过前面的分析我们可以知道,这样取出来的数据,不一定每次都一样。


所以由于有这段代码的存在,导致业务上的表现就很奇怪,明明是一模一样的请求参数,但是最终返回的结果可能不相同。


好,现在,我问你,你说在前面,我给出的这样的分页查询的 SQL 语句有没有毛病?



select * from table order by create_time desc limit 0,10;



没有任何毛病嘛,执行结果也没有任何毛病?


有没有给你按照 create_time 排序?


摸着良心说,是有的。


有没有给你取出排序后的 10 条数据?


也是有的。


所以,针对这种现象,官方的态度是:我没错!在我的概念里面,没有“分页”这样的玩意,你通过组合我提供的功能,搞出了“分页”这种业务场景,现在业务场景出问题了,你反过来说我底层有问题?


这不是欺负老实人吗?我没错!



所以,官方把这两种案例都拿出来,并且强调:



在每种情况下,查询结果都是按 ORDER BY 的列进行排序的,这样的结果是符合 SQL 标准的。




虽然我没错,但是我还是可以给你指个路。


如果你非常在意执行结果的顺序,那么在 ORDER BY 子句中包含一个额外的列,以确保顺序具有确定性。


例如,如果 id 值是唯一的,你可以通过这样的排序使给定类别值的行按 id 顺序出现。


你这样去写,排序的时候加个 id 字段,就稳了:



好了,如果觉得本文对你有帮助的话,求个免费的点赞,不过分吧?


作者:why技术
来源:juejin.cn/post/7277187894870671360
收起阅读 »

你知道抖音的IP归属地是怎么实现的吗

1.背景 最近刷抖音发现上线了 IP 属地的功能,小伙伴在发表动态、发表评论以及聊天的时候,都会显示自己的 IP 属地信息,其核心意义是让用户更具有真实性,减少虚假欺骗事件。正好最近本人开发获取客户端ip,做一些接口限流,黑白名单等需求功能,顺路就研究了一下怎...
继续阅读 »

1.背景


最近刷抖音发现上线了 IP 属地的功能,小伙伴在发表动态、发表评论以及聊天的时候,都会显示自己的 IP 属地信息,其核心意义是让用户更具有真实性,减少虚假欺骗事件。正好最近本人开发获取客户端ip,做一些接口限流,黑白名单等需求功能,顺路就研究了一下怎么解析IP获取归属地问题。


接下来,就着重讲解一下Java后端怎么实现IP归属地的功能,其实只需要以下两大步骤:


2.获取客户端ip接口


做过web开发都知道,无论移动端还是pc端的请求接口都会被封装成为一个HttpServletRequest对象,该对象包含了客户端请求信息包括请求的地址,请求的参数,提交的数据等等。


如果服务器直接把IP暴漏出去,那么request.getRemoteAddr()就能拿到客户端ip。


但目前流行的架构中,基本上服务器都不会直接把自己的ip暴漏出去,一般前面还有一层或多层反向代理,常见的nginx居多。 加了代理后,相当于服务器和客户端中间还有一层,这时·request.getRemoteAddr()拿到的就是代理服务器的ip了,并不是客户端的ip。所以这种情况下,一般会在转发头上加X-Forwarded-For等信息,用来跟踪原始客户端的ip。


X-Forwarded-For: 这是一个 Squid 开发的字段,只有在通过了HTTP代理或者负载均衡服务器时才会添加该项。 格式为X-Forwarded-For:client1,proxy1,proxy2,一般情况下,第一个ip为客户端真实ip,后面的为经过的代理服务器ip。 上面的代码注释也说的很清楚,直接截取拿到第一个ip。 Proxy-Client-IP/WL- Proxy-Client-IP: 这个一般是经过apache http服务器的请求才会有,用apache http做代理时一般会加上Proxy-Client-IP请求头,而WL-Proxy-Client-IP是他的weblogic插件加上的头。这种情况也是直接能拿到。 HTTP_CLIENT_IP: 有些代理服务器也会加上此请求头。 X-Real-IP: nginx一般用这个。


但是在日常开发中,并没有规范规定用以上哪一个头信息去跟踪客户端,所以都有可能,只能一一尝试,直到获取到为止。代码如下:


@Slf4j
public class IpUtils {

   private static final String UNKNOWN_VALUE = "unknown";
   private static final String LOCALHOST_V4 = "127.0.0.1";
   private static final String LOCALHOST_V6 = "0:0:0:0:0:0:0:1";

   private static final String X_FORWARDED_FOR = "X-Forwarded-For";
   private static final String X_REAL_IP = "X-Real-IP";
   private static final String PROXY_CLIENT_IP = "Proxy-Client-IP";
   private static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
   private static final String HTTP_CLIENT_IP = "HTTP_CLIENT_IP";

   private static final String IP_DATA_PATH = "/Users/shepherdmy/Desktop/ip2region.xdb";
   private static  byte[] contentBuff;
 
  /**
    * 获取客户端ip地址
    * @param request
    * @return
    */
   public static String getRemoteHost(HttpServletRequest request) {
       String ip = request.getHeader(X_FORWARDED_FOR);
       if (StringUtils.isNotEmpty(ip) && !UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           // 多次反向代理后会有多个ip值,第一个ip才是真实ip
           int index = ip.indexOf(",");
           if (index != -1) {
               return ip.substring(0, index);
          } else {
               return ip;
          }
      }
       ip = request.getHeader(X_REAL_IP);
       if (StringUtils.isNotEmpty(ip) && !UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           return ip;
      }
       if (StringUtils.isBlank(ip) || UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           ip = request.getHeader(PROXY_CLIENT_IP);
      }
       if (StringUtils.isBlank(ip) || UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           ip = request.getHeader(WL_PROXY_CLIENT_IP);
      }
       if (StringUtils.isBlank(ip) || UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           ip = request.getRemoteAddr();
      }

       if (StringUtils.isBlank(ip) || UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           ip = request.getHeader(HTTP_CLIENT_IP);
      }

       if (StringUtils.isBlank(ip) || UNKNOWN_VALUE.equalsIgnoreCase(ip)) {
           ip = request.getRemoteAddr();
      }
       return ip.equals(LOCALHOST_V6) ? LOCALHOST_V4 : ip;
  }

}


项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用


Github地址github.com/plasticene/…


Gitee地址gitee.com/plasticene3…


微信公众号Shepherd进阶笔记


交流探讨群:Shepherd_126



3.获取ip归属地


通过上面我们就能获取到客户端用户的ip地址,接下来就可以通过ip解析获取归属地了。


如果我们在网上搜索资料教程,大部分都是说基于各大平台(eg:淘宝,新浪)提供的ip库进行查询,不过不难发现这些平台已经不怎么维护这个功能,现在处于“半死不活”的状态,根本不靠谱,当然有些平台提供可靠的获取ip属地接口,但是收费、收费、收费


本着作为一个程序员的严谨:“能白嫖的就白嫖,避免出现要买的是你,不会用也是你的尴尬遭遇”。扯远了言归正传,为了寻求可靠有效的解决方案,只能去看看github有没有什么项目能满足需求,果然功夫不负有心人,发现一个宝藏级项目:ip2region,一个准确率 99.9% 的离线 IP 地址定位库,0.0x 毫秒级查询,ip2region.db 数据库只有数 MB的项目,提供了众多主流编程语言的 xdb 数据生成和查询客户端实现,这里只能说:开源真香,开源万岁。


3.1 Ip2region 特性


标准化的数据格式


每个 ip 数据段的 region 信息都固定了格式:国家|区域|省份|城市|ISP,只有中国的数据绝大部分精确到了城市,其他国家部分数据只能定位到国家,其余选项全部是0。


数据去重和压缩


xdb 格式生成程序会自动去重和压缩部分数据,默认的全部 IP 数据,生成的 ip2region.xdb 数据库是 11MiB,随着数据的详细度增加数据库的大小也慢慢增大。


极速查询响应


即使是完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别,可通过如下两种方式开启内存加速查询:



  1. vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。

  2. xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。


IP 数据管理框架


v2.0 格式的 xdb 支持亿级别的 IP 数据段行数,region 信息也可以完全自定义,例如:你可以在 region 中追加特定业务需求的数据,例如:GPS信息/国际统一地域信息编码/邮编等。也就是你完全可以使用 ip2region 来管理你自己的 IP 定位数据。


99.9% 准确率


数据聚合了一些知名 ip 到地名查询提供商的数据,这些是他们官方的的准确率,经测试着实比经典的纯真 IP 定位准确一些。


ip2region 的数据聚合自以下服务商的开放 API 或者数据(升级程序每秒请求次数 2 到 4 次):



备注:如果上述开放 API 或者数据都不给开放数据时 ip2region 将停止数据的更新服务。


3.2 整合Ip2region客户端进行查询


提供了众多主流编程语言的 xdb 数据生成和查询客户端实现,已经集成的客户端有:java、C#、php、c、python、nodejs、php扩展(php5 和 php7)、golang、rust、lua、lua_c,nginx。这里讲一下java的客户端。


首先我们需要引入依赖:


<dependency>
 <groupId>org.lionsoul</groupId>
 <artifactId>ip2region</artifactId>
 <version>2.6.5</version>
</dependency>

接下来我们需要先去下载数据文件ip2region.xdb到本地,然后基于数据文件进行查询,下面查询方法文件路径改为你本地路径即可,ip2region提供三种查询方式:


完全基于文件的查询


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
   public static void main(String[] args) {
       // 1、创建 searcher 对象
       String dbPath = "ip2region.xdb file path";
       Searcher searcher = null;
       try {
           searcher = Searcher.newWithFileOnly(dbPath);
      } catch (IOException e) {
           System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、查询
       try {
           String ip = "1.2.3.4";
           long sTime = System.nanoTime();
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }

       // 3、关闭资源
       searcher.close();
       
       // 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
  }
}

缓存 VectorIndex 索引


我们可以提前从 xdb 文件中加载出来 VectorIndex 数据,然后全局缓存,每次创建 Searcher 对象的时候使用全局的 VectorIndex 缓存可以减少一次固定的 IO 操作,从而加速查询,减少 IO 压力。


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
   public static void main(String[] args) {
       String dbPath = "ip2region.xdb file path";

       // 1、从 dbPath 中预先加载 VectorIndex 缓存,并且把这个得到的数据作为全局变量,后续反复使用。
       byte[] vIndex;
       try {
           vIndex = Searcher.loadVectorIndexFromFile(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to load vector index from `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、使用全局的 vIndex 创建带 VectorIndex 缓存的查询对象。
       Searcher searcher;
       try {
           searcher = Searcher.newWithVectorIndex(dbPath, vIndex);
      } catch (Exception e) {
           System.out.printf("failed to create vectorIndex cached searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 3、查询
       try {
           String ip = "1.2.3.4";
           long sTime = System.nanoTime();
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }
       
       // 4、关闭资源
       searcher.close();

       // 备注:每个线程需要单独创建一个独立的 Searcher 对象,但是都共享全局的制度 vIndex 缓存。
  }
}

缓存整个 xdb 数据


我们也可以预先加载整个 ip2region.xdb 的数据到内存,然后基于这个数据创建查询对象来实现完全基于文件的查询,类似之前的 memory search。


import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
   public static void main(String[] args) {
       String dbPath = "ip2region.xdb file path";

       // 1、从 dbPath 加载整个 xdb 到内存。
       byte[] cBuff;
       try {
           cBuff = Searcher.loadContentFromFile(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to load content from `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、使用上述的 cBuff 创建一个完全基于内存的查询对象。
       Searcher searcher;
       try {
           searcher = Searcher.newWithBuffer(cBuff);
      } catch (Exception e) {
           System.out.printf("failed to create content cached searcher: %s\n", e);
           return;
      }

       // 3、查询
       try {
           String ip = "1.2.3.4";
           long sTime = System.nanoTime();
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }
       
       // 4、关闭资源 - 该 searcher 对象可以安全用于并发,等整个服务关闭的时候再关闭 searcher
       // searcher.close();

       // 备注:并发使用,用整个 xdb 数据缓存创建的查询对象可以安全的用于并发,也就是你可以把这个 searcher 对象做成全局对象去跨线程访问。
  }
}

3.3 springboot整合示例


首先我们也需要像上面一样引入maven依赖。然后就可以基于上面的查询方式进行封装成工具类了,我这里选择了上面的第三种方式:缓存整个 xdb 数据


@Slf4j
public class IpUtils {
   private static final String IP_DATA_PATH = "/Users/shepherdmy/Desktop/ip2region.xdb";
   private static  byte[] contentBuff;

   static {
       try {
           // 从 dbPath 加载整个 xdb 到内存。
           contentBuff = Searcher.loadContentFromFile(IP_DATA_PATH);
      } catch (IOException e) {
           e.printStackTrace();
      }
  }
 
     /**
    * 根据ip查询归属地,固定格式:中国|0|浙江省|杭州市|电信
    * @param ip
    * @return
    */
   public static IpRegion getIpRegion(String ip) {
       Searcher searcher = null;
       IpRegion ipRegion = new IpRegion();
       try {
           searcher = Searcher.newWithBuffer(contentBuff);
           String region = searcher.search(ip);
           String[] info = StringUtils.split(region, "|");
           ipRegion.setCountry(info[0]);
           ipRegion.setArea(info[1]);
           ipRegion.setProvince(info[2]);
           ipRegion.setCity(info[3]);
           ipRegion.setIsp(info[4]);
      } catch (Exception e) {
           log.error("get ip region error: ", e);
      } finally {
           if (searcher != null) {
               try {
                   searcher.close();
              } catch (IOException e) {
                   log.error("close searcher error:", e);
              }
          }
      }
       return ipRegion;
  }

}

作者:shepherd111
来源:juejin.cn/post/7280118836685668367
收起阅读 »

问个事,我就用Tomcat,不用Nginx,行不行!

只用Tomcat,不用Nginx搭建Web服务,行不行?我曾经提出的愚蠢问题,今天详细给自己解释下,为什么必须用Nginx! 不用Nginx,只用Tomcat的Http请求流程 浏览器处理一个Http请求时,会首先通过DNS服务器找到域名关联的IP地址,然后请...
继续阅读 »

只用Tomcat,不用Nginx搭建Web服务,行不行?我曾经提出的愚蠢问题,今天详细给自己解释下,为什么必须用Nginx!


不用Nginx,只用Tomcat的Http请求流程


浏览器处理一个Http请求时,会首先通过DNS服务器找到域名关联的IP地址,然后请求到对应的IP地址。以阿里云域名管理服务为例,一个域名可以最多绑定三个IP地址,这三个IP地址需要是公网IP地址,所以首先需要在三个公网Ip服务器上部署Tomcat实例。


此时我将面临的麻烦如下



  1. 由于DNS域名管理绑定的IP地址有限,最多三个,你如果想要扩容4台Tomcat,是不支持的。无法满足扩容的诉求

  2. 如果你有10个服务,对应10套Tomcat集群,就需要10 * 3台公网Ip服务器。成本还是蛮高的。

  3. 10个服务需要对应10个域名,分别映射到对应的Tomcat集群

  4. 10个域名我花不起这个钱啊!(其实可以用二级域名配置DNS映射)

  5. 公网服务器作为接入层需要有防火墙等安全管控措施,30台公网服务器,网络安全运维,我搞不定。

  6. 公网IP地址需要额外从移动联通运营商或云厂商购买,30个公网IP价格并不便宜。

  7. 前后端分离的情况,Tomcat无法作为静态文件服务器,只能用Nginx或Apache


以上几个问题属于成本、安全、服务扩容等方面。


如果Tomcat服务发布怎么办


Tomcat在服务发布期间是不可用的,在发布期间Http请求打到发布的服务器,就会失败。由于DNS 最多配置3台服务器,也就是发布期间是 1/3 的失败率。 我会被老板枪毙,用加特林


DNS不能自动摘掉故障的IP地址吗?


不能,DNS只是负责解析域名对应的IP地址,他并不知道对应的服务器状态,更不会知道服务器上Tomcat的状态如何。DNS只是解析IP,并没有转发Http请求,所以压根不知道哪台服务器故障率高。更无法自动摘掉IP地址。


我能手动下掉故障的IP地址吗?


这个我能,但是还是会有大量请求失败。以阿里云为例,配置域名映射时,我可以下掉对应的IP地址,但需要指定域名映射的缓存时间,默认10分钟。换句话说,就算你在上线前,摘掉了对应的IP,依然要等10分钟,所有的客户端才会拿到最新的DNS解析地址。


那么把TTL缓存时间改小,可以吗? 可以的,但是改小了,就意味更多的请求被迫从DNS服务器拿最新的映射,整体请求耗时增加,用户体验下降!被老板发现,会骂我。


节点突然挂掉怎么办?


虽然可以在DNS管理后台手动下掉IP地址,但是节点突然宕机、Tomcat Crash等因素导致的突然故障,我是来不及下掉对应IP地址的,我只能打电话告诉老板,“线上服务崩了,你等我10分钟改点东西”。


如果这时候有个软件能 对Tomcat集群健康检查和故障重试,那就太好了。


恰好,这是 Nginx 的长处!


Nginx可以健康检查和故障重试


而Tomcat没有。


例如有两台Tomcat节点,在Nginx配置故障重试策略


upstream test {
server 127.0.0.1:8001 fail_timeout=60s max_fails=2; # Server A
server 127.0.0.1:8002 fail_timeout=60s max_fails=2; # Server B
}

当A节点出现 connect refused时(端口关闭或服务器挂了),说明服务不可用,可能是服务发布,也可能是服务器挂了。此时nginx会把失败的请求自动转发到B节点。 假设第二个请求 请求到A还是失败,正好累计2个失败了,那么Nginx会自动把A节点剔除存活列表 60 秒,然后继续把请求2 转发到B节点进行处理。60秒后,再次尝试转发请求到A节点…… 循环往复,直至A节点活过来……


而这一过程客户端是感知不到失败的。因为两次请求都二次转发到B节点成功处理了。客户端并不会感知到A节点的处理失败,这就是Nginx 反向代理的好处。即客户端不用直连服务端,加了个中间商,服务端的个别节点宕机或发布,对客户端都毫无影响。


而Tomcat只是Java Web容器,并不能做这些事情。


10个服务,10个Tomcat集群,就要10个域名,30个公网IP吗?


以阿里云为例,域名管理后台是可以配置二级域名映射,所以一个公网域名拆分为10个二级域名就可以了。


所以只用Tomcat,不用Nginx。需要1个公网域名,10个二级域名,30台服务器、30个公网IP。


当我和老板提出这些的时候,他跟我说:“你XX疯了,要不滚蛋、要不想想别的办法。老子没钱,你看我脑袋值几个钱,拿去换公网IP吧”。


image.png


心里苦啊,要是能有一个软件,能帮我把一个域名分别映射到30个内网IP就好了。


恰好 Nginx可以!


Nginx 虚拟主机和反向代理


例如把多个二级域名映射到不同的文件目录,例如



  1. bbs.abc.com,映射到 html/bbs

  2. blog.abc.com 映射到 html/blog


http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name http://www.abc.com;
location / {
root html/www;
index index.html index.htm;
}
}

server {
listen 80;
server_name bbs.abc.com;
location / {
root html/bbs;
index index.html index.htm;
}
}

server {
listen 80;
server_name blog.abc.com;
location / {
root html/blog;
index index.html index.htm;
}
}
}

例如把不同的二级域名或者URL路径 映射到不同的 Tomcat集群



  1. 分别定义 serverGroup1、serverGroup2 两个Tomcat集群

  2. 分别把路径group1、group1 反向代理到serverGroup1、serverGroup2


upstream serverGroup1 {                    # 定义负载均衡设备的ip和状态
server 192.168.225.100:8080 ; # 默认权重值为一
server 192.168.225.101:8082 weight=2; # 值越高,负载的权重越高
server 192.168.225.102:8083 ;
server 192.168.225.103:8084 backup; # 当其他非backup状态的server 不能正常工作时,才请求该server,简称热备
}

upstream serverGroup2 { # 定义负载均衡设备的ip和状态
server 192.168.225.110:8080 ; # 默认权重值为一
server 192.168.225.111:8080 weight=2; # 值越高,负载的权重越高
server 192.168.225.112:8080 ;
server 192.168.225.113:8080 backup; # 当其他非backup状态的server 不能正常工作时,才请求该server,简称热备
}

server { # 设定虚拟主机配置
listen 80; # 监听的端口
server_name picture.itdragon.com; # 监听的地址,多个域名用空格隔开
location /group1 { # 默认请求 ,后面 "/group1" 表示开启反向代理,也可以是正则表达式
root html; # 监听地址的默认网站根目录位置
proxy_pass http://serverGroup1; # 代理转发
index index.html index.htm; # 欢迎页面
deny 127.0.0.1; # 拒绝的ip
allow 192.168.225.133; # 允许的ip
}
location /group2 { # 默认请求 ,后面 "/group2" 表示开启反向代理,也可以是正则表达式
root html; # 监听地址的默认网站根目录位置
proxy_pass http://serverGroup2; # 代理转发
index index.html index.htm; # 欢迎页面
deny 127.0.0.1; # 拒绝的ip
allow 192.168.225.133; # 允许的ip
}

error_page 500 502 503 504 /50x.html;# 定义错误提示页面
location = /50x.html { # 配置错误提示页面
root html;
}
}

经过以上的教训,我再也不会犯这么愚蠢的错误了,我需要Tomcat,也需要Nginx。


当然如果钱足够多、资源无限丰富,公网IP、公网服务器、域名无限…… 服务发布,网站崩溃,无动于衷,可以不用Nginx。


作者:他是程序员
来源:juejin.cn/post/7280088532377534505
收起阅读 »

什么是 HTTP 长轮询?

什么是 HTTP 长轮询? Web 应用程序最初是围绕客户端/服务器模型开发的,其中 Web 客户端始终是事务的发起者,向服务器请求数据。因此,没有任何机制可以让服务器在没有客户端先发出请求的情况下独立地向客户端发送或推送数据。 为了克服这个缺陷,Web 应用...
继续阅读 »

什么是 HTTP 长轮询?


Web 应用程序最初是围绕客户端/服务器模型开发的,其中 Web 客户端始终是事务的发起者,向服务器请求数据。因此,没有任何机制可以让服务器在没有客户端先发出请求的情况下独立地向客户端发送或推送数据。


为了克服这个缺陷,Web 应用程序开发人员可以实施一种称为 HTTP长轮询的技术,其中客户端轮询服务器以请求新信息。服务器保持请求打开,直到有新数据可用。一旦可用,服务器就会响应并发送新信息。客户端收到新信息后,立即发送另一个请求,重复上述操作。


什么是 HTTP 长轮询?


那么,什么是长轮询?HTTP 长轮询是标准轮询的一种变体,它模拟服务器有效地将消息推送到客户端(或浏览器)。


长轮询是最早开发的允许服务器将数据“推送”到客户端的技术之一,并且由于其寿命长,它在所有浏览器和 Web 技术中几乎无处不在。即使在一个专门为持久双向通信设计的协议(例如 WebSockets)的时代,长轮询的能力仍然作为一种无处不在的回退机制占有一席之地。


HTTP 长轮询如何工作?


要了解长轮询,首先要考虑使用 HTTP 的标准轮询。


“标准”HTTP 轮询


HTTP 轮询由客户端(例如 Web 浏览器)组成,不断向服务器请求更新。


一个用例是想要关注快速发展的新闻报道的用户。在用户的浏览器中,他们已经加载了网页,并希望该网页随着新闻报道的展开而更新。实现这一点的一种方法是浏览器反复询问新闻服务器“内容是否有任何更新”,然后服务器将以更新作为响应,或者如果没有更新则给出空响应。浏览器请求更新的速率决定了新闻页面更新的频率——更新之间的时间过长意味着重要的更新被延迟。更新之间的时间太短意味着会有很多“无更新”响应,从而导致资源浪费和效率低下。




上图:Web 浏览器和服务器之间的 HTTP 轮询。服务器向立即响应的服务器发出重复请求。


这种“标准”HTTP 轮询有缺点:


  • 更新请求之间没有完美的时间间隔。请求总是要么太频繁(效率低下)要么太慢(更新时间比要求的要长)。
  • 随着规模的扩大和客户端数量的增加,对服务器的请求数量也会增加。由于资源被无目的使用,这可能会变得低效和浪费。

HTTP 长轮询解决了使用 HTTP 进行轮询的缺点


  1. 请求从浏览器发送到服务器,就像以前一样
  2. 服务器不会关闭连接,而是保持连接打开,直到有数据供服务器发送
  3. 客户端等待服务器的响应。
  4. 当数据可用时,服务器将其发送给客户端
  5. 客户端立即向服务器发出另一个 HTTP 长轮询请求


上图:客户端和服务器之间的 HTTP 长轮询。请注意,请求和响应之间有很长的时间,因为服务器会等待直到有数据要发送。


这比常规轮询更有效率。


  • 浏览器将始终在可用时接收最新更新
  • 服务器不会被永远无法满足的请求所搞垮。

长轮询有多长时间?


在现实世界中,任何与服务器的客户端连接最终都会超时。服务器在响应之前保持连接打开的时间取决于几个因素:服务器协议实现、服务器体系结构、客户端标头和实现(特别是 HTTP Keep-Alive 标头)以及用于启动的任何库并保持连接。


当然,许多外部因素也会影响连接,例如,移动浏览器在 WiFi 和蜂窝连接之间切换时更有可能暂时断开连接。


通常,除非您可以控制整个架构堆栈,否则没有单一的轮询持续时间。


使用长轮询时的注意事项


在您的应用程序中使用 HTTP 长轮询构建实时交互时,需要考虑几件事情,无论是在开发方面还是在操作/扩展方面。


  • 随着使用量的增长,您将如何编排实时后端?
  • 当移动设备在WiFi和蜂窝网络之间快速切换或失去连接,IP地址发生变化时,长轮询会自动重新建立连接吗?
  • 通过长轮询,您能否管理消息队列并如何处理丢失的消息?
  • 长轮询是否提供跨多个服务器的负载平衡或故障转移支持?

在为服务器推送构建具有 HTTP 长轮询的实时应用程序时,您必须开发自己的通信管理系统。这意味着您将负责更新、维护和扩展您的后端基础设施。


服务器性能和扩展


使用您的解决方案的每个客户端将至少每 5 分钟启动一次与您的服务器的连接,并且您的服务器将需要分配资源来管理该连接,直到它准备好满足客户端的请求。一旦完成,客户端将立即重新启动连接,这意味着实际上,服务器将需要能够永久分配其资源的一部分来为该客户端提供服务。当您的解决方案超出单个服务器的能力并且引入负载平衡时,您需要考虑会话状态——如何在服务器之间共享客户端状态?您如何应对连接不同 IP 地址的移动客户端?您如何处理潜在的拒绝服务攻击?


这些扩展挑战都不是 HTTP 长轮询独有的,但协议的设计可能会加剧这些挑战——例如,您如何区分多个客户端发出多个真正的连续请求和拒绝服务攻击?


消息排序和排队


在服务器向客户端发送数据和客户端发起轮询请求之间总会有一小段时间,数据可能会丢失。


服务器在此期间要发送给客户端的任何数据都需要缓存起来,并在下一次请求时传递给客户端。




然后出现几个明显的问题:


  • 服务器应该将数据缓存或排队多长时间?
  • 应该如何处理失败的客户端连接?
  • 服务器如何知道同一个客户端正在重新连接,而不是新客户端?
  • 如果重新连接花费了很长时间,客户端如何请求落在缓存窗口之外的数据?

所有这些问题都需要 HTTP 长轮询解决方案来回答。


设备和网络支持


如前所述,由于 HTTP 长轮询已经存在了很长时间,它在浏览器、服务器和其他网络基础设施(交换机、路由器、代理、防火墙)中几乎得到了无处不在的支持。这种级别的支持意味着长轮询是一种很好的后备机制,即使对于依赖更现代协议(如 WebSockets )的解决方案也是如此。


众所周知,WebSocket 实现,尤其是早期实现,在双重 NAT 和某些 HTTP 长轮询运行良好的代理环境中挣扎。


作者:demo007x
链接:https://juejin.cn/post/7240111396869161020
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

趣解设计模式之《小王看病记》

〇、小故事 小王最近参与了公司一个大项目,工作很忙,经常加班熬夜,满负荷工作了2个月后,项目终于如期上线,并且客户反馈也特别的好。老板很开心,知道大家为这个项目付出了很多,所以给全组同事都放了1个星期的假。 小王在项目期间也经常因为饮食不规范而导致胃疼,最近也...
继续阅读 »

〇、小故事


小王最近参与了公司一个大项目,工作很忙,经常加班熬夜,满负荷工作了2个月后,项目终于如期上线,并且客户反馈也特别的好。老板很开心,知道大家为这个项目付出了很多,所以给全组同事都放了1个星期的假。


小王在项目期间也经常因为饮食不规范而导致胃疼,最近也越来越严重了。所以他就想趁着这个假期时间去医院检查一下身体


他来到医院的挂号处,首先缴费挂号,挂了一个检查胃部的诊室。



小王按照挂号信息,来到了诊室,医生简单的询问了一下他的病情,然后给他开了几个需要检查的单子



小王带着医生开具的检查单,就在医院的收费处排队等待着缴费



缴费完毕后,小王就按照医生开的检查项目进行了身体检查……



那么从上面小王的一系列看病流程我们可以发现,这是一系列的处理过程,跟链条一样,即:



挂号——>开检查单——>缴费——>检查——>……



那么对于类似这种的业务逻辑,我们就可以使用一种设计模式来处理,即今天要介绍的——责任链模式


一、模式定义


责任链模式Chain of Responsibility Pattern



使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。



二、模式类图


下面我们再举一个例子,一家公司收到了好多的电子邮件,其中大致分为四类:



CEO处理】公司粉丝发来的邮件。

法律部门处理诽谤公司产品的邮件。

业务部门处理】要求业务合作的邮件。

直接丢弃】其他垃圾邮件。



这里需要CEO先查阅邮件处理,然后再由法务部处理,随后是业务部处理,最后是垃圾邮件执行废弃。根据以上的描述,我们首先需要邮件实体类Email,和用于区分不同处理方式的邮件类型EmailType。对于所有处理者,我们首先创建一个抽象的处理器类AbstractProcessor,再创建四个处理器的实现类,分别是CEO处理器CeoProcessor法务部门处理器LawProcessor业务部门处理器BusinessProcessor垃圾邮件处理器GarbageProcessor。具体类关系如下图所示:



三、模式实现


创建邮件实体类Email.java


@Data
@NoArgsConstructor
@AllArgsConstructor
public class Email {
// 邮件类型
private int type;

// 邮件内容
private String content;
}

创建邮件类型枚举类EmailType.java


public enum EmailType {
FANS_EMAIL(1, "粉丝邮件"),
SLANDER_EMAIL(2, "诽谤邮件"),
COOPERATE_EMAIL(3, "业务合作邮件"),
GARBAGE_EMAIL(99, "垃圾邮件");

public int type;

public String remark;

EmailType(int type, String remark) {
this.type = type;
this.remark = remark;
}
}

创建抽象处理类AbstractProcessor.java


public abstract class AbstractProcessor {

// 责任链中下一个处理节点
private AbstractProcessor nextProcessor;

// 返回的处理结果
private String result;

public final String handleMessage(List emails) {
List filterEmails =
emails.stream().filter(email -> email.getType() == this.emailType()).collect(Collectors.toList());
result = this.execute(filterEmails);
if (this.nextProcessor == null) {
return result;
}
return this.nextProcessor.handleMessage(emails);
}

// 设置责任链的下一个处理器
public void setNextProcessor(AbstractProcessor processor) {
this.nextProcessor = processor;
}

// 获得当前Processor可以处理的邮件类型
protected abstract int emailType();

// 具体处理方法
protected abstract String execute(List emails);
}

创建CEO处理类CeoProcessor.java


public class CeoProcessor extends AbstractProcessor {
@Override
protected int emailType() {
return EmailType.FANS_EMAIL.type; // 处理粉丝来的邮件
}

@Override
protected String execute(List emails) {
if (CollectionUtils.isNotEmpty(emails)) {
System.out.println("-------CEO开始处理邮件-------");
emails.stream().forEach(email ->
System.out.println(email.getContent()));
}
return "任务执行完毕!";
}
}

创建法律部门处理类LawProcessor.java


public class LawProcessor extends AbstractProcessor {

@Override
protected int emailType() {
return EmailType.SLANDER_EMAIL.type; // 处理诽谤类型的邮件
}

@Override
protected String execute(List emails) {
if (CollectionUtils.isNotEmpty(emails)) {
System.out.println("-------法律部门开始处理邮件-------");
emails.stream().forEach(email ->
System.out.println(email.getContent()));
}
return "任务执行完毕!";
}
}

创建业务部门处理类BusinessProcessor.java


public class BusinessProcessor extends AbstractProcessor {

@Override
protected int emailType() {
return EmailType.COOPERATE_EMAIL.type; // 处理合作类型的邮件
}

@Override
protected String execute(List emails) {
if (CollectionUtils.isNotEmpty(emails)) {
System.out.println("-------业务部门开始处理邮件-------");
emails.stream().forEach(email ->
System.out.println(email.getContent()));
}
return "任务执行完毕!";
}
}

创建垃圾邮件处理类GarbageProcessor.java


public class GarbageProcessor extends AbstractProcessor {

@Override
protected int emailType() {
return EmailType.GARBAGE_EMAIL.type; // 处理垃圾类型邮件
}

@Override
protected String execute(List emails) {
if (CollectionUtils.isNotEmpty(emails)) {
System.out.println("-------垃圾开始处理邮件-------");
emails.stream().forEach(email ->
System.out.println(email.getContent()));
}
return "任务执行完毕!";
}
}

创建责任链模式测试类ChainTest.java


public class ChainTest {
// 初始化待处理邮件
private static List emails = Lists.newArrayList(
new Email(EmailType.FANS_EMAIL.type, "我是粉丝A"),
new Email(EmailType.COOPERATE_EMAIL.type, "我要找你们合作"),
new Email(EmailType.GARBAGE_EMAIL.type, "我是垃圾邮件"),
new Email(EmailType.FANS_EMAIL.type, "我是粉丝B"));

public static void main(String[] args) {
// 初始化处理类
AbstractProcessor ceoProcessor = new CeoProcessor();
AbstractProcessor lawProcessor = new LawProcessor();
AbstractProcessor businessProcessor = new BusinessProcessor();
AbstractProcessor garbageProcessor = new GarbageProcessor();

// 设置责任链条
ceoProcessor.setNextProcessor(lawProcessor);
lawProcessor.setNextProcessor(businessProcessor);
businessProcessor.setNextProcessor(garbageProcessor);

// 开始处理邮件
ceoProcessor.handleMessage(emails);
}
}

执行后的结果


-------CEO开始处理邮件-------
我是粉丝A
我是粉丝B
-------业务部门开始处理邮件-------
我要找你们合作
-------垃圾开始处理邮件-------
我是垃圾邮件

Process finished with exit code 0

作者:爪哇缪斯
来源:juejin.cn/post/7277801611996676157
收起阅读 »

微博图床挂了!

一直担心的事情还是发生了。 作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然...
继续阅读 »

一直担心的事情还是发生了。


作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今年早些的时候,部分如「ws1、ws2……」的域名就已经无法使用了,但通过某些手段还是可以让其存活的,而最近,所有调用的微博图床图片都无法加载并提示“403 Forbidden”了。




💡Tips:图片中出现的Tengine是淘宝在Nginx的基础上修改后开源的一款Web服务器,基本上,Tengine可以被看作一个更好的Nginx,或者是Nginx的超集,详情可参考👉淘宝Web服务器Tengine正式开源 - The Tengine Web Server



刚得知这个消息的时候,我的第一想法其实是非常生气的,毕竟自己这几年上千张图片都是用的微博图床,如今还没备份就被403了,可仔细一想,说到底还是把东西交在别人手里的下场,微博又不是慈善企业,也要控制成本,一直睁一只眼闭一只眼让大家免费用就算了,出了问题还是不太好怪到微博上来的。


那么有什么比较好的办法解决这个问题呢?


查遍了网上一堆复制/粘贴出来的文章,不是开启反向代理就是更改请求头,真正愿意从根本上解决问题的没几个。


如果不想将自己沉淀的博客、文章托管在印象笔记、notion、语雀这些在线平台的话,想要彻底解决这个问题最好的方式是:自建图床!


为了更好的解决问题,我们先弄明白,403是什么,以及我们存在微博上的图片究竟是如何被403的。


403


百度百科,对于403错误的解释很简单



403错误是一种在网站访问过程中,常见的错误提示,表示资源不可用。服务器理解客户的请求,但拒绝处理它,通常由于服务器上文件或目录的权限设置导致的WEB访问错误。



所以说到底是因为访问者无权访问服务器端所提供的资源。而微博图床出现403的原因主要在于微博开启了防盗链。


防盗链的原理很简单,站点在得知有请求时,会先判断请求头中的信息,如果请求头中有Referer信息,然后根据自己的规则来判断Referer头信息是否符合要求,Referer 信息是请求该图片的来源地址。


如果盗用网站是 https 的 协议,而图片链接是 http 的话,则从 https 向 http 发起的请求会因为安全性的规定,而不带 referer,从而实现防盗链的绕过。官方输出图片的时候,判断了来源(Referer),就是从哪个网站访问这个图片,如果是你的网站去加载这个图片,那么 Referer 就是你的网站地址;你的网址肯定没在官方的白名单内,(当然作为可操作性极强的浏览器来说 referer 是完全可以伪造一个官方的 URL 这样也也就也可以饶过限制🚫)所以就看不到图片了。



解决问题


解释完原理之后我们发现,其实只要想办法在自己的个人站点中设置好referer就可以解决这个问题,但说到底也只是治标不治本,真正解决这个问题就是想办法将图片迁移到自己的个人图床上。


现在的图床工具很多,iPic、uPic、PicGo等一堆工具既免费又开源,问题在于选择什么云存储服务作为自己的图床以及如何替换自己这上千张图片。



  1. 选择什么云存储服务

  2. 如何替换上千张图片


什么是OSS以及如何选择


「OSS」的英文全称是Object Storage Service,翻译成中文就是「对象存储服务」,官方一点解释就是对象存储是一种使用HTTP API存储和检索非结构化数据和元数据对象的工具。


白话文解释就是将系统所要用的文件上传到云硬盘上,该云硬盘提供了文件下载、上传等一列服务,这样的服务以及技术可以统称为OSS,业内提供OSS服务的厂商很多,知名常用且成规模的有阿里云、腾讯云、百度云、七牛云、又拍云等。


对于我们这些个人用户来说,这些云厂商提供的服务都是足够使用的,我们所要关心的便是成本💰。


笔者使用的是七牛云,它提供了10G的免费存储,基本已经够用了。


有人会考虑将GitHub/Gitee作为图床,并且这样的文章在中文互联网里广泛流传,因为很多人的个人站点都是托管在GitHub Pages上的,但是个人建议是不要这么做。


首先GitHub在国内的访问就很受限,很多场景都需要科学上网才能获得完整的浏览体验。再加上GitHub官方也不推荐将Git仓库存储大文件,GitHub建议仓库保持较小,理想情况下小于 1 GB,强烈建议小于 5 GB。


如何替换上千张图片


替换文章中的图片链接和“把大象放进冰箱里”步骤是差不多的



  1. 下载所有的微博图床的图片

  2. 上传所有的图片到自己的图床(xx云)

  3. 对文本文件执行replaceAll操作


考虑到我们需要迁移的文件数量较多,手动操作肯定是不太可行的,因此我们可以采用代码的方式写一个脚本完成上述操作。考虑到自己已经是一个成熟的Java工程师了,这个功能就干脆用Java写了。


为了减少代码量,精简代码结构,我这里引入了几个第三方库,当然不引入也行,如果不引入有一些繁琐而又简单的业务逻辑需要自己实现,有点浪费时间了。


整个脚本逻辑非常简单,流程如下:



获取博客文件夹下的Markdown文件


这里我们直接使用hutool这个三方库,它内置了很多非常实用的工具类,获取所有markdown文件也变得非常容易


/**
* 筛选出所有的markdown文件
*/

public static List<File> listAllMDFile() {
List<File> files = FileUtil.loopFiles(VAULT_PATH);
return files.stream()
.filter(Objects::nonNull)
.filter(File::isFile)
.filter(file -> StringUtils.endsWith(file.getName(), ".md"))
.collect(Collectors.toList());
}

获取文件中的所有包含微博图床的域名


通过Hutools内置的FileReader我们可以直接读取markdown文件的内容,因此我们只需要解析出文章里包含微博图床的链接即可。我们可以借助正则表达式快速获取一段文本内容里的所有url,然后做一下filter即可。


/**
* 获取一段文本内容里的所有url
*
* @param content 文本内容
* @return 所有的url
*/

public static List<String> getAllUrlsFromContent(String content) {
List<String> urls = new ArrayList<>();
Pattern pattern = Pattern.compile(
"\\b(((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)" + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov"
+ "|mil|biz|info|mobi|name|aero|jobs|museum" + "|travel|[a-z]{2}))(:[\\d]{1,5})?"
+ "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?" + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
+ "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)" + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
+ "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*" + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?\\b");
Matcher matcher = pattern.matcher(content);
while (matcher.find()) {
urls.add(matcher.group());
}
return urls;
}

下载图片


用Java下载文件的代码在互联网上属实是重复率最高的一批检索内容了,这里就直接贴出代码了。


public static void download(String urlString, String fileName) throws IOException {
File file = new File(fileName);
if (file.exists()) {
return;
}
URL url = null;
OutputStream os = null;
InputStream is = null;
try {
url = new URL(urlString);
URLConnection con = url.openConnection();
// 输入流
is = con.getInputStream();
// 1K的数据缓冲
byte[] bs = new byte[1024];
// 读取到的数据长度
int len;
// 输出的文件流
os = Files.newOutputStream(Paths.get(fileName));
// 开始读取
while ((len = is.read(bs)) != -1) {
os.write(bs, 0, len);
}
} finally {
if (os != null) {
os.close();
}
if (is != null) {
is.close();
}
}
}

上传图片


下载完图片后我们便要着手将下载下来的图片上传至我们自己的云存储服务了,这里直接给出七牛云上传图片的文档链接了,文档里写的非常详细,我就不赘述了👇


Java SDK_SDK 下载_对象存储 - 七牛开发者中心


全局处理


通过阅读代码的细节,我们可以发现,我们的方法粒度是单文件的,但事实上,我们可以先将所有的文件遍历一遍,统一进行图片的下载、上传与替换,这样可以节约点时间。


统一替换的逻辑也很简单,我们申明一个全局Map,


private static final Map<String, String> URL_MAP = Maps.newHashMap();

其中,key是旧的新浪图床的链接,value是新的自定义图床的链接。


我们将listAllMDFile这一步中所获取到的所有文件里的所有链接保存于此,下载时只需遍历这个Map的key即可获取到需要下载的图片链接。然后将上传后得到的新链接作为value存在到该Map中即可。


全文替换链接并更新文件


有了上述这些处理步骤,接下来一步就变的异常简单,只需要遍历每个文件,将匹配到全局Map中key的链接替换成Map中的value即可。


/**
* 替换所有的图片链接
*/

private static String replaceUrl(String content, Map<String, String> urlMap) {
for (Map.Entry<String, String> entry : urlMap.entrySet()) {
String oldUrl = entry.getKey();
String newUrl = entry.getValue();
if (StringUtils.isBlank(newUrl)) {
continue;
}
content = RegExUtils.replaceAll(content, oldUrl, newUrl);
}
return content;
}

我们借助commons-lang实现字符串匹配替换,借助Hutools实现文件的读取和写入。


files.forEach(file -> {
try {
FileReader fileReader = new FileReader(file.getPath());
String content = fileReader.readString();
String replaceContent = replaceUrl(content, URL_MAP);
FileWriter writer = new FileWriter(file.getPath());
writer.write(replaceContent);
} catch (Throwable e) {
log.error("write file error, errorMsg:{}", e.getMessage());
}
});

为了安全起见,最好把文件放在新的目录中,不要直接替换掉原来的文件,否则程序出现意外就麻烦了。


接下来我们只需要运行程序,静待备份结果跑完即可。


以上就是本文的全部内容了,希望对你有所帮助


作者:插猹的闰土
来源:juejin.cn/post/7189651446306963514
收起阅读 »

git merge 和 git rebase的区别

git rebase 让你的提交记录更加清晰可读 git rebase 的使用 rebase 翻译为变基,它的作用和 merge 很相似,用于把一个分支的修改合并到另外一个分支上。 如下图所示,下图介绍了经过 rebase 前后提交历史的变化情况。 现在我们...
继续阅读 »

git rebase 让你的提交记录更加清晰可读


git rebase 的使用


rebase 翻译为变基,它的作用和 merge 很相似,用于把一个分支的修改合并到另外一个分支上。


如下图所示,下图介绍了经过 rebase 前后提交历史的变化情况。



现在我们来用一个例子来解释一下上面的过程。


假设我们现在有2条分支,一个为 master\color{#2196F3}{master} ,一个为 feature/1\color{#2196F3}{feature/1},他们都基于初始的一个提交 add readme\color{#2196F3}{add \ readme} 进行检出分支,之后,master 分支增加了 3.js\color{red}{3.js},和 4.js\color{red}{4.js} 的文件,分别进行了2次提交,feature/1\color{#2196F3}{feature/1} 也增加了 1.js\color{red}{1.js}2.js\color{red}{2.js} 的文件,分别对应以下2条提交记录。


master\color{#2196F3}{master} 分支如下图:



feature/1\color{#2196F3}{feature/1} 分支如下图:



结合起来看是这样的:



此时,切换到 feature/1 分支下,执行 git rebase master ,成功之后,通过 log 查看记录。


如下图所示:可以看到先是逐个应用了 master 分支的更改,然后以 master\color{#2196F3}{master} 分支最后的提交作为基点,再逐个应用 feature/1\color{#2196F3}{feature/1} 的每个更改。



所以,我们的提交记录就会非常清晰,没有分叉,上面演示的是比较顺利的情况,但是大部分情况下,rebase 的过程中会产生冲突的,此时,就需要手动解决冲突,然后使用 git addgit rebase --continue 的方式来处理冲突,完成 rebase,如果不想要某次 rebase 的结果,那么需要使用 git rebase --skip 来跳过这次 rebase


git merge 和 git rebase 的区别


不同于 git rebase的是,git merge 在不是 fast-forward(快速合并)的情况下,会产生一条额外的合并记录,类似 Merge branch 'xxx' into 'xxx' 的一条提交信息。



另外,在解决冲突的时候,用 merge 只需要解决一次冲突即可,简单粗暴,而用 rebase 的时候 ,需要一次又一次的解决冲突。


git rebase 交互模式


在开发中,常会遇到在一个分支上产生了很多的无效的提交,这种情况下使用 rebase 的交互式模式可以把已经发生的多次提交压缩成一次提交,得到了一个干净的提交历史,例如某个分支的提交历史情况如下:



进入交互式模式的方法是执行:


git rebase -i <base-commit>

参数 base-commit 就是指明操作的基点提交对象,基于这个基点进行 rebase 的操作,对于上述提交历史的例子,我们要把最后的一个提交对象 (ac18084\color{#F19E38}{ac18084}) 之前的提交压缩成一次提交,我们需要执行的命令格式是


git rebase -i ac18084

此时会进入一个 vim 的交互式页面,编辑器列出的信息像下列这样。



想要合并这一堆更改,我们要使用 squash 策略进行合并,即把当前的 commit 和它的上一个 commit 内容进行合并, 大概可以表示为下面这样。


pick  ... ...
s ... ...
s ... ...
s ... ...

修改文件后 按下 : 然后 wq 保存退出,此时又会弹出一个编辑页面,这个页面是用来编辑提交的信息,修改为 feat: 更正,最后保存一下,接着使用 git branch 查看提交的 commit 信息,rebase 后的提交记录如下图所示,是不是清爽了很多? rebase 操作可以让我们的提交历史变得更加清晰。




特别注意,只能在自己使用的 feature 分支上进行 rebase 操作,不允许在集成分支上进行 rebase,因为这种操作会修改集成分支的历史记录。



rebase 的风险



patch:【假设本地分支为 dev1,c1 和 c2 是本地往 dev1 分支上做的两次提交】把 dev1 分支上的c1和 c2 “拆”下来,并临时保存成 c1' 和 c2'。git 里将其称为 patch



rebase\color{red}{rebase} 会将当前分支的新提交拆下来,保存成 patch\color{red}{patch},然后合并进其他分支新的 commit\color{red}{commit},最后将 patch\color{red}{patch} 接进当前分支。这是 rebase\color{red}{rebase} 对多条分支的操作。对于单条分支,rebase\color{red}{rebase} 还能够合并多个 commit\color{red}{commit} 单号,将多个提交合并成一个提交。


git rebase -i [commit id]命令能够合并(整改) commit id 之前的所有 commit\color{red}{commit} 单。加上-i选项能够提供一个交互界面,分阶段修改commit信息并 rebase\color{red}{rebase}


但这里就会出现一个问题:如果你合并多个单号时,一不小心合并多了,将别人的提交也合并了,此时你本地的 commit history\color{red}{commit \ history} 和远程仓库的 commit history\color{red}{commit \ history} 不一样了,无论你如何 push\color{red}{push},都无法推送你的代码了。如果你并不记得 rebase\color{red}{rebase} 之前的 HEAD\color{red}{HEAD} 指向的 commit\color{red}{commit}commit ID\color{red}{commit \ ID} 的话,git reflog\color{red}{git \ reflog} 都救不了你。


tips:  你可以 push\color{red}{push} 时带上 f\color{red}{-f} 参数,强制覆盖远程 commit history\color{red}{commit \ history},你这样做估计会被打,因为覆盖之后,团队的其他人的本地 commit history\color{red}{commit \ history} 就与远程的不一样了,都无法推送了。


因此,请保证仅仅对自己私有的提交单进行 rebase\color{red}{rebase} 操作,对于已经合并进远程仓库的历史提交单,不要使用 rebase\color{red}{rebase} 操作合并 commit\color{red}{commit} 单。


作者:d_motivation
来源:juejin.cn/post/7277089907974357052
收起阅读 »

第一次使用缓存,因为没预热,翻车了

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。 悲惨的上线时刻 事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状...
继续阅读 »

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。


悲惨的上线时刻


事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状态,提单时也需要校验库存状态是否可售卖。但是由于库存状态的计算包含较复杂的业务逻辑,耗时比较高,在500ms以上。如果要在商品页面透出库存状态那么商品页面耗时增加500ms,这几乎是无法忍受的事情。


如何实现呢?最合适的方案当然是缓存了,我当时设计的方案是如果缓存有库存状态直接读缓存,如果缓存查不到,则计算库存状态,然后加载进缓存,同时设定过期时间。何时写库存呢? 答案是过期后,cache miss时重新加载进缓存。 由于计算逻辑较复杂,库存扣减等用户写操作没有同步更新缓存,但是产品认可库存状态可以有几分钟的状态不一致。为什么呢?


因为仓库有冗余库存,就算库存状态不一致导致超卖,也能容忍。同时库存不足以后,需要运营补充库存,而补充库存的时间是肯定比较长的。虽然补充库存完成几分钟后,才变为可售卖的,产品也能接受。 梳理完缓存的读写方案,我就沉浸于学习Redis的过程。


第一次使用缓存,我把时间和精力都放在Redis存储结构,Redis命令,Redis为什么那么快等方面的关注。如饥似渴的学习Redis知识。


直到上线阶段我也没有意识到系统设计的缺陷。


代码写的很快,测试验证也没有问题。然而上线过程中,就开始噼里啪啦的报警,开始我并没有想到报警这事和我有关。直到有人问我,“XXX,你是不是在上线库存状态的需求?”。


我人麻了,”怎么了,啥事”,我颤抖的问


“商品页面耗时暴涨,赶紧回滚”。一个声音传来


“我草”,那一瞬间,我的血压上涌,手心发痒,心跳加速,头皮发麻,颤抖的手不知道怎么在发布系统点回滚,“我没回滚过啊,咋回滚啊?”


“有降级开关吗”? 一个声音传来。


"没写..."。我回答的时候觉得自己真是二笔,为啥没加降级啊。(这也是复盘被骂的重要原因)


那么如何对缓存进行预热呢?


如何预热缓存


灰度放量


灰度放量实际上并不是缓存预热的办法,但是确实能避免缓存雪崩的问题。例如这个需求场景中,如果我没有放开全量数据,而是选择放量1%的流量。这样系统的性能不会有较大的下降,并且逐步放量到100%。


虽然这个过程中,没有主动同步数据到缓存,但是通过控制放量的节奏,保证了初始化缓存过程中,不会出现较大的耗时波动。


例如新上线的缓存逻辑,可以考虑逐渐灰度放量。


扫描数据库刷缓存


如果缓存维度是商品维度或者用户维度,可以考虑扫描数据库,提前预热部分数据到缓存中。


开发成本较高。除了开发缓存部分的代码,还需要开发扫描全表的任务。为了控制缓存刷新的进度,还需要使用线程池增加并发,使用限流器限制并发。这个方案的开发成本较高。


通过数据平台刷缓存


这是比较好的方式,具体怎么实现呢?


数据平台如果支持将数据库离线数据同步到Hive,Hive数据同步到Kafka,我们就可以编写Hive SQL,建立ETL任务。把业务需要被刷新的数据同步到Kafka中,再消费Kafka,把数据写入到缓存中。在这个过程中通过数据平台控制并发度,通过Kafka 分片和消费线程并发度控制 缓存写入的速率。


这个方案开发逻辑包括ETL 任务,消费Kafka写入缓存。这两部分的开发工作量不大。并且相比扫描全表任务,ETL可以编写更加复杂的SQL,修改后立即上线,无需自己控制并发、控制限流。在多个方面ETL刷缓存效率更高。


但是这个方案需要公司级别支持 多个存储系统之间可以进行数据同步。例如mysql、kafka、hive等。


除了首次上线,是否还有其他场景需要预热缓存呢?


需要预热缓存的其他场景


如果Redis挂了,数据怎么办


刚才提到上线前,一定要进行缓存预热。还有一个场景:假设Redis挂了,怎么办?全量的缓存数据都没有了,全部请求同时打到数据库,怎么办。


除了首次上线需要预热缓存,实际上如果缓存数据丢失后,也需要预热缓存。所以预热缓存的任务一定要开发的,一方面是上线前预热缓存,同时也是为了保证缓存挂掉后,也能重新预热缓存。


假如有大量数据冷启动怎么办


假如促销场景,例如春节抢红包,平时非活跃用户会在某个时间点大量打开App,这也会导致大量cache miss,进而导致雪崩。 此时就需要提前预热缓存了。具体的办法,可以考虑使用ETL任务。离线加载大量数据到Kafka,然后再同步到缓存。


总结



  1. 一定要预热缓存,不然线上接口性能和数据库真的扛不住。

  2. 可以通过灰度放量,扫描全表、ETL数据同步等方式预热缓存

  3. Redis挂了,大量用户冷启动的促销场景等场景都需要提前预热缓存。


作者:他是程序员
来源:juejin.cn/post/7277461864349777972
收起阅读 »

王兴入局大模型!美团耗资21亿拿下光年之外100%股权

【新智元导读】 正式官宣!美团收购光年之外全部权益,斥资20.65亿。**** 官宣了!美团以20.65亿人民币收购光年之外。 就在刚刚,美团在港交所公告,已订立交易协议收购光年之外的全部权益。 总代价包括现金233,673,600美元;债务承担人民币366,...
继续阅读 »
【新智元导读】 正式官宣!美团收购光年之外全部权益,斥资20.65亿。****

官宣了!美团以20.65亿人民币收购光年之外。


就在刚刚,美团在港交所公告,已订立交易协议收购光年之外的全部权益。


总代价包括现金233,673,600美元;债务承担人民币366,924,000元;现金人民币1.00元。




于公告日期,光年之外的净现金总额约为285,035,563美元。转让协议交割完成后,美团将持有光年之外100%权益。


前几天,光年之外联合创始人王慧文因健康问题暂时离岗引发许多人的关注。


甚至,外界关心诸多的是他的停职对公司造成哪些影响。




美团在公告中对于并购的解释是,通过收购事项获得领先的AGI技术及人才,有机会加强其于快速增长的人工智能行业中的竞争力。


这次,美团出手,意味着光年之外在后续运营有了足够资金支持。


同时,对美团来说,大模型能对未来业务转型也将产生有利帮助。


美团拿下光年之外



其实,在外界看来,美团收购光年之外,就像是板凳钉钉的事。


从感性层面讲,王兴与王慧文是清华的室友,在创业路上并肩作战。王慧文入局大模型后,王兴紧接着应声跟进。


在大模型爆火后,美团CEO王兴也对此表示极大的关注,甚至,在3月份还投资光年之外。


当时,王兴表示「AI大模型让我既兴奋于即将创造出来的巨大生产力,又忧虑它未来对整个世界的冲击。老王和在创业路上同行近二十年,既然他决心拥抱这次大浪潮,那我必须支持。」




从理性层面讲,自2019年美团将战略升级为「零售+科技」后,不论是王兴本人,还是公司来讲,对AI也投入非常大的兴趣。


当前,大模型已经成为兵家必争之地,国内许多头部科技纷纷入局。


据「豹变」独家报道,美团做大模型,已经有2个多月,几乎是与王兴投资光年之外同步进行的。


据称,算法团队正积极扩招,甚至还在筹划成立单独的「平台部门」,帮助美团大模型通过具体的商业化形式落地。


对美团来讲,智能配送系统、外卖无人车等场景,都需要AI驱动。


收购光年之外后,美团能够将大模型的能力,与自家核心业务相结合,比如外卖、本地生活服务等等。




此外,还能够在客服、物流、产品体验等各种场景中实现应用,将大模型能力与场景深度融合。


美团方面表示,并购完成后,将支持光年团队继续在大模型领域进行探索和研究。


所以说,美团的未来还是值得期待的。


而前几日,王慧文病倒的消息,让外界猜测纷纷,比如融资不顺利,或团队组建困难。


有国内媒体澄清道,光年之外A轮融资已经完成一个月,融资到账实际金额远高于外部报道的2.3亿美元,网传的“融资不顺利”消息,属于谣言。


此次美团在港交所的公告,也证实了这一点。


而在人才组队上,光年之外也进展顺利。


在成立后不久,光年之外就以换股形式收购了一流科技,原核心技术团队被保留。


而在两个月内,光年之外的研发团队规模就已经在70人左右,团队在算法等领域,研发经验丰富。


这样一支已经组建成熟的团队,在当下的大模型之战中,无疑属于稀缺资源。


美团选择收购光年之外,显然也是经过深思熟虑。


VC平稳退出



光年之外在6月初刚刚完成了的这笔2.3亿美元的融资,由源码资本领投。


腾讯、五源资本和快手创始人宿华也参与了这次融资。


从港交所披露的信息来看,除了6月初的这轮融资,红杉中国也在前期对光年之外进行了投资。


当王慧文因病离开光年之外的领导岗位之后,这些前期投资的VC都因为这突发的黑天鹅事件,可能面临着投资打水漂的风险。


但是随着美团的出手收购,这些参与光年之外的投资至少能在一定程度上落袋为安。


不用再担心因为被投公司核心创始人离职给被投公司带来的巨大影响。


王慧文辞任董事



此前,大模型创业4个月,王慧文就因身体原因停职休养。


紧接着,美团在港交所公布,王慧文已提出辞去公司非执行董事、公司董事会提名委员会成员和公司授权代表职务,自6月26日起生效。




在王慧文卸任董事后,美团宣布,执行董事穆荣均已获委任为授权代表,自2023年6月26日起生效。


另外,提名委员会将由冷雪松先生和沈向洋博士组成,冷雪松继续担任提名委员会主席。


在另一份公告中,美团更新了董事会成员。王兴和穆荣均担任公司执行董事,沈南鹏为非执行董事,欧高敦、冷雪松、沈向洋是独立非执行董事。



参考资料:


www1.hkexnews.hk/listedco/li…


作者:新智元
链接:https://juejin.cn/post/7253434752836632613
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

开发一个网站,用户密码你打算怎么存储

我们在开发网站或者APP时,首先要解决的问题,就是如何安全地传输和存储用户的密码。一些大公司的用户数据库泄露事件也时有发生,带来非常大的负面影响。因此,如何安全传输存储用户密码,是每位程序员必备的基础。本文将跟大家一起学习,如何安全传输存储用户的密码。 1....
继续阅读 »

我们在开发网站或者APP时,首先要解决的问题,就是如何安全地传输和存储用户的密码。一些大公司的用户数据库泄露事件也时有发生,带来非常大的负面影响。因此,如何安全传输存储用户密码,是每位程序员必备的基础。本文将跟大家一起学习,如何安全传输存储用户的密码。


image.png


1. 如何安全地传输用户的密码


要拒绝用户密码在网络上裸奔,我们很容易就想到使用https协议,那先来回顾下https相关知识吧~


1.1 https 协议


image.png



  • 「http的三大风险」


为什么要使用https协议呢?http它不香吗? 因为http是明文信息传输的。如果在茫茫的网络海洋,使用http协议,有以下三大风险:




  • 窃听/嗅探风险:第三方可以截获通信数据。

  • 数据篡改风险:第三方获取到通信数据后,会进行恶意修改。

  • 身份伪造风险:第三方可以冒充他人身份参与通信。



如果传输不重要的信息还好,但是传输用户密码这些敏感信息,那可不得了。所以一般都要使用https协议传输用户密码信息。



  • 「https 原理」


https原理是什么呢?为什么它能解决http的三大风险呢?



https = http + SSL/TLS, SSL/TLS 是传输层加密协议,它提供内容加密、身份认证、数据完整性校验,以解决数据传输的安全性问题。



为了加深https原理的理解,我们一起复习一下 一次完整https的请求流程吧~


image.png




  1. 客户端发起https请求

  2. 服务器必须要有一套数字证书,可以自己制作,也可以向权威机构申请。这套证书其实就是一对公私钥。

  3. 服务器将自己的数字证书(含有公钥、证书的颁发机构等)发送给客户端。

  4. 客户端收到服务器端的数字证书之后,会对其进行验证,主要验证公钥是否有效,比如颁发机构,过期时间等等。如果不通过,则弹出警告框。如果证书没问题,则生成一个密钥(对称加密算法的密钥,其实是一个随机值),并且用证书的公钥对这个随机值加密。

  5. 客户端会发起https中的第二个请求,将加密之后的客户端密钥(随机值)发送给服务器。

  6. 服务器接收到客户端发来的密钥之后,会用自己的私钥对其进行非对称解密,解密之后得到客户端密钥,然后用客户端密钥对返回数据进行对称加密,这样数据就变成了密文。

  7. 服务器将加密后的密文返回给客户端。

  8. 客户端收到服务器发返回的密文,用自己的密钥(客户端密钥)对其进行对称解密,得到服务器返回的数据。




  • 「https一定安全吗?」


https的数据传输过程,数据都是密文的,那么,使用了https协议传输密码信息,一定是安全的吗?其实不然




  • 比如,https 完全就是建立在证书可信的基础上的呢。但是如果遇到中间人伪造证书,一旦客户端通过验证,安全性顿时就没了哦!平时各种钓鱼不可描述的网站,很可能就是黑客在诱导用户安装它们的伪造证书!

  • 通过伪造证书,https也是可能被抓包的哦。



1.2 对称加密算法


既然使用了https协议传输用户密码,还是 「不一定安全」,那么,我们就给用户密码 「加密再传输」 呗~


加密算法有 「对称加密」「非对称加密」 两大类。用哪种类型的加密算法 「靠谱」 呢?



对称加密:加密和解密使用 「相同密钥」 的加密算法。



image.png
常用的对称加密算法主要有以下几种哈:


image.png
如果使用对称加密算法,需要考虑 「密钥如何给到对方」 ,如果密钥还是网络传输给对方,传输过程,被中间人拿到的话,也是有风险的哦。


1.3 非对称加密算法


再考虑一下非对称加密算法呢?



「非对称加密:」 非对称加密算法需要两个密钥(公开密钥和私有密钥)。公钥与私钥是成对存在的,如果用公钥对数据进行加密,只有对应的私钥才能解密。



image.png


常用的非对称加密算法主要有以下几种哈:


image.png



如果使用非对称加密算法,也需要考虑 「密钥公钥如何给到对方」 ,如果公钥还是网络传输给对方,传输过程,被中间人拿到的话,会有什么问题呢?「他们是不是可以伪造公钥,把伪造的公钥给客户端,然后,用自己的私钥等公钥加密的数据过来?」 大家可以思考下这个问题哈~



我们直接 「登录一下百度」 ,抓下接口请求,验证一发大厂是怎么加密的。可以发现有获取公钥接口,如下:


image.png
再看下登录接口,发现就是RSA算法,RSA就是 「非对称加密算法」 。其实百度前端是用了JavaScript库 「jsencrypt」 ,在github的star还挺多的。


image.png
因此,我们可以用 「https + 非对称加密算法(如RSA)」 传输用户密码~


2. 如何安全地存储你的密码?


假设密码已经安全到达服务端啦,那么,如何存储用户的密码呢?一定不能明文存储密码到数据库哦!可以用 「哈希摘要算法加密密码」 ,再保存到数据库。



哈希摘要算法:只能从明文生成一个对应的哈希值,不能反过来根据哈希值得到对应的明文。



2.1  MD5摘要算法保护你的密码


MD5 是一种非常经典的哈希摘要算法,被广泛应用于数据完整性校验、数据(消息)摘要、数据加密等。但是仅仅使用 MD5 对密码进行摘要,并不安全。我们看个例子,如下:


public class MD5Test {  
    public static void main(String[] args) {
        String password = "abc123456";
        System.out.println(DigestUtils.md5Hex(password));
    }
}

运行结果:
0659c7992e268962384eb17fafe88364


在MD5免费破解网站一输入,马上就可以看到原密码了。。。


image.png
试想一下,如果黑客构建一个超大的数据库,把所有20位数字以内的数字和字母组合的密码全部计算MD5哈希值出来,并且把密码和它们对应的哈希值存到里面去(这就是 「彩虹表」 )。在破解密码的时候,只需要查一下这个彩虹表就完事了。所以 「单单MD5对密码取哈希值存储」 ,已经不安全啦~


2.2  MD5+盐摘要算法保护用户的密码


那么,为什么不试一下MD5+盐呢?什么是 「加盐」



在密码学中,是指通过在密码任意固定位置插入特定的字符串,让散列后的结果和使用原始密码的散列结果不相符,这种过程称之为“加盐”。



用户密码+盐之后,进行哈希散列,再保存到数据库。这样可以有效应对彩虹表破解法。但是呢,使用加盐,需要注意一下几点:




  • 不能在代码中写死盐,且盐需要有一定的长度(盐写死太简单的话,黑客可能注册几个账号反推出来)

  • 每一个密码都有独立的盐,并且盐要长一点,比如超过 20 位。(盐太短,加上原始密码太短,容易破解)

  • 最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。



2.3 提升密码存储安全的利器登场,Bcrypt


即使是加了盐,密码仍有可能被暴力破解。因此,我们可以采取更 「慢一点」 的算法,让黑客破解密码付出更大的代价,甚至迫使他们放弃。提升密码存储安全的利器~Bcrypt,可以闪亮登场啦。



实际上,Spring Security 已经废弃了 MessageDigestPasswordEncoder,推荐使用BCryptPasswordEncoder,也就是BCrypt来进行密码哈希。BCrypt 生而为保存密码设计的算法,相比 MD5 要慢很多。



看个例子对比一下吧:


public class BCryptTest {  

    public static void main(String[] args) {
        String password = "123456";
        long md5Begin = System.currentTimeMillis();
        DigestUtils.md5Hex(password);
        long md5End = System.currentTimeMillis();
        System.out.println("md5 time:"+(md5End - md5Begin));
        long bcrytBegin = System.currentTimeMillis();
        BCrypt.hashpw(password, BCrypt.gensalt(10));
        long bcrytEnd = System.currentTimeMillis();
        System.out.println("bcrypt Time:" + (bcrytEnd- bcrytBegin));
    }
}

运行结果:


md5 time:47


bcrypt Time:1597


粗略对比发现,BCrypt比MD5慢几十倍,黑客想暴力破解的话,就需要花费几十倍的代价。因此一般情况,建议使用Bcrypt来存储用户的密码


3. 总结



  • 因此,一般使用https 协议 + 非对称加密算法(如RSA)来传输用户密码,为了更加安全,可以在前端构造一下随机因子哦。

  • 使用BCrypt + 盐存储用户密码。

  • 在感知到暴力破解危害的时候,「开启短信验证、图形验证码、账号暂时锁定」 等防御机制来抵御暴力破解。


作者:小王和八蛋
来源:juejin.cn/post/7260140790546251831
收起阅读 »

揭秘外卖平台的附近公里设计

背景 相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen带你一起揭秘。 分析 我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的...
继续阅读 »

背景


相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen带你一起揭秘。


分析


我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的商家。类似我下方的图展示:



想到了位置,我们自然想到了卫星定位,想到了二维的坐标。那这个需求我们有什么好的设计方案吗?


redis的GEO地理位置坐标这个数据结构刚好能解决我们的需求。


GEO


GEO 是一种地理空间数据结构,它可以存储和处理地理位置信息。它以有序集合(Sorted Set)的形式存储地理位置的经度和纬度,以及与之关联的成员。


以下是 Redis GEO 的一些常见操作:



  1. GEOADD key longitude latitude member [longitude latitude member ...]:将一个或多个地理位置及其成员添加到指定的键中。 示例:GEOADD cities -122.4194 37.7749 "San Francisco" -74.0059 40.7128 "New York"

  2. GEODIST key member1 member2 [unit]:计算两个成员之间的距离。 示例:GEODIST cities "San Francisco" "New York" km

  3. GEOPOS key member [member ...]:获取一个或多个成员的经度和纬度。 示例:GEOPOS cities "San Francisco" "New York"

  4. GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]:根据给定的经纬度和半径,在指定范围内查找与给定位置相匹配的成员。 示例:GEORADIUS cities -122.4194 37.7749 100 km WITHDIST COUNT 5


Redis 的 GEO 功能可用于许多应用场景,例如:



  • 位置服务:可以存储城市、商店、用户等位置信息,并通过距离计算来查找附近的位置。

  • 地理围栏:可以存储地理围栏的边界信息,并检查给定的位置是否在围栏内。

  • 最短路径:可以将城市或节点作为地理位置,结合图算法,查找两个位置之间的最短路径。

  • 热点分析:可以根据位置信息生成热力图,统计热门区域或目标位置的访问频率。


Redis 的 GEO 功能提供了方便且高效的方式来存储和操作地理位置信息,使得处理地理空间数据变得更加简单和快速。



默默的说一句,redis在路径规划下边竟然也这么厉害!



好的,那我们就来开始实现吧。今天我的操作还是用代码来展示,毕竟经纬度在控制台输入可能会出错。


代码实现


今天的案例是将湖北省武汉市各个区的数据存储在redis中,并以我所在的位置计算离别的区距离,以及我最近10km内的区。数据来源



我的测试代码如下,其中的运行结果也在对应的注释上有显示。



因为代码图片的宽度过长,导致代码字体很小,在移动端可尝试横屏观看;在PC端可尝试右键在新标签页打开图片。




以上的代码案例也参考:Redis GEO 常用 RedisTemplate API(Java),感谢作者提供的代码案例支持。


总结


对于需要存储地理数据和需要进行地理计算的需求,可以尝试使用redis进行解决。当然,elasticsearch也提供了对应的数据类型支持。有机会的话,shigen也会逐一的展开分析讲解。感谢伙伴们的支持。


shigen一起,每天不一样!


作者:shigen01
来源:juejin.cn/post/7275595571733282853
收起阅读 »

揭秘 Google Cloud Next '23:生成式 AI 的探索之路与开发范式变革

戳这里了解更多 前言: 8 月底,谷歌以「AI 与云科技驱动创新」为题,举办了为期三天的 Google Cloud Next ’23 大会,展示了谷歌在基础架构、数据和 AI、Workspace 协作和信息安全解决方案等全系列产品不断创新的成果。 乍看之下似乎...
继续阅读 »


戳这里了解更多


前言:


8 月底,谷歌以「AI 与云科技驱动创新」为题,举办了为期三天的 Google Cloud Next ’23 大会,展示了谷歌在基础架构、数据和 AI、Workspace 协作和信息安全解决方案等全系列产品不断创新的成果。


乍看之下似乎又是一场「大而全」的行业大会,但全程看完之后会明显的感受到,本次大会的内容全部围绕住了一个重心 —— 「生成式 AI」。


生成式 AI 作为近一两年最热门的技术话题没有之一,大家的谈论早已经超出了技术的范畴。如何应用、如何融合、如何落地,各行各业都在探索生成式 AI 带来的可能性。但除了 ChatGPT 这类的聊天机器人,似乎还没有特别成功的落地工具或者应用,哪怕是技术本源所在的研发领域也如是。


谷歌这次,似乎给出了一个参考答案。


一、Google Next '23:生成式 AI 的探索之路


生成式 AI 与传统 AI 技术最根本的区别在于前者通过理解自然语言创建内容,而后者依赖的是编程语言,这是生成式 AI 技术的关键变革特征,也是以前从未有过的能力。并且生成式 AI 能够以文本、图像、视频、音频和代码的形式生成新内容,而传统的 AI 系统训练计算机对人类行为、商业结果等进行预测。


对于许多人来说,第一次切身感知到生成式 AI 技术就是通过 ChatGPT。作为一种人工智能聊天机器人,在 2022 年 11 月迅速风靡全球。


大部分人不知道的是,ChatGPT 在架构层使用的是 Transformer 这一语言处理架构,该架构实际上便是谷歌在 2017 年的论文《Attention Is All You Need》中提出的。


谷歌作为一家成立了 25 年的公司,曾经在搜索、邮箱等领域取得了很多成绩,但在 AI 领域却面临了一些质疑。此前有媒体表示“谷歌在人工智能领域没有‘秘密武器’,无法赢得这场竞争。”而今年 5 月份的 Google I/O 以及前几日的 Google Cloud Next '23,可能正是在某种程度上回击了这种言论。


正如 Alphabet 和谷歌首席执行官桑达尔·皮查伊 (Sundar Pichai) 在活动开幕式上表示:


在过去几年与企业领导者的交谈中,我听到了一个类似的主题。从桌面到移动,到云,再到现在的人工智能,他们需要的是一直走在技术突破前沿的合作伙伴。很多转变确实令人兴奋,但同时也会带来不确定性。向人工智能的转变无疑就是如此。


作为一家公司,我们已经为这一时刻准备了一段时间。在过去的七年里,我们采取了人工智能先行的方法,应用人工智能使我们的产品从根本上更加可用。我们相信,让人工智能为每个人带来帮助,是我们在未来十年完成使命的最重要方式。


先内部小规模测试,再面向大众开放成熟的能力。谷歌也许确实没有“秘密武器”,但可能重点在于并不需要“秘密”,准备好之后,拿出来大家正面比划一下。这次的大会中,谷歌便亮出了其武器:


1**、**Cloud TPU v5e


生成式 AI 带来许多先进的功能,并可广泛使用于各种应用,但不可否认的是更加迫切的需要更先进、更强大的基础架构,设计和构建计算基础设施的传统方法已不足以满足生成式 AI 和大语言模型 (LLM) 等新兴工作负载的需求。为了解决这个问题,谷歌推出了 Cloud TPU v5e,一款最新且最具成本效益的 TPU。


TPU 是专门为大型人工智能模型的训练和推理而设计的定制人工智能芯片。客户可以使用单个 Cloud TPU 平台来运作大规模 AI 训练和推理。根据大会公开信息展示,Cloud TPU v5e 可扩展到数万个芯片并针对效率进行了优化。与 Cloud TPU v4 相比,每美元的训练效率可提升 2 倍,每美元的推论效率可提升 2.5 倍。


2**、**Vertex AI


在 2021 年 Google I/O 大会中,谷歌推出了 Vertex AI 托管式机器学习平台,用来帮助开发者更轻松地构建、部署和维护其机器学习模型。在本次的大会上,则正式推出了 Vertex AI 的搜索和对话功能,并将 ML 模型数量增加到 100 多个,这些模型都依据不同任务和不同大小进行了优化,包括文本、聊天、图像、语音、软件代码等等。


为了进一步平衡用户使用大模型进行建模的灵活性,以及他们可以生成的场景与推理成本以及微调能力,谷歌还为 Vertex AI 带来了扩展功能和 Grounding 等新的功能和工具。


借助 Vertex AI 扩展功能,开发者可以将 Model Garden 模型库中的模型与实时数据、专有数据或第三方平台(如 CRM 系统或电子邮件)连接起来,从而提供即时信息、集成公司数据并代表用户采取行动。这为生成式 AI 应用程序开辟了无限的新可能性。


Grounding 则是适用于 Vertex AI 基础模型、搜索及对话(Search and Conversation)的一项服务,可以协助客户将回复纳入企业自身的数据中,以提供更准确的回复内容。这一功能的重点在于可以一定程度上避免现阶段 AI 的“胡言乱语”,从而规避一些风险或者问题。


3**、**Duet AI


在 5 月的 I/O 大会上,Google Cloud 推出了 Duet AI。官方将其描述为“一位重要的协作伙伴、教练、灵感来源,和生产力推进器”,比如将 Docs 大纲转换成 Slides 中的演示文档,根据表格中的数据生成对应的图表;或者把 Duet AI 当做一个创作型的工具,用它来撰写电子邮件、生成图像、做会议纪要、检查文章的语法错误等等。


但当时的 Duet AI 只能在 Workspace 中使用,这次则扩展到了 Google Cloud 和 BigQuery 中,并推出更多适用的 AI 功能。例如 BigQuery 中的 Duet AI 旨在通过生成完整的函数和代码块,让用户专注于逻辑结果。它还可以建议和编写 Python 代码和 SQL 查询。这将进一步发挥 Duet AI "编码专家、软件可靠性工程师、数据库专家、数据分析专家和网络安全顾问 "的作用。


数据是生成式 AI 的核心,不难看出谷歌这次的更新迭代正式为了帮助数据团队进一步提高生产力,协助组织发挥数据及 AI 的最大潜力。


二、一些后续思考:生成式 AI 带来的开发范式变革


从基建、到平台再到应用,草蛇灰线,伏脉千里。谷歌在生成式 AI 领域的探索,其实并不像大家所想的有些“掉队”,而是在另一个维度提前布局。


25 年来,谷歌不断投资数据中心和网络,现在已经拥有涵盖 38 个云区域的全球网络,根据官方所说,目标是在 2030 年完全实现全天候采用无碳能源维持运营。谷歌的 AI 基础架构也在业界占据很大的份额,有超过 70% 的生成式 AI 独角兽公司和超过一半获得融资的生成式 AI 初创公司,都是 Google Cloud 客户。


"我们从每一层开始。这是对整个堆栈的重新构想。"这是英伟达的黄仁勋在 Google Cloud Next '23 中传递的一个态度,"生成式人工智能正在彻底改变计算堆栈的每一层。我们两家公司(英伟达和谷歌)拥有世界上最有才华的两支计算科学团队,将为生成式人工智能重新发明云基础设施。"


开发者关注的,是如何借助生成式 AI 的能力&工具提效;企业关注的,是如何借助生成式 AI 来迭代业务产品抢占市场心智。但对谷歌这类“搞基建”的公司而言,关注堆栈的每一层、关注堆栈的整体结构,才有可能推进技术的发展,实现传统开发范式的变革。


今年年初,谷歌推出了 Security AI Workbench,这是业界首创的可扩展平台,由谷歌的新一代安全性大语言模型 Sec-PaLM 2 驱动,结合了谷歌独有的观测技术,能帮助开发者掌握不断变化的安全性威胁,并针对网络安全操作进行微调。


几周前,谷歌推出 Chronicle CyberShield,能解决数据孤岛的问题,也能集中管理安全性数据,并统一规划处理方式。


“我们正处于一个由人工智能推动的全新数字化转型时代,”Google Cloud 首席执行官库里安说,“这项技术已经在改善企业的运营方式以及人类之间的互动方式。它正在改变医生照顾病人的方式、人们沟通的方式,甚至我们在工作中的安全方式。而这仅仅是个开始。”


生成式 AI 通过 ChatGPT 类的工具产品,已经在艺术创作、代码生成等领域带来了未曾设想过的便利,随着基础设施的迭代演进,相信现阶段的开发范式变革,可能真的仅仅是个开始。


结语:


生成式 AI 兴起之后,业界纷纷提出“想象力等于生产力”之类的观点,并借助一些场景的应用为佐证。谷歌这次的大会发布,无论是对生成式 AI 技术的推动,还是开发工具&服务的迭代,都给了我们更多的信心与想象的方向。


无论是从 AI 最佳化的基础架构,到注入了生成式 AI 强大功能的数据分析和信息安全服务;还是从增加了更多新模型和工具的 Vertex AI 平台,到扩大了支持 Duet AI 的 Workspace 和 Google Cloud,这些技术都是难得的探索与尝试,这些演变或者变革都是迈向下一次重大演变的正确方向的垫脚石。


对于开发者这一最了解技术本质的人群而言,我们能做的就是拥抱变化与发展,与企业、社区、生态一起,持续探索与创新。在变革到来前,找到要去的方向;在变革到来后,找到自己的位置。


Tips:会后的配套学习资料,给你准备好了!


为了让中国的开发者们更好地 Get 新技术、新发展,Google Cloud 今年同样安排了会后的配套系列课程 —— 「Next ’23 中文精选课」。


今年的课程将聚焦 AI/ML、安全合规、数据库、数据分析、DevOps、应用程序开发等领域,解读最新技术发布与行业实践应用,解读 Next ’23 发布的 100 项创新成果 。


发布会中没来得及讲的、没讲完的,都在这次的课程中了👌


据官方的信息展示,这次的中文精选课不仅有技术干货,更给开发者提供了多种互动方式体验,以及一批 Google Cloud 官方周边礼品,旅行颈枕、无线鼠标、电竞游戏耳机、蓝牙音箱,甚至还有 Google 25 周年纪念版安卓小人!


作者:稀土君
链接:https://juejin.cn/post/7277395050508730425
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

很佩服的一个Google大佬,离职了。。

这两天,科技圈又有一个突发的爆款新闻相信不少同学都已经看到了。 那就是75岁的计算机科学家Geoffrey Hinton从谷歌离职了,从而引起了科技界的广泛关注和讨论。 而Hinton自己也证实了这一消息。 提到Geoffrey Hinton这个名字,对于...
继续阅读 »

这两天,科技圈又有一个突发的爆款新闻相信不少同学都已经看到了。


那就是75岁的计算机科学家Geoffrey Hinton从谷歌离职了,从而引起了科技界的广泛关注和讨论。



而Hinton自己也证实了这一消息。




提到Geoffrey Hinton这个名字,对于一些了解过AI人工智能和机器学习等领域的同学来说,应该挺熟悉的。


Hinton是一位享誉全球的人工智能专家,被誉为“神经网络之父”、“深度学习的鼻祖”、“人工智能教父”等等,在这个领域一直以来都是最受尊崇的泰斗之一。



作为人工智能领域的先驱,他的工作和成就也对该领域的后续发展产生了深远的影响。




其实算一下时间,距离Hinton 2013年加入谷歌,已经也有十个年头了。


据报道,Hinton在4月份其实就提出了离职,并于后来直接与谷歌CEO劈柴哥(Sundar Pichai)进行了交谈。


Hinton在接受媒体访谈时表示,他非常关注人工智能的风险,并表示对自己多年的工作和研究存在遗憾。


正当大家都在好奇Hinton离职原因的时候,Hinton自己却表示,这样一来可以更加自由地讨论人工智能的风险。





1947年,Geoffrey Hinton出生于英国温布尔登的一个知识分子家庭。



他的父亲Howard Everest Hinton是一个研究甲壳虫的英国昆虫学家,而母亲Margaret Clark则是一名教师。


除此之外,他的高曾祖父George Boole还是著名的逻辑学家,也是现代计算科学的基础布尔代数的发明人,而他的叔叔Colin Clark则是一个著名的经济学家。


如此看来,Hinton家庭里的很多成员都在学术和研究方面都颇有造诣。




Hinton主要从事神经网络和机器学习的研究,在AI领域做出过许多重要贡献,其中最著名的当属他在神经网络领域所做的研究工作。



他在20世纪80年代就已经开启了反向传播算法(Back Propagation, BP算法)的研究,并将其应用于神经网络模型的训练中。这一算法被广泛应用于语音识别、图像识别和自然语言处理等领域。



除此之外,Hinton还在卷积神经网络(Convolutional Neural Networks,CNN)、深度置信网络(Deep Belief Networks,DBN)、递归神经网络(Recursive Neural Networks,RNN)、胶囊网络(Capsule Network)等领域做出了重要贡献。




2013年,Hinton加入Google,同时把机器学习相关的很多技术带进了谷歌,同时融合到谷歌的多项业务之中。



2019年3月,ACM公布了2018年度的图灵奖得主。


图灵奖大家都知道,是计算机领域的国际最高奖项,也被誉为“计算机界的诺贝尔奖”。


而Hinton则与蒙特利尔大学计算机科学教授Yoshua Bengio和Meta首席AI科学家Yann LeCun一起因为研究神经网络而获得了该年度的图灵奖,以表彰他们在对应领域所做的杰出贡献。



除此之外,Hinton在他的学术生涯中发表了数百篇论文,这些论文中提出了许多重要的理论和方法,涵盖了人工智能、机器学习、神经网络、计算机视觉等多个领域。


而且他的论文被引用的次数也是惊人,这对于这些领域的研究和发展都产生了重要的影响。





除了自身在机器学习方面的造诣很高,Hinton同时也是一个优秀的老师。


Hinton带过很多大牛学生,其中不少都被像苹果、Google等这类硅谷科技巨头所挖走,在对应的公司里领导着人工智能相关领域的研究。


这其中最典型的就是Ilya Sutskever,他是Hinton的学生,同时他也是最近大名鼎鼎的OpenAI公司的联合创始人和首席科学家。



聊到这里,不得不感叹大佬们的创造力以及对这个领域所作出的贡献。


既然离开了谷歌,那也就意味着将开启一段新的旅程,也期待着大佬后续给大家带来更多精彩的故事。


好了,以上就是今天的文章内容,感谢大家的收看,我们下期见。


作者:CodeSheep
链接:https://juejin.cn/post/7229687757848690749
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一个简单的TODO,原来这么好用

平常我们再开发的时候,遇到一些想要之后去编写的部分,或者说再开发某个模块的时候,突然被事情打断,暂时无法实现的代码,以后才会去修复的bug的时候,要如何精准快速的去定位到那个位置呢? 下面来介绍一个很多人会忽律的标记TODO TODO是一个特殊的标记,用于标识...
继续阅读 »

平常我们再开发的时候,遇到一些想要之后去编写的部分,或者说再开发某个模块的时候,突然被事情打断,暂时无法实现的代码,以后才会去修复的bug的时候,要如何精准快速的去定位到那个位置呢?


下面来介绍一个很多人会忽律的标记TODO


TODO是一个特殊的标记,用于标识需要实现但目前还未实现的功能。这是一个Javadoc的标签,因此它只能应用于类、接口和方法。


它可以帮助我们跟踪和管理开发中的待办事项。


使用方法


首先看一个最基本的使用方法


@RestController
public class TestController {

@GetMapping("/hello")
public String hello(){
//TODO do something
return "Hello World";
}
}

这里我们加上TODO。之后再需要去进行修改的时候。


直接去搜索就可以了


image-20230906195743692


除了这个方法,还有很多隐藏的方法


进入设置


image-20230906195949934


这里就可以自定义todo了


如果是团队协作的话,每个人可以自定义其他的todo类型。


也可以用自己喜欢的更加醒目的颜色


image-20230906200230765


同时也可以在idea中进行全局的todo查看


image-20230906200444351


除了这个之外,还有过滤器,可以进行自定义的todo类型


image-20230906200527489


阿里巴巴Java开发手册中对TODO的规范标注主要有以下两点:



  1. TODO:表示需要实现,但目前还未实现的功能。这个标记通常用于类、接口和方法中。

  2. FIXME:标记某代码是错误的,而且不能工作,需要及时纠正的情况。


最佳实践


编写一个代码模板


image-20230906201219291


image-20230906201810835


这样,就是一个最佳的实战了。


作者:小u
来源:juejin.cn/post/7276696131113959458
收起阅读 »

听说你会架构设计?来,弄一个打车系统

目录 引言 网约车系统 需求设计 概要设计 详细设计 体验优化 小结 1.引言 1.1 台风来袭 深圳上周受台风“苏拉”影响,从 9 月 1 日 12 时起在全市启动防台风和防汛一级应急响应。 对深圳打工人的具体影响为,当日从下午 4 点起全市...
继续阅读 »

目录




  1. 引言

  2. 网约车系统



    1. 需求设计

    2. 概要设计

    3. 详细设计

    4. 体验优化



  3. 小结



1.引言


1.1 台风来袭


深圳上周受台风“苏拉”影响,从 9 月 1 日 12 时起在全市启动防台风和防汛一级应急响应。


对深圳打工人的具体影响为,当日从下午 4 点起全市实行 “五停”:停工、停业、停市,当日已经停课、晚上 7 点后停运。


由于下午 4 点停市,于是大部分公司都早早下班。其中有赶点下班的,像这样:



有提前下班的,像这样:



还有像我们这样要居家远程办公的:



1.2 崩溃打车


下午 4 点左右,公交和地铁都人满为患。


于是快下班(居家办公)的时候就想着打个车回家,然而打开滴滴之后:



排队人数 142 位,这个排队人数和时长,让我的心一下就拔凉拔凉的。


根据历史经验,在雨天打上车的应答时间得往后推半个小时左右。更何况,这还是台风天气!


滴滴啊滴滴,你就不能提前准备一下嘛,这个等待时长,会让你损失很多订单分成的。


但反过来想,这种紧急预警,也不能完全怪打车平台,毕竟,车辆调度也是需要一定时间的。在这种大家争相逃命(bushi 的时候,周围的车辆估计也不太够用。


卷起来


等着也是等着,于是就回到公司继续看技术文章。这时我突然想到,经过这次车辆紧急调度之后,如果我是滴滴的开发工程师,需要怎么处理这种情况呢?


如果滴滴的面试官在我眼前,他又会怎么考量候选人的技术深度和产品思维呢?


2. 设计一个“网约车系统”


面试官:“滴滴打车用过是吧!看你简历里写道会架构设计是吧,如果让你设计一个网约车系统,你会从哪些方面考虑呢?”


2.1 需求分析


网约车系统(比如滴滴)的核心功能是把乘客的打车订单发送给附件的网约车司机,司机接单后,到上车点接送乘客,乘客下车后完成订单。


其中,司机通过平台约定的比例抽取分成(70%-80%不等),乘客可以根据第三方平台的信用值(比如支付宝)来开通免密支付,在下车后自动支付订单。用例图如下:



乘客和司机都有注册登录功能,分属于乘客用户模块和司机用户模块。网约车系统的另外核心功能是乘客打车,订单分配,以及司机送单。


2.2 概要设计


网约车系统是互联网+共享资源的一种模式,目的是要把车辆和乘客结合起来,节约已有资源的一种方式,通常是一辆网约车对多个用户。


所以对于乘客和司机来说,他们和系统的交互关系是不同的。比如一个人一天可能只打一次车,而一个司机一天得拉好几趟活。


故我们需要开发两个 APP 应用,分别给乘客和司机打车和接单,架构图如下:



1)乘客视角


如上所示:乘客在手机 App 注册成为用户后,可以选择出发地和目的地,进行打车。


打车请求通过负载均衡服务器,经过请求转发等一系列筛选,然后到达 HTTP 网关集群,再由网关集群进行业务校验,调用相应的微服务。


例如,乘客在手机上获取个人用户信息,收藏的地址信息等,可以将请求转发到用户系统。需要叫车时,将出发地、目的地、个人位置等信息发送至打车系统


2)司机视角


如上图所示:司机在手机 App 注册成为用户并开始接单后,打开手机的位置信息,通过 TCP 长连接定时将自己的位置信息发送给平台,同时也接收平台发布的订单消息。



司机 App 采用 TCP 长连接是因为要定时发送和接收系统消息,若采用 HTTP 推送:


一方面对实时性有影响,另一方面每次通信都得重新建立一次连接会有失体面(耗费资源)。​



司机 App:每 3~5 秒向平台发送一次当前的位置信息,包括车辆经纬度,车头朝向等。TCP 服务器集群相当于网关,只是以 TCP 长连接的方式向 App 提供接入服务,地理位置服务负责管理司机的位置信息。


3)订单接收


网关集群充当业务系统的注册中心,负责安全过滤,业务限流,请求转发等工作。


业务由一个个独立部署的网关服务器组成,当请求过多时,可以通过负载均衡服务器将流量压力分散到不同的网关服务器上。


当用户打车时,通过负载均衡服务器将请求打到某一个网关服务器上,网关首先会调用订单系统,为用户创建一个打车订单(订单状态为 “已创建”),并存库。


然后网关服务器调用打车系统,打车系统将用户信息、用户位置、出发地、目的地等数据封装到一个消息包中,发送到消息队列(比如 RabbitMQ),等待系统为用户订单分配司机。


4)订单分配


订单分配系统作为消息队列的消费者,会实时监听队列中的订单。当获取到新的订单消息时,订单分配系统会将订单状态修改为 “订单分配中”,并存库。


然后,订单分配系统将用户信息、用户位置、出发地、目的地等信息发送给订单推送 SDK


接着,订单推送 SDK 调用地理位置系统,获取司机的实时位置,再结合用户的上车点,选择最合适的司机进行派单,然后把订单消息发送到消息告警系统。这时,订单分配系统将订单状态修改为 “司机已接单” 状态。


订单消息通过专门的消息告警系统进行推送,通过 TCP 长连接将订单推送到匹配上的司机手机 App。


5)拒单和抢单


订单推送 SDK 在分配司机时,会考虑司机当前的订单是否完成。当分配到最合适的司机时,司机也可以根据自身情况选择 “拒单”,但是平台会记录下来评估司机的接单效率。


打车平台里,司机如果拒单太多,就可能在后续的一段时间里将分配订单的权重分数降低,影响自身的业绩。



订单分派逻辑也可以修改为允许附加的司机抢单,具体实现为:


当订单创建后,由订单推送 SDK 将订单消息推送到一定的地理位置范围内的司机 App,在范围内的司机接收到订单消息后可以抢单,抢单完成后,订单状态变为“已派单”。


2.3 详细设计


打车平台的详细设计,我们会关注网约车系统的一些核心功能,如:长连接管理、地址算法、体验优化等。


1)长连接的优势


除了网页上常用的 HTTP 短连接请求,比如:百度搜索一下,输入关键词就发起一个 HTTP 请求,这就是最常用的短连接。


但是大型 APP,尤其是涉及到消息推送的应用(如 QQ、微信、美团等应用),几乎都会搭建一套完整的 TCP 长连接通道。


一张图看懂长连接的优势:



图片来源:《美团点评移动网络优化实践》


通过上图,我们得出结论。相比短连接,长连接优势有三:




  1. 连接成功率高




  2. 网络延时低




  3. 收发消息稳定,不易丢失




2)长连接管理


前面说到了长连接的优势是实时性高,收发消息稳定,而打车系统里司机需要定期发送自身的位置信息,并实时接收订单数据,所以司机 App 采用 TCP 长连接的方式来接入系统。


和 HTTP 无状态连接不同的是,TCP 长连接是有状态的连接。所谓无状态,是指每次用户请求可以随意发送到某一台服务器上,且每台服务器的返回相同,用户不关心是哪台服务器处理的请求。



当然,现在 HTTP2.0 也可以是有状态的长连接,我们此处默认是 HTTP1.x 的情况。



而 TCP 长连接为了保证传输效率和实时性,服务器和用户的手机 App 需要保持长连接的状态,即有状态的连接。


所以司机 App 每次信息上报或消息推送时,都会通过一个特定的连接通道,司机 App 接收消息和发送消息的连接通道是固定不变的。


因此,司机端的 TCP 长连接需要进行专门管理,处理司机 App 和服务器的连接信息,架构图如下:



为了保证每次消息的接收和推送都能找到对应通道,我们需要维护一个司机 App 到 TCP 服务器的映射关系,可以用 Redis 进行保存。


当司机 App 第一次登录,或者和服务器断开连接(比如服务器宕机、用户切换网络、后台关闭手机 App 等),需要重连时,司机 App 会通过用户长连接管理系统重新申请一个服务器连接(可用地址存储在 Zookeeper 中),TCP 连接服务器后再刷新 Redis 的缓存。


3)地址算法


当乘客打车后,订单推送 SDK 会结合司机所在地理位置,结合一个地址算法,计算出最适合的司机进行派单。


目前,手机收集地理位置一般是收集经纬度信息。经度范围是东经 180 到西经 180,纬度范围是南纬 90 到北纬 90。


我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成4个部分。



根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识司机和乘客的位置信息。再通过 Redis 的 GeoHash 算法,来获取乘客附加的所有司机信息。


GeoHash 算法的原理是将乘客的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有司机


它的实现用到了跳表数据结构,具体实现为:


将某个市区的一块范围作为 GeoHash 的 key,这个市区范围内所有的司机存储到一个跳表中,当乘客的地理位置出现在这个市区范围时,获取该范围内所有的司机信息。然后进一步筛选出最近的司机信息,进行派单。


4)体验优化


1. 距离算法


作为线上派单,通过距离运算来分配订单效果一定会比较差,因为 Redis 计算的是两点之间的空间距离,但司机必须沿道路行驶过来,在复杂的城市路况下,也许几十米的空间距离行驶十几分钟也未可知。


所以,后续需综合行驶距离(而非空间距离)、司机车头朝向以及上车点进行路径规划,来计算区域内每个司机到达乘客的距离和时间。


更进一步,如果区域内有多个乘客和司机,就要考虑所有人的等待时间,以此来优化用户体验,节省派单时间,提升盈利额。



2. 订单优先级


如果打车订单频繁取消,可根据司机或乘客行为进行判责。判责后给乘客和司机计算信誉分,并告知用户信誉分会影响乘客和司机的使用体验,且关联到派单的优先级。


司机接单优先级

综合考虑司机的信誉分,投诉次数,司机的接单数等等,来给不同信誉分的司机分配不同的订单优先级。


乘客派单优先级

根据乘客的打车时间段,打车距离,上车点等信息,做成用户画像,以合理安排司机,或者适当杀熟(bushi。


PS:目前有些不良打车平台就是这么做的 🐶  甚至之前爆出某打车平台,会根据不同的手机系统,进行差异收费


4. 小结


4.1 网约车平台发展


目前,全球网约车市场已经达到了数千亿美元的规模,主要竞争者包括滴滴、Uber、Grab 等公司。在中国,滴滴作为最大的网约车平台已经占据了绝大部分市场份额。


网约车的核心商业逻辑比较简单,利益关联方主要为平台、司机、车辆、消费者。


平台分别对接司机、车辆【非必选项,有很多司机是带车上岗】和乘客,通过有效供需匹配赚取整个共享经济链省下的钱。


具体表现为:乘客和司机分别通过网约平台打车和接单,平台提供技术支持。乘客为打车服务付费,平台从交易金额中抽成(10%-30%不等)。



据全国网约车监管信息交互平台统计,截至 2023 年 2 月底,全国共有 303 家网约车平台公司取得网约车平台经营许可。


这些平台一部分是依靠高德打车、百度地图、美团打车为代表的网约车聚合平台;另一部分则是以滴滴出行、花小猪、T3 为代表的出行平台


4.2 网约车平台现状


随着出行的解封,网约车平台重现生机。


但由于部分网约车聚合平台的准入门槛太低,所以在过去一段时间里暴露出愈来愈多的问题。如车辆、司机合规率低,遇到安全事故,产生责任纠纷,乘客维权困难等等。


由于其特殊的模式,导致其与网约车运营商存在责任边界问题,一直游离在法律边缘。



但随着网约车聚合平台的监管不断落地,全国各地都出行了一定的监管条例。


比如某打车平台要求车辆将司机和乘客的沟通记录留档,除了司机与乘客的在线沟通记录必须保存以外,还需要一个语音电话或车载录音转换,留存一段时间备查。


有了这些人性化的监管条例和技术的不断创新,网约车平台或许会在未来的一段时间内,继续蓬勃发展。


后话


面试官:嗯,又专又红,全面发展!这小伙子不错,关注了~


作者:xin猿意码
来源:juejin.cn/post/7275211391102746684
收起阅读 »

马斯克的Twitter迎来严重危机,我国的超级App模式是否能拯救?

Meta公司近期推出的Threads 被网友戏称为“Twitter杀手”,该应用上线仅一天,用户就突破了3000 万人。外界普遍认为,这是推特上线17年来遭遇的最严峻危机。面对扎克伯格来势汹汹的挑战马斯克会如何快速组织反击? 前段时间闹得沸沸扬扬的“马扎大战”...
继续阅读 »

Meta公司近期推出的Threads 被网友戏称为“Twitter杀手”,该应用上线仅一天,用户就突破了3000 万人。外界普遍认为,这是推特上线17年来遭遇的最严峻危机。面对扎克伯格来势汹汹的挑战马斯克会如何快速组织反击?


前段时间闹得沸沸扬扬的“马扎大战”再出新剧情,继“笼斗”约架被马斯克妈妈及时叫停之后,马斯克在7月9日再次向扎克伯克打起嘴炮,这次不仅怒骂小扎是混蛋,还要公开和他比大小?!!此番马斯克的疯狂言论,让网友直呼他不是疯了就是账号被盗了。



互联网各路“吃瓜群众”对于大佬们宛如儿戏般的掐架喜闻乐见,摇旗呐喊!以至于很多人忘了这场闹剧始于一场商战:“马扎大战”开始之初,年轻的扎克伯格先发制人,率先挥出一记左钩拳——Threads,打得老马措手不及。


Threads 被网友戏称“Twitter杀手”,该应用上线仅一天,用户就突破了3000 万人。其中,不乏从推特中逃离的各界名流。舆论普遍认为,这是Twitter上线17年来遭遇的最严峻危机。



紧接着马斯克还以一记右勾拳,一封律师函向小扎发难,称Meta公司“非法盗用推特的商业秘密和其他知识产权的行为”。虽然Meta公司迅速回应,否认其团队成员中有Twitter的前雇员。但这样的回应似乎没有什么力度,Threads在功能、UI设计上均与Twitter相似,并在相关宣传中表示,Threads“具有良好的运营”,并称其为当前“一片混乱中的”Twitter的绝佳替代品。


社交平台之战的第一个回合,小扎向老马发起了猛烈的攻势。吃了一记闷拳的马斯克除了打嘴炮之外,会如何快速组织有效的反击?


会不会是老马嘴里的“非秘密武器”App X —App of Everything?


超级App或成为Twitter反击重拳


时间回溯到去年,在收购Twitter之前,马斯克就放出豪言即将创建一款他称之为“App X”的功能包罗万有的超级应用软件(Super App), 在他的愿景中,超级 “App X”就如同多功能瑞士军刀(Swiss Army Knife)般,能够包办用户日常生活大小事,包括:社交、购物、打车、支付等等。他希望这款App可以成为美国首个集食、衣、住、行功能于一身的平台。收购Twitter,似乎给了他改造实现这个超级App的起步可能。


马斯克坦言这是从微信的经营模式中汲取的灵感。微信一直被视为“超级应用程序”的代表,作为一体化平台,满足了用户的各种需求,包括即时通讯、社交、支付等等。在去年6月的推特全体员工大会上,马斯克就表示“我们还没有一个像微信那样优秀的应用,所以我的想法是为何不借鉴微信”。马斯克还在推特上写到“购买推特是创建App X的加速器,这是一个超级App(Everything App)。”


从他接手Twitter的任期开始,马斯克便加快推动超级 “App X”的发展步伐。对标于微信,除了社交功能之外,还将推出支付与电子商务。而获得监管许可是实现支付服务的重要第一步,支付也成了推特转型超级 “App X”的第一步,除了商业的必要性外,此举多少还有点宿命感。要知道,马斯克是从支付行业起家的,1999 年他投资 1200 万美元与Intuit前首席执行官 Bill Harris 共同创立了 X.com,而这家公司就是PayPal的前身。


据英国《金融时报》 1月份报道,Twitter 已经开始申请联邦和州监管许可。同时Twitter内部正在开发电子支付功能,未来更会整合其他金融服务,以实现超级App的终极目标。


但是,在亚洲“超级应用”巨头之外,几乎没有消息应用实现支付服务的先例,Whats App和Telegram 都未推出类似服务。老马领导下的Twitter,能不能成功?


添加了支付能力,也只不过是迈向“超级”的第一小步。挑战在于怎么把“everything”卷进来:衣食住行的数字服务、各行各业的商业场景。在微信世界,everything = 小程序。老马是否也要开发一套Twitter版小程序技术、缔造一个“Twitter小程序”宇宙?



“超级App”技术已实现普世化


事实上,马斯克并非“Super App ”技术理念在欧美的唯一拥趸。超级App的雄心壮志多年来早已成为美国公司管理层炫酷PPT展示中的常客了,甚至连沃尔玛都曾考虑过超级App的计划。


全球权威咨询机构Gartner发布的企业机构在2023年需要探索的十大战略技术趋势中也提到了超级应用。并预测,到2027年,全球50%以上的人口将成为多个超级应用的日活跃用户。


国外互联网巨头们开始对超级App技术趋之若鹜,但超级App的技术,是不是只有巨头才能拥有呢?


答案是否定的。互联网技术往往领先于企业应用5~7年,现在这个技术正在进入企业软件世界,任何行业的任何企业都可以拥有。


一种被称为“小程序容器”的技术,是构建超级App的核心,目前已经完全实现普及商用。背后推手是 FinClip,它作为当前市场上唯一独立小程序容器技术产品,致力于把制造超级App的技术带进各行各业,充当下一代企业数字化软件的技术底座。


超级App的技术实现,原理上是围绕一种内容载体,由三项技术共同组成:内容载体通常是某种形态的“轻巧应用”——读者最容易理解的,当然就是小程序,万事万物的数字场景,以小程序形态出现。马斯克大概率在把Twitter改造成他所谓的App X的过程中,要发展出一种类似的东西。反正在国内这就叫小程序,在W3C正在制定的标准里,这叫做Mini-App。我们就姑且依照大家容易理解的习惯,把这种“轻巧应用”称之为小程序吧。


围绕小程序,一个超级App需要在设备端实现“安全沙箱”+ “运行时”,负责把小程序从网上下载、关在一个安全隔离环境中,然后解释运行小程序内容;小程序内容的“镜像”(也就是代码包),则是发布在云端的小程序应用商店里,供超级App的用户在使用到某个商业场景或服务的时候,动态下载到设备端按需运行 – 随需随用且可以用完即弃。小程序应用商店负责了小程序的云端镜像“四态合一“(开发、测试、灰度、投产)的发布管理。


不仅仅这样,超级App本质上是一个庞大的数字生态平台,里面的小程序内容,并不是超级App的开发团队开发的,而是由第三方“进驻”和“上架”,所以,超级App还有一个非常重要的云端运营中心,负责引进和管理小程序化的数字内容生态。


超级App之所以“超级”,是因为它的生命周期(开发、测试、发版、运营),和运行在它里面的那些内容(也就是小程序)的生命周期完全独立,两者解耦,从而可运行“全世界”为其提供的内容、服务,让“全世界”为它提供“插件”而无需担心超级App本身的安全。第三方的内容无论是恶意的、有安全漏洞的或者其他什么潜在风险,并不能影响平台自身的安全稳定、以及平台上由其他人提供的内容安全保密。在建立了这样的安全与隔离机制的基础上,超级App才能实现所谓的“Economy of Scale”(规模效应),可以大开门户,放心让互联网上千行百业的企业、个人“注入插件”,产生丰富的、包罗万有的内容。


对于企业来说,拥有一个自己的超级App意味着什么呢?是超级丰富的业务场景、超级多元的合作生态、超级数量的内容开发者、以及超级敏捷的运营能力。相比传统的、封闭的、烟囱式的App,超级App实际上是帮助企业突破传统边界、建立安全开放策略、与合作伙伴实现数字化资源交换的技术手段,真正让一家企业具备平台化商业模式,加速数字化转型、增强与世界的在线连接、形成自己的网络效应。


超级App不是一个App -- Be A“world” platform


超级App+小程序,这不是互联网大平台的专利。对于传统企业来说,考虑打造自己的超级App动因至少有三:


首先,天下苦应用商店久矣。明明是纯粹企业内部一个商业决策行为,要发布某个功能或服务到自己的App上从而触达自己的客服服务自己的市场,这个发版却不得不经过不相干的第三方(App store们)批准。想象一下,你是一家银行,现在你计划在你的“数字信用卡”App里更新上架某个信用卡服务功能,你的IT完成了开发、测试,你的信用卡业主部门作了验收,你的合规、风控、法务部门通过内部的OA系统环环相扣、层层审批,现在流程到了苹果、谷歌… 排队等候审核,最后流程回到IT,服务器端一顿操作配合,正式开闸上线。你的这个信用卡服务功能,跟苹果谷歌们有一毛钱关系?但对不起,他们在你的审批流程里拥有终极话语权。


企业如果能够控制业务内容的技术实现粒度,通过自己的“服务商店”、“业务内容商店”去控制发布,让“宿主”App保持稳定,则苹果谷歌们也不用去操这个心你的App会不会每次更新都带来安全漏洞或者其他风险行为。


第二,成为一个“world platform”,企业应该有这样的“胸襟”和策略。虽然你可能不是腾讯不是推特不拥有世界级流量,这不妨碍你成为自己所在细分市场细分领域的商业世界里的平台,这里背后的思路是开放——开放平台,让全“世界”的伙伴成为我的生态,哪怕那个“世界”只存在于一个垂直领域。而这,就是数字化转型。讲那么多“数字化转型”理念,不如先落地一个技术平台作为载体,talk is cheap,show me the code。当你拥有一个在自己那个商业世界里的超级App和数以百千计的小程序的时候,你的企业已经数字化转型了。


第三,采用超级App是最有效的云化策略,把你和你的合作伙伴的内容作为小程序,挪到云端去,设备端只是加载运行和安全控制这些小程序内容的入口。在一个小小的手机上弹丸之地,“尺寸”限制了企业IT的生产力 – 无法挤进太大的团队让太多工程师同时开发生产,把一切挪到云上,那里的空间无限大,企业不再受限于“尺寸”,在云上你可以无上限的扩展技术团队,并行开发,互不认识互不打扰,为你供应无限量的内容。互联网大平台上动辄几百万个小程序是怎么来的?并行开发、快速迭代、低成本试错、无限量内容场景供应,这样的技术架构,是不是很值得企业借鉴?


做自己所在细分市场、产业宇宙里的“World Platform”吧,技术的发展已经让这一切唾手可得,也许在马斯克还在打“App of Everything”嘴炮的时候,你的超级App已经瓜熟蒂落、呱呱坠地。


作者:Finbird
链接:https://juejin.cn/post/7256759718811418682
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iPhone 14 被用户吐槽电池老化

iOS
国内要闻 香港高校陆续拥抱 ChatGPT,暑期忙于筹备新学期变革 香港众多高校拥抱了 OpenAI 的聊天机器人 ChatGPT。香港科技大学率先引入ChatGPT。6 月 1 日它正式为学生与教职人员提供港科大版 ChatGPT,是香港首所为学生与教职工提...
继续阅读 »



国内要闻


香港高校陆续拥抱 ChatGPT,暑期忙于筹备新学期变革


香港众多高校拥抱了 OpenAI 的聊天机器人 ChatGPT。香港科技大学率先引入ChatGPT。6 月 1 日它正式为学生与教职人员提供港科大版 ChatGPT,是香港首所为学生与教职工提供 ChatGPT 的大学。香港中文大学、香港理工大学、香港浸会大学等高校也陆续推出使用 AI 工具的指引,共同希望师生批判性探索和谨慎使用 AI。除了在高等教育掀起热潮,AI 也将进入香港的初中课堂。香港教育局指出,ChatGPT 可以成为有价值的教育工具,但要留意抄袭的伦理问题,并期望所有公立中学尽快规划,于 2023/24 学年在“资讯和通讯科技课程”中安排 10 至 14 小时的 AI 课程教授。(奇客Solidot)


小鹏智驾灵魂人物吴新宙确认离职


小鹏汽车董事长何小鹏发文称,因家庭和多方面的原因,小鹏汽车自动驾驶中心副总裁吴新宙在 2022 年下半年表示要回到美国。在此后 10 个月时间里,小鹏汽车确立全新的工作模式,并在架构和组织上进行了提前优化和迭代。负责 XNGP 项目的李力耘博士将接手自动驾驶团队。


据业内人士透露,吴新宙或将担任英伟达“全球副总裁”这一级别的职位,直接向黄仁勋汇报,“是黄仁勋本人亲自出马,将吴新宙招至麾下。”届时,吴新宙将成为全球知名公司的最高等级华人高管,并继续在芯片等多个方面和小鹏汽车深度合作。(雷锋网)


微信要做“小绿书”?知情人士:小范围内测,优化视频号图文发布及呈现


据网传消息,微信正在灰度测试“小绿书”。从知情人士处了解到,这是一次非常小范围的内测,不是新功能,初衷就是为了更方便视频号创作者发布图文短内容,以及提高用户获得信息的效率。(36氪)


OPPO IoT 事业群负责人李开新离职,电视业务几近裁撤


OPPO IoT 事业群负责人李开新离职,可能导致其电视业务几近裁撤。OPPO IoT 部门最近两年变动不断,一直在探索新的产品线。虽然 OPPO 在 IoT 方面也尝试过其他小品类,但较为稳定的业务还是耳机和可穿戴设备。近期有报道称 OPPO 将裁撤电视业务,但 OPPO 方面表示电视业务目前运营正常。


百度千帆接入 LLaMA2 等 33 个大模型


8 月 2 日,百度智能云宣布千帆大模型平台完成新一轮升级,全面接入LLaMA2全系列、ChatGLM2、RWKV、MPT 等 33 个大模型,成为国内拥有大模型最多的平台,接入的模型经过千帆平台二次性能增强,模型推理成本可降低50%。同时,上线 103 个预置 Prompt 模板,覆盖对话、游戏、编程、写作十余个场景,并发布多款全新插件。


国际要闻


iPhone 14 被用户吐槽电池老化


据报道,不少 iPhone 14 系列机主在社交媒体吐槽,该系列出现了严重的电池老化问题。iPhone 14 系列于 2022 年 9 月上市发售,首批用户持有时间还不到一年。社交网站上不少用户留言反馈称手机电池健康已经低于 90%,最多的跌到 87%。苹果官方对“电池健康”的描述为:包含最大电池容量和峰值性能容量。一般在手机电池正常使用的情况下,完整充电次数达到 500 次,电池健康的最大容量低于 80% 则会影响手机峰值性能,保修期内的 iPhone 可以得到官方保修甚至更换。(IT之家)


消息称 OpenAI 正测试第三代图片生成模型


OpenAI 在去年 4 月推出了第二代 DALL-E“文生图”模型,该模型凭借过硬的实力吸引了业界广泛注意,据外媒表示,OpenAI 日前正在准备下一代 DALL-E AI 模型(DALL-E 3),目前该公司正在进行一系列 Alpha 测试,而部分用户已经提早接触到了该 AI 模型。(财联社)


韩国室温超导团队称论文存在缺陷


韩国一研究团队近日发布论文称实现了室温超导,在引起全球广泛关注的同时,也遭到了质疑。而该研究团队的成员表示,论文存在缺陷,系团队中的一名成员擅自发布,目前团队已要求下架论文。分析师郭明錤认为,常温常压超导体商业化的时程并没有任何能见度,但未来若能够顺利商业化,将对计算器与消费电子领域的产品设计有颠覆性的影响。即便是小如iPhone的行动装置,都能拥有与量子计算机匹敌的运算能力。(财联社)


消息称苹果 Vision Pro 开发者实验室冷清,开发者兴趣不大


苹果公司在 7 月份开始邀请开发者去 Vision Pro 的开发者实验室,这些实验室分布在库比蒂诺、伦敦、慕尼黑、上海、新加坡和东京等城市,但是目前看来,开发者对这些实验室并没有表现出很大的兴趣。据彭博社的 Mark Gurman 报道,这些开发者实验室“参与人数不多,只有少量的开发者”。


AI 打败 AI:谷歌研究团队利用 GPT-4 击败 AI-Guardian 审核系统


8 月 2 日消息,谷歌研究团队正在进行一项实验,他们使用 OpenAI 的 GPT-4 来攻破其他 AI 模型的安全防护措施,该团队目前已经攻破 AI-Guardian 审核系统,并分享了相关技术细节。谷歌 Deep Mind 的研究人员 Nicholas Carlini 在一篇题为“AI-Guardian 的 LLM 辅助开发”的论文中,探讨了使用 GPT-4“设计攻击方法、撰写攻击原理”的方案。据悉,GPT-4 会发出一系列错误的脚本和解释来欺骗 AI-Guardian ,论文中提到,GPT-4 可以让 AI-Guardian 认为“某人拿着枪的照片”是“某人拿着无害苹果的照片”,从而让 AI-Guardian 直接放行相关图片输入源。谷歌研究团队表示,通过 GPT-4 的帮助,他们成功地“破解”了 AI-Guardian 的防御,使该模型的精确值从 98% 的降低到仅 8%。(IT之家)


程序员专区


KubeSphere 3.4.0 发布


致力于打造以 Kubernetes 为内核的云原生分布式操作系统 KubeSphere 3.4.0 发布,该版本带来了值得大家关注的新功能以及增强:扩大对 Kubernetes 的支持范围,最新稳定性支持 1.26;重构告警策略架构,解耦为告警规则与规则组;提升集群别名展示权重,减少原集群名称不可修改导致的管理问题;升级 KubeEdge 组件到 v1.13 等。同时,还进行了多项修复、优化和增强,更进一步完善交互设计,并全面提升了用户体验。


Firefox 116 发布


浏览器 Firefox 116 正式发布,该版本新增加了编辑现有文本注释的可能性、用户可以从操作系统复制任何文件并将其粘贴到 Firefox 中,开发方面,Firefox 现在支持 CSP3 external hashes,添加了对 dirname 属性的支持。具体可查看发布说明:http://www.mozilla.org/en-US/firef…


作者:小莫欢
链接:https://juejin.cn/post/7262900590936604730
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

“WWW” 仍然属于 URL 吗?它可以消失吗?

多年来,我们的地址栏上一直在进行着一场小小的较真战。也就是Google、Instagram和Facebook 等品牌。该群组已选择重定向 example.com 至 http://www.example.com。相反:GitHub...
继续阅读 »

多年来,我们的地址栏上一直在进行着一场小小的较真战。也就是GoogleInstagramFacebook 等品牌。该群组已选择重定向 example.com 至 http://www.example.com。相反:GitHubDuckDuckGoDiscord。该组织选择执行相反的操作并重定向 http://www.example.com 到 example.com



“WWW”属于 URL 吗?一些开发人员对此主题持有强烈的意见。在了解了一些历史之后,我们将探讨支持和反对它的论据。


WWW是什么?


WWW代表"World Wide Web",是上世纪80年代晚期的一个发明,引入了浏览器和网站。使用"WWW"的习惯源于给子域名命名的传统:


如果没有WWW会发生什么问题?


1. 向子域名泄露cookies


反对"没有WWW"的域名的批评者指出,在某些情况下,subdomain.example.com可以读取example.com设置的cookies。如果你是一个允许客户在你的域名上运营子域名的Web托管提供商,这可能是不希望看到的。


然而,这种行为只存在于Internet Explorer中。


RFC 6265标准化了浏览器对cookies的处理,并明确指出这种行为是错误的。


另一个潜在的泄露源是example.com设置的cookies的Domain值。如果Domain值明确设置为example.com,那么这些cookies也将被其子域名所访问。


总之,只要你不明确设置Domain值,而且你的用户不使用Internet Explorer,就不会发生cookie泄露。



2. DNS的困扰


有时,"没有WWW"的域名可能会使你的域名系统(DNS)设置复杂化。


当用户在浏览器的地址栏中输入example.com时,浏览器需要知道他们想访问的Web服务器的Internet协议(IP)地址。浏览器通过你的域名的域名服务器向其DNS服务器(通常间接通过用户的互联网服务提供商(ISP)的DNS服务器)请求IP地址。如果你的域名服务器配置为响应包含IP地址的A记录,那么"没有WWW"的域名将正常工作。


在某些情况下,你可能希望使用规范名称(CNAME)记录来代替为你的网站设置。这样的记录可以声明http://www.example.comexample123.somecdnprovider.com的别名,这会告诉用户的浏览器去查找example123.somecdnprovider.com的IP地址,并将HTTP请求发送到那里。


请注意,上面的示例使用了一个WWW子域名。对于example.com,不可能定义一个CNAME记录。根据RFC 1912,CNAME记录不能与其他记录共存。如果你尝试为example.com定义CNAME记录,example.com上的MX(邮件交换)记录将无法存在。因此,就不可能在@example.com上接收邮件


一些DNS提供商可以让你绕过这个限制。Cloudflare称其解决方案为CNAME解析。通过这种技术,域名管理员配置一个CNAME记录,但他们的域名服务器将暴露一个A记录。


例如,如果管理员为example.com配置了指向example123.somecdnprovider.com的CNAME记录,并且存在一个指向1.2.3.4example123.somecdnprovider.com的A记录,那么Cloudflare就会暴露一个指向1.2.3.4的example.com的A记录。


总之,虽然这个问题对希望使用CNAME记录的域名所有者来说是有效的,但现在有一些DNS提供商提供了合适的解决办法。


没有WWW的好处


大部分反对WWW的论点是实用性或外观方面的。"无WWW"的支持者认为example.comhttp://www.example.com更容易说和输入(对于不那么精通技术的用户可能更不容易混淆)。


反对WWW子域名的人还指出,去掉它会带来一种谦虚的性能优势。网站所有者可以通过这样做每个HTTP请求节省4个字节。虽然这些节省对于像Facebook这样的高流量网站可能会累积起来,但带宽通常并不是一种紧缺的资源。


有"WWW"的好处


支持WWW的一个实际论点适用于使用较新顶级域的情况。例如,http://www.example.miamiexample.miami无法立即被识别为Web地址。对于具有诸如.com这样的可识别顶级域的网站,这不是一个太大的问题。


对搜索引擎排名的影响


目前的共识是你的选择不会影响你的搜索引擎表现。如果你希望从一个URL迁移到另一个URL,你需要配置永久重定向(HTTP 301)而不是临时重定向(HTTP 302)。永久重定向确保你旧的URL的SEO价值转移到新的URL。


同时支持两者的技巧


网站通常会选择example.comhttp://www.example.com作为官方网站,并为另一个配置HTTP 301重定向。理论上,可以支持http://www.example.com和example.com两者。但实际上,成本可能会超过效益。


从技术角度来看,你需要验证你的技术栈是否能够处理。你的内容管理系统(CMS)或静态生成的网站需要将内部链接输出为相对URL以保留访问者的首选主机名。除非你可以将主机名配置为别名,否则你的分析工具可能会将流量分别记录在两个主机名上。


最后,你需要采取额外的措施来保护你的搜索引擎表现。谷歌将把URL的"WWW""非WWW"版本视为重复内容。为了在其搜索索引中去重复内容,谷歌将显示它认为用户更喜欢的那个版本——不论是好是坏。


为了在谷歌中保持对自己的控制,建议插入规范链接标签。首先,决定哪个主机名将成为官方(规范)主机名。


例如,如果你选择了www.example.com,则必须在 https://example.com/my-article里的 <head> 上的标记 中插入以下代码段:

    <link href="<https://www.example.com/my-article>" rel="canonical"> 

这个代码片段告诉谷歌"无WWW"变体代表着相同的内容。通常情况下,谷歌会在搜索结果中偏好你标记为规范的版本,也就是在这个例子中的"WWW"变体。


总结


对于是否在URL中加入"WWW",人们有不同的观点。下面是支持和反对的论点:


支持"WWW"的论点:

  1. 存在子域名的安全性问题:某些情况下,子域名可以读取主域名设置的cookies。虽然这个问题只存在于Internet Explorer浏览器中,并且已经被RFC 6265标准化修复,但仍有人认为使用"WWW"可以避免潜在的安全风险。
  2. DNS配置的复杂性:如果你的域名系统(DNS)配置为响应包含IP地址的A记录,那么"没有WWW"的域名将正常工作。但如果你想使用CNAME记录来设置规范名称,那么"没有WWW"的域名可能会导致一些限制,例如无法同时定义CNAME记录和MX(邮件交换)记录。
  3. 对搜索引擎排名的影响:对于使用较新顶级域的网站,使用"WWW"可以帮助识别网址,而不是依赖可识别的顶级域名。然而,目前的共识是选择是否使用"WWW"对搜索引擎表现没有直接影响。

支持去除"WWW"的论点:

  1. 实用性和外观:去除"WWW"可以使域名更简洁和易于输入,减少了用户可能混淆的机会。
  2. 节省字节:去除"WWW"可以每个HTTP请求节省4个字节。虽然这对于高流量网站来说可能是一个可累积的优势,但对于大多数网站来说,带宽通常不是一个紧缺的资源。

最佳实践:
一般来说,网站会选择将example.com或www.example.com作为官方网址,并对另一个进行重定向。你可以通过使用HTTP 301永久重定向来确保旧URL的SEO价值转移到新URL。同时,你还可以在页面的标签中插入规范链接标签,告诉搜索引擎两个URL代表相同的内容,以避免重复内容问题。


需要注意的是,在做决策时要考虑到技术栈的支持能力、DNS配置的限制和谷歌对搜索排名的处理方式。


本文同步我的技术文档


作者:狗头大军之江苏分军
链接:https://juejin.cn/post/7263274550074507321
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

从《孤注一掷》出发,聊聊 SSL 证书的重要性

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。  图片来源于电影《孤注一掷》 这部电影除...
继续阅读 »

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。 


 图片来源于电影《孤注一掷》


这部电影除了让人后背发凉外,也不禁让人回忆起了曾经上网冲浪遇到的种种现象:看小说时性感荷官总在网页右下角在线发牌;看电影时网页左下角常常蹦出“在线老虎机”……这些让人烦不胜烦的广告弹窗之所以出现,要么是建站人员利欲熏心投放了非法广告,要么就是因为网站使用了不安全的 HTTP 协议而遭到了攻击,正常的网页内容被恶意篡改。


网站是电信诈骗、网络赌博等非法内容出现的重灾区,建站者和使用者都应该提高安全意识,特别是对建站者来说,保护通信安全才能更好的承担起建站责任。本文将从 HTTP 讲起,介绍 HTTPS 保护通信安全的原理,以及作为网络通信安全基石的 SSL 证书的重要性。


HTTP 协议


HTTP(Hyper Text Transfer Protocol)协议是超文本传输协议。它是从 WEB 服务器传输超文本标记语言(HTML)到本地浏览器的传送协议。HTTP 基于 TCP/IP 通信协议来传递数据,通信双方在 TCP 握手后即可开始互相传输 HTTP 数据包。具体过程如下图所示: 


 HTTP 建立流程


HTTP 协议中,请求和响应均以明文传输。如下图所示,在访问一个使用 HTTP 协议的网站时,通过抓包软件可以看到网站 HTTP 响应包中的完整 HTML 内容。




虽然 HTTP 明文传输的机制在性能上带来了优势,但同时也引入了安全问题:

  • 缺少数据机密性保护。HTTP 数据包内容以明文传输,攻击者可以轻松窃取会话内容。
  • 缺少数据完整性校验。通信内容以明文传输,数据内容可被攻击者轻易篡改,且双方缺少校验手段。
  • 缺少身份验证环节。攻击者可冒充通信对象,拦截真实的 HTTP 会话。

HTTP 劫持


作为划时代的互联网通信标准之一,HTTP 协议的出现为互联网的普及做出了不可磨灭的贡献。但正如上节谈到, HTTP 协议因为缺少加密、身份验证的过程导致很可能被恶意攻击,针对 HTTP 协议最常见的攻击就是 HTTP 劫持。


HTTP 劫持是一种典型的中间人攻击。HTTP 劫持是在使用者与其目的网络服务所建立的数据通道中,监视特定数据信息,当满足设定的条件时,就会在正常的数据流中插入精心设计的网络数据报文,目的是让用户端程序解析“错误”的数据,并以弹出新窗口的形式在使用者界面展示宣传性广告或直接显示某网站的内容。


下图是一种典型的 HTTP 劫持的流程。当客户端给服务端发送 HTTP 请求,图中发送请求为“梁安娜的电话号码是?”,恶意节点监听到该请求后将其放行给服务端,服务端返回正常 HTML 响应,关键返回内容本应该是“+86 130****1234”,恶意节点监听到该响应,并将关键返回内容篡改为泰国区电话“+66 6160 *88”,导致用户端程序展示出错误信息,这就是 HTTP 劫持的全流程。



 HTTP 劫持流程


例如,在某网站阅读某网络小说时,由于该网站使用了不安全的 HTTP 协议,攻击者可以篡改 HTTP 相应的内容,使网页上出现与原响应内容无关的广告,引导用户点击,可能将跳转进入网络诈骗或其他非法内容的页面。



 原网页



 HTTP 劫持后网页


HTTPS 工作原理


HTTPS 协议的提出正是为了解决 HTTP 带来的安全问题。HTTPS 协议(HyperText Transfer Protocol Secure,超文本传输安全协议),是一种通过计算机网络进行安全通信的传输协议。HTTPS 经由 HTTP 进行通信,但利用 SSL/TLS 来加密数据包。HTTPS 的开发主要是提供对网站服务器的身份认证,保护交换资料的隐私性与完整性。


TLS 握手是 HTTPS 工作原理的安全基础部分。TLS 传统的 RSA 握手流程如下所示:



 TLS 握手流程


TLS 握手流程主要可以分为以下四个部分:


第一次握手:客户端发送 Client Hello 消息。该消息包含:客户端支持的 SSL/TLS 协议版本(如 TLS v1.2 );用于后续生成会话密钥的客户端随机数 random_1;客户端支持的密码套件列表。


第二次握手:服务端收到 Client Hello 消息后,保存随机数 random_1,生成随机数 random_2,并发送以下消息。

  • 发送 Server Hello 消息。该消息包含:服务端确认的 SSL/TLS 协议版本(如果双方支持的版本不同,则关闭加密通信);用于后续生成会话密钥的服务端随机数 random_2;服务端确认使用的密码套件
  • 发送“Server Certificate”消息。该消息包含:服务端的 SSL 证书。SSL 证书又包含服务端的公钥、身份等信息。
  • 发送“Server Hello Done”消息。该消息表明 ServerHello 及其相关消息的结束。发送这个消息之后,服务端将会等待客户端发过来的响应。

第三次握手:客户端收到服务端证书后,首先验证服务端证书的正确性,校验服务端身份。若证书合法,客户端生成预主密钥,之后客户端根据(random_1, random_2, 预主密钥)生成会话密钥,并发送以下消息。

  • 发送“Client Key Exchange”消息,该消息为客户端生成的预主密钥,预主密钥会被服务端证书中的公钥加密后发送。
  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。
  • 发送“Encrypted Handshake Message”消息,表示客户端的握手阶段已经结束。客户端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给服务端,供服务端校验。

第四次握手:服务端收到客户端的消息后,利用自己的服务端证书私钥解密出预主密钥,并根据(random_1, random_2, 预主密钥)计算出会话密钥,之后发送以下消息。

  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。
  • 发送“Encrypted Handshake Message”,表示服务端的握手阶段已经结束,同时服务端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给客户端,供客户端校验。

根据 TLS 握手流程,可以看出它是如何解决 HTTP 协议缺陷,以及避免中间人攻击的:


1.规避窃听风险,攻击者无法获知通信内容


在客户端进行真正的 HTTPS 请求前,客户端与服务端都已经拥有了本次会话中用于加密的对称密钥,后续双方 HTTPS 会话中的内容均用该对称密钥加密,攻击者在无法获得该对称密钥的情况下,无法解密获得会话中内容的明文。即使攻击者获得了 TLS 握手中双方发送的所有明文信息,也无法从这些信息中恢复对称密钥,这是由大数质因子分解难题和有限域上的离散对数难题保证的。


2.规避篡改风险,攻击者无法篡改通信内容


在数据通信阶段,双端消息发送时会对原始消息做一次哈希,得到该消息的摘要后,与加密内容一起发送。对端接受到消息后,使用协商出来的对称加密密钥解密数据包,得到原始消息;接着也做一次相同的哈希算法得到摘要,对比发送过来的消息摘要和计算出的消息摘要是否一致,可以判断通信数据是否被篡改。


3.规避冒充风险,攻击者无法冒充身份参与通信


在 TLS 握手流程中的第二步“Server Hello”中,服务端将自己的服务端证书交付给客户端。客户端拿到 SSL 证书后,会对服务端证书进行一系列校验。以浏览器为例,校验服务端证书的过程为:

  • 验证证书绑定域名与当前域名是否匹配。
  • 验证证书是否过期,是否被吊销。
  • 查找操作系统中已内置的受信任的证书发布机构 CA(操作系统会内置有限数量的可信 CA),与服务端证书中的颁发者 CA 比对,验证证书是否为合法机构颁发。如果服务端证书不是授信 CA 颁发的证书,则浏览器会提示服务端证书不可信。
  • 验证服务端证书的完整性,客户端在授信 CA 列表中找到服务端证书的上级证书,后使用授信上级证书的公钥验证服务端证书中的签名哈希值。
  • 在确认服务端证书是由国际授信 CA 签发,且完整性未被破坏后,客户端信任服务端证书,也就确认了服务端的正确身份。

SSL 证书


正如上一节介绍,SSL 证书在 HTTPS 协议中扮演着至关重要的作用,即验证服务端身份,协助对称密钥协商。只有配置了 SSL 证书的网站才可以开启 HTTPS 协议。在浏览器中,使用 HTTP 的网站会被默认标记为“不安全”,而开启 HTTPS 的网站会显示表示安全的锁图标。



 使用 HTTP 协议的网站



 使用 HTTPS 协议的网站


从保护范围、验证强度和适用类型出发, SSL 证书会被分成不同的类型。只有了解类型之间的区别,才能根据实际情况选择更适合的证书类型,保障通信传输安全。


从保护范围分,SSL 证书可以分为单域名证书、通配符证书、多域名证书。

  • 单域名证书:单域名证书只保护一个域名,这些域名形如 http://www.test.com 等。
  • 通配符证书:通配符证书可以保护基本域和无限的子域。通配符 SSL 证书的公用名中带有星号 ,其中,星号表示具有相同基本域的任何有效子域。例如,。test.com 的通配符证书可用于保护 a.test.com、 b.test.com……
  • 多域名证书:多域证书可用于保护多个域或子域。包括完全唯一的域和具有不同顶级域的子域(本地/内部域除外)的组合。

从验证强度和适用类型进一步区分,SSL 证书可以分为 DV、OV、EV 证书。

  • DV(Domain Validated):域名验证型。在颁发该类型证书时,CA 机构仅验证申请者对域名的所有权。CA 机构会通过检查 WHOIS、DNS 的特定记录来确认资格。一般来说,DV 证书适用于博客、个人网站等不需要任何私密信息的网站。
  • OV(Organization Validated):组织验证型。OV 证书的颁发除了要验证域名所有权外,CA 还会额外验证申请企业的详细信息(名称、类型、地址)等。一般来说,OV 证书适用于中级商业组织。
  • EV(Extended Validation):扩展验证型。EV 证书的颁发除了 CA 对 DV 和 OV 证书所采取的所有身份验证步骤之外,还需要审查商业组织是否在真实运营、其实际地址,并致电以验证申请者的就业情况。一般来说,EV 证书适用于顶级商业组织。

结尾


随着互联网应用的普及,网络诈骗的方式也越发花样百出,让人防不胜防。


除了文内提到的网页环境,在软件应用、邮件、文档、物联网等领域同样存在恶意软件、钓鱼邮件、文档篡改、身份认证的问题。幸运的是,作为 PKI 体系下的优秀产品,证书体系同样在这些领域发挥着重要作用,软件签名证书、邮件签名证书、文档签名证书、私有证书等保护着各自领域的信息安全。


总有不法分子企图通过漏洞牟利,而证书体系在保护数据机密性、完整性、可用性以及身份验证场景上有着无可取代的地位,牢牢守护着用户信息,保障通信安全。


推荐活动


火山引擎域名与网站特惠活动来啦,欢迎访问火山引擎官网抢购!


5 折抢购 SSL 证书、1 元注册/转入域名、1 元升级 DNS 专业版、HTTPDNS 资源包 1 折起火热进行中……


此外,火山引擎已新推出:

  • 私有 CA(Private CA/PCA),通过私有证书灵活标识和保护企业内部资源和数据
  • 商标服务,专业、高效的商标注册管理服务平台
  • 私网解析 PrivateZone,灵活构建 VPC 内的私网域名系统
  • 公共解析PublicDNS,快速安全的递归DNS,永久免费
  • 域名委托购买服务,0元下单即可尝试获取心仪域名

关于火山引擎边缘云:
火山引擎边缘云,以云原生技术为基础底座,融合异构算力和边缘网络,构建在大规模边缘基础设施之上的云计算服务,形成以边缘位置的计算、网络、存储、安全、智能为核心能力的新一代分布式云计算解决方案。


作者:火山引擎边缘云
链接:https://juejin.cn/post/7273685263841263672
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一条SQL差点引发离职

排除一切不可能的,剩下的即使再不可能,那也是真相” 背景        最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。 本来是一个很简单的逻辑,就...
继续阅读 »

排除一切不可能的,剩下的即使再不可能,那也是真相”



背景


       最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。

本来是一个很简单的逻辑,就是根据唯一的id去更新对应的MySQL数据,代码简化后如下:


var updates []*model.Goods
for id, newGoods := range update {
 if err := model.GetDB().Model(&model.Goods{}).Where("id = ?", id).Updates(map[string]interface{}{
  "selling_price":  newGoods.SellingPrice,
  "sell_type":      newGoods.SellType,
  "status":         newGoods.Status,
  "category_id":    newGoods.CategoryID,
 }).Error; err != nil {
  return nil, err
 }
}

很明显,updates[]model.Goods\color{red}{updates []*model.Goods}本来应该是想声明为 map[string]model.Goods\color{red}{map[string]*model.Goods}类型的,然后key是唯一id。这样下面的更新逻辑才是对的,否则拿到的id其实是数组的下标。

但是code review由于跟着一堆代码一起评审了,并且这段更新很简单,同时测试的时候也测试过了(能测试通过也是“机缘巧合”),所以没有发现这段异常。

发到线上后,进行了灰度集群的测试,这个时候发现只要调用了这个接口,灰度集群的数据全部都变成了一样,回滚后正常。


分析


       回滚后在本地进行复现,由于本地环境是开启了SQL打印的,于是看到了这么一条SQL:很明显是拿数组的下标去比较了


update db_name set selling_price = xx,sell_type = xx where id = 0;

       由于我们的id是全部是通过uuid生成的,所以下意识的认为这条sql应该啥也不会更新才对,但是本地的确只执行了这条sql,没有别的sql,并且db中的数据全部都被修改了。

这个时候想起福尔摩斯的名言“排除一切不可能的,剩下的即使再不可能,那也是真相”\color{blue}{“排除一切不可能的,剩下的即使再不可能,那也是真相”} ,于是抱着试一试的心态直接拿这条sql去db控制台执行了一遍,发现果然所有的数据又都被修改了。

也就是 whereid=0\color{red}{where id = 0}  这个条件对于所有的记录都是恒为true,就会导致所有记录都被更新。在这个时候,想起曾经看到过MySQL对于不同类型的比较会有 【隐式转换】\color{red}{【隐式转换】},难道是这个原因导致的?


隐式转换规则


在MySQL官网找到了不同类型比较的规则:



最后一段的意思是:对于其他情况,将按照浮点(双精度)数进行比较。例如,字符串和数字的比较就按照浮点数规则进行比较。

也就是id会首先被转换成浮点数,然后再跟0进行比较。


MySQL字符转为浮点数时会按照如下规则进行:


1.如果字符串的第一个字符就是非数字的字符,那么转换结果就是0;

2.如果字符串以数字开头:

(1)如果字符串都是数字,转换结果就是整个字符串对应的数字;

(2)如果字符串中存在非数字,转换结果就是开头的那些数字对应的值;

举例说明:

"test" -> 0

"1test" -> 1

"12test12" -> 12

由于我们生成的uuid没有数字开头的字符串,于是都会转变成0。那么这条SQL就变成了:


update db_name set selling_price = xx,sell_type = xx where 0 = 0;

就恒为true了。

修复就很简单了,把取id的逻辑改成正确的就行。


为什么测试环境没有发现


       前面有提到这段代码在测试环境是测试通过了的,这是因为开发和测试同学的环境里都只有一条记录,每次更新他发现都能正常更新就认为是正常的了。同时由于逻辑太简单了,所以都没有重视这块的回归测试。

幸好在灰度集群就发现了这个问题,及时进行了回滚,如果发到了线上影响了用户数据,可能就一年白干了。


最后


代码无小事,事事需谨慎啊。一般致命问题往往是一行小小的修改导致的。


作者:云舒编程
来源:juejin.cn/post/7275550679790960640
收起阅读 »

请给系统加个【消息中心】功能,因为真的很简单

个人项目:社交支付项目(小老板) 作者:三哥,j3code.cn 项目文档:http://www.yuque.com/g/j3code/dv… 预览地址(未开发完):admire.j3code.cn/small-boss 内网穿透部署,第一次访问比较慢 ...
继续阅读 »

个人项目:社交支付项目(小老板)


作者:三哥,j3code.cn


项目文档:http://www.yuque.com/g/j3code/dv…


预览地址(未开发完):admire.j3code.cn/small-boss



  • 内网穿透部署,第一次访问比较慢



我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面:


1)消息提示


Snipaste_2023-08-27_13-41-36.jpg


2)消息列表


这样


Snipaste_2023-08-27_13-42-25.jpg


这样


Snipaste_2023-08-27_16-41-30.jpg


那,这就是我们今天要聊的【消息中心】。


1、设计


老规矩先来搞清楚消息中心的需求,再来代码实现。


我们知道在社交类项目中,有很多评论、点赞等数据的产生,而如果这些数据的产生不能让用户感知到,那你们想想这会带来什么影响?



用户A:太鸡肋了,发布的内容被人评论点赞了,我居然看不到,下次不用了...


用户B:还好没用这个系统...



所以,看到这些结果我们是不是能够意识到一个健全的社交功能,是不是少不了这种通知用户的机制啊!而这种机制我就把他定义为【消息中心】功能。


再来拆分一下这四个字:消息中心



  1. 消息

  2. 中心


消息:这个可以是由我们自己定义,如:把帖子被用户评论当作一条消息,把评论被用户点赞也可以当作一条消息,甚至系统发布的通知也是一条消息。


中心:这个就是字面意思,将上面所提到的所有消息,归拢到一个地方进行展示。


上面我们也提到消息基本就是这两种:



  • 用户对用户:用户消息

  • 平台对用户:系统消息


针对用户消息,就类似这样,用户 A 给用户 B 的一条评论进行了点赞,那这个点赞动作就会产生一条消息,并且通知到用户 B 的一个存储消息的地方,这里通常就指用户的收件箱。这个收件箱就是专门用来存储用户发给用户的消息,而这个点对点的模式是不是就是推送模式啊!(A 推送消息给 B)


接着针对系统消息,就类似这样,平台管理人员发布了一条通知,告诉大家平台有啥 XXX 活动。那这个活动通知肯定是要让平台的所有用户都知道把,所以这个通知就要存在一个发件箱中。这个发件箱就是专门存储平台的通知,所有用户都来这个发件箱中读取消息就行,而这个一对多的模式是不是就是拉取模式啊!(所有用户都来拉取平台消息)


这样一来,我们根据不同的消息场景就抽出了一个基本的消息推拉模型,模型图如下:



Snipaste_2023-08-27_14-27-25.jpg



Snipaste_2023-08-27_14-59-50.jpg


针对这两种模式,不知道大家有没有看出区别,好像乍一看没啥区别,都是发消息,读消息,对吧!


没错,确实都是一个发,一个读,但是两者的读写频率确实有着巨大的差异。先来看推模型,一个普通用户发表了一条帖子,然后获得了寥寥无几的评论和赞,这好似也没啥特别之处,对吧!那如果这个普通用户发表的帖子成为了热门帖子呢,也即该贴子获得了上万的评论和赞。那,你们想想是不是发消息的频率非常高,而该普通用户肯定是不可能一下子读取这么多消息的,所以是不是一个写多读少的场景。再来看看拉模型,如果你的平台用户人数寥寥无几,那倒没啥特别之处,但如果用户人数几万甚至几十万。那,每个用户都过来拉取系统消息是不是就是一个读频率非常高,而发消息频率非常低(系统消息肯定不会发的很快),所以这是不是一个读多写少的场景。


1.1 推:写多读少


针对这个模式,我们肯定是要将写这个动作交给性能更高的中间件来处理,而不是 MySQL,所以此时我们的 RocketMQ 就出来了。


当系统中产生了评论、点赞类的高频消息,那就无脑的丢给 MQ 吧,让其在消息中间件中呆会,等待消费者慢慢的将消息进行消费并发到各个用户的收件箱中,就类似下面这张图的流程:


Snipaste_2023-08-27_15-45-46.jpg


2.2 拉:读多写少


那对于这个模式,所实话,我觉得不用引入啥就可以实现,因为对于读多的话无非就是一个查,MySQL 肯定是能搞定的,即使你的用户几万、几十万都是 ok 的。


但咱们是不是可以这样想一下,一个系统的官方通知肯定是不多的,或者说几天或者几个星期一次,且一旦发送就不可更改。那是不是可以考虑缓存,让用户读取官方通知的时候走缓存,如果缓存没有再走 MySQL 这样应该是可以提高查询效率,提高响应速度。


具体流程如下图:


Snipaste_2023-08-27_15-57-21.jpg


2.3 表结构设计


基本的业务流程已经分析的差不多了,现在可以把表字段抽一下了,先根据上面分析的,看看我们需要那些表:



  1. 用户收件箱表

  2. 系统发件箱表


看似好像就这两张表,但是应该还有第三张表:



  1. 用户读取系统消息记录表



我们看到页面是不是每次有一条新的消息都会有一个小标点记录新消息数量,而第三张表就是为了这个作用而设计的。


具体原理如下:



  1. 首先运营人员发布的消息都是存储在第二张表中,这肯定是没错的

  2. 那用户每次过来拉取系统消息时,将最近拉取的一条消息写入到第三种表中

  3. 这样等用户下次再来拉取的时候,就可以根据第三张表的读取记录,来确定他有几条系统消息未查看了


可能有人会发出疑问:那用户的收件箱为啥不出一个用户读取记录表呢!


这个很简单,因为收件箱中的数据已经表示这个用户需要都这些个消息了,只是不知道那些是已读的那些是未读的,我们只需要再收件箱表中加一个字段,这个字段的作用就是记录最新一次读取的消息 ID 就行,等下次要读消息时,找到上传读取读取消息的记录ID,往后读新消息即可。



好,现在来看看具体的表字段:


1)用户收件箱表(sb_user_inbox)



  • id

  • 消息数据唯一 id:MQ唯一消息凭证

  • 消息类型:评论消息或者点赞消息

  • 帖子id:业务id

  • 业务数据id:业务id

  • 内容:消息内容

  • 业务数据类型:业务数据类型(商品评论、帖子、帖子一级评论、帖子二级评论)

  • 发起方的用户ID:用户 A 对用户 B 进行点赞,那这就是用户 A 的ID

  • 接收方的用户ID:用户 B 的 ID

  • 用户最新读取位置ID:用户最近一次读取记录的 ID


SQL


CREATE TABLE `sb_user_inbox` (
`id` bigint(20) NOT NULL,
`uuid` varchar(128) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '消息数据唯一id',
`message_type` tinyint(1) NOT NULL COMMENT '消息类型',
`post_id` bigint(20) DEFAULT NULL COMMENT '帖子id',
`item_id` bigint(20) NOT NULL COMMENT '业务数据id',
`content` varchar(1000) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '内容',
`service_message_type` tinyint(1) NOT NULL COMMENT '业务数据类型',
`from_user_id` bigint(20) NOT NULL COMMENT '发起方的用户ID',
`to_user_id` bigint(20) NOT NULL COMMENT '接收方的用户ID',
`read_position_id` bigint(20) DEFAULT '0' COMMENT '用户最新读取位置ID',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `un01` (`uuid`),
UNIQUE KEY `un02` (`item_id`,`service_message_type`,`to_user_id`),
KEY `key` (`to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

可以看到,我加了很多业务相关的字段,这个主要是为了方便查询数据和展示数据。


2)系统发件箱表(sb_sys_outbox)



  • id

  • 内容


SQL


CREATE TABLE `sb_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`content` varchar(2000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

这个表就非常简单了,没啥业务字段冗余。


3)用户读取系统消息记录表(sb_user_read_sys_outbox)



  • id

  • 系统收件箱数据读取id

  • 读取的用户id


SQL


CREATE TABLE `sb_user_read_sys_outbox` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sys_outbox_id` bigint(20) NOT NULL COMMENT '系统收件箱数据读取id',
`user_id` bigint(20) NOT NULL COMMENT '读取的用户id',
PRIMARY KEY (`id`),
UNIQUE KEY `un` (`user_id`),
KEY `key` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

ok,这是消息中心所有分析阶段了,下面就开始实操。


2、实现


先来引入引入一下 RocketMQ 的依赖


<!--rocketmq-->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>

RocketMQ 的双主双从同步刷新集群搭建教程:blog.csdn.net/qq_40399646…


MQ 配置:


Snipaste_2023-08-27_16-26-09.jpg


2.1 生产者


先来实现生产者如何发送消息。


1)消息体对象:LikeAndCommentMessageDTO


位置:cn.j3code.config.dto.mq


@Data
public class LikeAndCommentMessageDTO {

/**
* 该消息的唯一id
* 业务方可以不设置,如果为空,代码会自动填充
*/

private String uuid;

/**
* 消息类型
*/

private UserCenterMessageTypeEnum messageType;

/**
* 冗余一个帖子id进来
*/

private Long postId;

/**
* 业务数据id
*/

private Long itemId;

/**
* 如果是评论消息,这个内容就是评论的内容
*/

private String content;

/**
* 业务数据类型
*/

private UserCenterServiceMessageTypeEnum serviceMessageType;

/**
* 发起方的用户ID
*/

private Long fromUserId;

/**
* 接收方的用户ID
*/

private Long toUserId;


/*
例子:
用户 A 发表了一个帖子,B 对这个帖子进行了点赞,那这个实体如下:
messageType = UserCenterMessageTypeEnum.LIKE
itemId = 帖子ID(对评论进行点赞,就是评论id,对评论进行回复,就是刚刚评论的id)
serviceMessageType = UserCenterServiceMessageTypeEnum.POST(这个就是说明 itemId 的 ID 是归于那个业务的,方便后续查询业务数据)
fromUserId = 用户B的ID
toUserId = 用户 A 的ID
*/

}

2)发送消息代码


位置:cn.j3code.community.mq.producer


@Slf4j
@Component
@AllArgsConstructor
public class LikeAndCommentMessageProducer {

private final RocketMQTemplate rocketMQTemplate;

/**
* 单个消息发送
*
* @param dto
*/

public void send(LikeAndCommentMessageDTO dto) {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message<LikeAndCommentMessageDTO> message = MessageBuilder
.withPayload(dto)
.build();
rocketMQTemplate.send(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, message);
}

/**
* 批量消息发送
*
* @param dtos
*/

public void send(List<LikeAndCommentMessageDTO> dtos) {
/**
* 将 dtos 集合分割成 1MB 大小的集合
* MQ 批量推送的消息大小最大 1MB 左右
*/

ListSizeSplitUtil.split(1 * 1024 * 1024L, dtos).forEach(items -> {
List<Message<LikeAndCommentMessageDTO>> messageList = new ArrayList<>(items.size());
items.forEach(dto -> {
if (Objects.isNull(dto.getUuid())) {
dto.setUuid(IdUtil.simpleUUID());
}
checkMessageDTO(dto);
Message<LikeAndCommentMessageDTO> message = MessageBuilder
.withPayload(dto)
.build();
messageList.add(message);
});
rocketMQTemplate.syncSend(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, messageList);
});
}

private void checkMessageDTO(LikeAndCommentMessageDTO dto) {
AssertUtil.isTrue(Objects.isNull(dto.getMessageType()), "消息类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getItemId()), "业务数据ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getServiceMessageType()), "业务数据类型不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getFromUserId()), "发起方用户ID不为空!");
AssertUtil.isTrue(Objects.isNull(dto.getToUserId()), "接收方用户ID不为空!");
}


/**
* 发送点赞消息
*
* @param messageType 消息类型
* @param serviceMessageType 业务类型
* @param itemToUserIdMap 业务ID对应的用户id
* @param saveLikeList 点赞数据
*/

public void sendLikeMQMessage(
UserCenterMessageTypeEnum messageType,
UserCenterServiceMessageTypeEnum serviceMessageType,
Map<Long, Long> itemToUserIdMap, List<Like> saveLikeList)
{
if (CollectionUtils.isEmpty(saveLikeList)) {
return;
}
List<LikeAndCommentMessageDTO> dtos = new ArrayList<>();
for (Like like : saveLikeList) {
LikeAndCommentMessageDTO messageDTO = new LikeAndCommentMessageDTO();
messageDTO.setItemId(like.getItemId());
messageDTO.setMessageType(messageType);
messageDTO.setServiceMessageType(serviceMessageType);
messageDTO.setFromUserId(like.getUserId());
messageDTO.setToUserId(itemToUserIdMap.get(like.getItemId()));
dtos.add(messageDTO);
}
try {
send(dtos);
} catch (Exception e) {
//错误处理
log.error("发送MQ消息失败!", e);
}
}
}

注意:这里我用了 MQ 批量发送消息的一个功能,但是他有一个限制就是每次只能发送 1MB 大小的数据。所以我需要做一个功能工具类将业务方丢过来的批量数据进行分割。


工具类:ListSizeSplitUtil


位置:cn.j3code.config.util


public class ListSizeSplitUtil {

private static Long maxByteSize;

/**
* 根据传进来的 byte 大小限制,将 list 分割成对应大小的 list 集合数据
*
* @param byteSize 每个 list 数据最大大小
* @param list 待分割集合
* @param <T>
* @return
*/

public static <T> List<List<T>> split(Long byteSize, List<T> list) {
if (Objects.isNull(list) || list.size() == 0) {
return new ArrayList<>();
}

if (byteSize <= 100) {
throw new RuntimeException("参数 byteSize 值不小于 100 bytes!");
}
ListSizeSplitUtil.maxByteSize = byteSize;


if (isSurpass(List.of(list.get(0)))) {
throw new RuntimeException("List 中,单个对象都大于 byteSize 的值,分割失败");
}

List<List<T>> result = new ArrayList<>();

List<T> itemList = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
itemList.add(list.get(i));

if (isSurpass(itemList)) {
i = i - 1;
itemList.remove(itemList.size() - 1);
result.add(new ArrayList<>(itemList));
itemList = new ArrayList<>();
}
}
result.add(new ArrayList<>(itemList));
return result;
}


private static <T> Boolean isSurpass(List<T> obj) {
// 字节(byte)
long objSize = RamUsageEstimator.sizeOfAll(obj.toArray());
return objSize >= ListSizeSplitUtil.maxByteSize;
}
}

至此呢,生产者的逻辑就算是完成了,每次有消息的时候就调用这个方法即可。


2.2 消费者


位置:cn.j3code.user.mq.consumer


@Slf4j
@Component
@AllArgsConstructor
@RocketMQMessageListener(topic = RocketMQConstants.USER_MESSAGE_CENTER_TOPIC,
consumerGroup = RocketMQConstants.GROUP,
messageModel = MessageModel.CLUSTERING,
consumeMode = ConsumeMode.CONCURRENTLY
)

public class LikeAndCommentMessageConsumer implements RocketMQListener<LikeAndCommentMessageDTO> {

private final UserInboxService userInboxService;

@Override
public void onMessage(LikeAndCommentMessageDTO message) {
userInboxService.saveMessage(message);
}
}

saveMessage 方法的逻辑就是将消息保存到 MySQL 中,至此消息的产生和存储就算完成了,下面来看看用户如何查看吧!


2.3 用户消息查看


对于用户查看普通的消息就是访问一下 MySQL,并且更新一下最新读取的字段值即可,我贴一下关键代码就行了,代码如下:


public IPage<UserMessageVO> page(UserMessagePageRequest request) {
// 获取消息
IPage<UserMessageVO> page = getBaseMapper().page(new Page<UserMessageVO>(request.getCurrent(), request.getSize()), request);

if (CollectionUtils.isEmpty(page.getRecords())) {
return page;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
if (Objects.isNull(page.getRecords().get(0).getReadPositionId()) ||
page.getRecords().get(0).getReadPositionId() == 0) {
UserInbox userInbox = new UserInbox();
userInbox.setId(page.getRecords().get(0).getId());
userInbox.setReadPositionId(userInbox.getId());
updateById(userInbox);
}
}
return page;
}

2.4 系统消息查看


对于系统消息的查看也是,只贴出关键代码,查询和更新读取记录逻辑,代码如下:


@Override
public IPage<SysOutboxVO> lookSysPage(SysOutboxPageRequest request) {
Page<SysOutbox> page = lambdaQuery()
.orderByDesc(SysOutbox::getId)
.page(new Page<>(request.getCurrent(), request.getSize()));
IPage<SysOutboxVO> outboxVOIPage = page.convert(userInboxConverter::converter);
if (CollectionUtils.isEmpty(outboxVOIPage.getRecords())) {
return outboxVOIPage;
}
// 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
if (request.getCurrent() == 1) {
userReadSysOutboxService.updateReadLog(page.getRecords().get(0).getId(), SecurityUtil.getUserId());
}
return outboxVOIPage;
}

这里,可能有人会发现,没有按照上面分析的那用从缓存中读,是的。这里的实现我没有用到 Redis,这里我偷了一下懒,如果有拿到我代码的同学可以试着优化一下这个逻辑。


作者:J3code
来源:juejin.cn/post/7274922643453853735
收起阅读 »

从《孤注一掷》出发,聊聊 SSL 证书的重要性

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。 图片来源于电影《孤注一掷》 这部电影除了让人后背发凉外,也...
继续阅读 »

你去看《孤注一掷》了吗?相信最近大家的朋友圈和抖音都被爆火电影《孤注一掷》成功刷屏。取材于上万真实案例的《孤注一掷》揭露了缅甸诈骗园区残暴的统治,以及电信诈骗中系统性极强的诈骗技巧,引发了大量讨论。
image001.png
图片来源于电影《孤注一掷》


这部电影除了让人后背发凉外,也不禁让人回忆起了曾经上网冲浪遇到的种种现象:看小说时性感荷官总在网页右下角在线发牌;看电影时网页左下角常常蹦出“在线老虎机”……这些让人烦不胜烦的广告弹窗之所以出现,要么是建站人员利欲熏心投放了非法广告,要么就是因为网站使用了不安全的 HTTP 协议而遭到了攻击,正常的网页内容被恶意篡改。


网站是电信诈骗、网络赌博等非法内容出现的重灾区,建站者和使用者都应该提高安全意识,特别是对建站者来说,保护通信安全才能更好的承担起建站责任。本文将从 HTTP 讲起,介绍 HTTPS 保护通信安全的原理,以及作为网络通信安全基石的 SSL 证书的重要性。


HTTP 协议


HTTP(Hyper Text Transfer Protocol)协议是超文本传输协议。它是从 WEB 服务器传输超文本标记语言(HTML)到本地浏览器的传送协议。HTTP 基于 TCP/IP 通信协议来传递数据,通信双方在 TCP 握手后即可开始互相传输 HTTP 数据包。具体过程如下图所示:
image003.jpg
HTTP 建立流程


HTTP 协议中,请求和响应均以明文传输。如下图所示,在访问一个使用 HTTP 协议的网站时,通过抓包软件可以看到网站 HTTP 响应包中的完整 HTML 内容。


image005.png


虽然 HTTP 明文传输的机制在性能上带来了优势,但同时也引入了安全问题:



  • 缺少数据机密性保护。HTTP 数据包内容以明文传输,攻击者可以轻松窃取会话内容。

  • 缺少数据完整性校验。通信内容以明文传输,数据内容可被攻击者轻易篡改,且双方缺少校验手段。

  • 缺少身份验证环节。攻击者可冒充通信对象,拦截真实的 HTTP 会话。


HTTP 劫持


作为划时代的互联网通信标准之一,HTTP 协议的出现为互联网的普及做出了不可磨灭的贡献。但正如上节谈到, HTTP 协议因为缺少加密、身份验证的过程导致很可能被恶意攻击,针对 HTTP 协议最常见的攻击就是 HTTP 劫持。


HTTP 劫持是一种典型的中间人攻击。HTTP 劫持是在使用者与其目的网络服务所建立的数据通道中,监视特定数据信息,当满足设定的条件时,就会在正常的数据流中插入精心设计的网络数据报文,目的是让用户端程序解析“错误”的数据,并以弹出新窗口的形式在使用者界面展示宣传性广告或直接显示某网站的内容。


下图是一种典型的 HTTP 劫持的流程。当客户端给服务端发送 HTTP 请求,图中发送请求为“梁安娜的电话号码是?”,恶意节点监听到该请求后将其放行给服务端,服务端返回正常 HTML 响应,关键返回内容本应该是“+86 130****1234”,恶意节点监听到该响应,并将关键返回内容篡改为泰国区电话“+66 6160 *88”,导致用户端程序展示出错误信息,这就是 HTTP 劫持的全流程。


image007.jpg
HTTP 劫持流程


例如,在某网站阅读某网络小说时,由于该网站使用了不安全的 HTTP 协议,攻击者可以篡改 HTTP 相应的内容,使网页上出现与原响应内容无关的广告,引导用户点击,可能将跳转进入网络诈骗或其他非法内容的页面。


image009.png
原网页


image011.png
HTTP 劫持后网页


HTTPS 工作原理


HTTPS 协议的提出正是为了解决 HTTP 带来的安全问题。HTTPS 协议(HyperText Transfer Protocol Secure,超文本传输安全协议),是一种通过计算机网络进行安全通信的传输协议。HTTPS 经由 HTTP 进行通信,但利用 SSL/TLS 来加密数据包。HTTPS 的开发主要是提供对网站服务器的身份认证,保护交换资料的隐私性与完整性。


TLS 握手是 HTTPS 工作原理的安全基础部分。TLS 传统的 RSA 握手流程如下所示:


image013.jpg
TLS 握手流程


TLS 握手流程主要可以分为以下四个部分:


第一次握手:客户端发送 Client Hello 消息。该消息包含:客户端支持的 SSL/TLS 协议版本(如 TLS v1.2 );用于后续生成会话密钥的客户端随机数 random_1;客户端支持的密码套件列表。


第二次握手:服务端收到 Client Hello 消息后,保存随机数 random_1,生成随机数 random_2,并发送以下消息。



  • 发送 Server Hello 消息。该消息包含:服务端确认的 SSL/TLS 协议版本(如果双方支持的版本不同,则关闭加密通信);用于后续生成会话密钥的服务端随机数 random_2;服务端确认使用的密码套件

  • 发送“Server Certificate”消息。该消息包含:服务端的 SSL 证书。SSL 证书又包含服务端的公钥、身份等信息。

  • 发送“Server Hello Done”消息。该消息表明 ServerHello 及其相关消息的结束。发送这个消息之后,服务端将会等待客户端发过来的响应。


第三次握手:客户端收到服务端证书后,首先验证服务端证书的正确性,校验服务端身份。若证书合法,客户端生成预主密钥,之后客户端根据(random_1, random_2, 预主密钥)生成会话密钥,并发送以下消息。



  • 发送“Client Key Exchange”消息,该消息为客户端生成的预主密钥,预主密钥会被服务端证书中的公钥加密后发送。

  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。

  • 发送“Encrypted Handshake Message”消息,表示客户端的握手阶段已经结束。客户端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给服务端,供服务端校验。


第四次握手:服务端收到客户端的消息后,利用自己的服务端证书私钥解密出预主密钥,并根据(random_1, random_2, 预主密钥)计算出会话密钥,之后发送以下消息。



  • 发送“Change Cipher Spec”消息,表示之后数据都将用会话密钥进行加密。

  • 发送“Encrypted Handshake Message”,表示服务端的握手阶段已经结束,同时服务端会生成所有握手报文数据的摘要,并用会话密钥加密后发送给客户端,供客户端校验。


根据 TLS 握手流程,可以看出它是如何解决 HTTP 协议缺陷,以及避免中间人攻击的:


1.规避窃听风险,攻击者无法获知通信内容


在客户端进行真正的 HTTPS 请求前,客户端与服务端都已经拥有了本次会话中用于加密的对称密钥,后续双方 HTTPS 会话中的内容均用该对称密钥加密,攻击者在无法获得该对称密钥的情况下,无法解密获得会话中内容的明文。即使攻击者获得了 TLS 握手中双方发送的所有明文信息,也无法从这些信息中恢复对称密钥,这是由大数质因子分解难题和有限域上的离散对数难题保证的。


2.规避篡改风险,攻击者无法篡改通信内容


在数据通信阶段,双端消息发送时会对原始消息做一次哈希,得到该消息的摘要后,与加密内容一起发送。对端接受到消息后,使用协商出来的对称加密密钥解密数据包,得到原始消息;接着也做一次相同的哈希算法得到摘要,对比发送过来的消息摘要和计算出的消息摘要是否一致,可以判断通信数据是否被篡改。


3.规避冒充风险,攻击者无法冒充身份参与通信


在 TLS 握手流程中的第二步“Server Hello”中,服务端将自己的服务端证书交付给客户端。客户端拿到 SSL 证书后,会对服务端证书进行一系列校验。以浏览器为例,校验服务端证书的过程为:



  • 验证证书绑定域名与当前域名是否匹配。

  • 验证证书是否过期,是否被吊销。

  • 查找操作系统中已内置的受信任的证书发布机构 CA(操作系统会内置有限数量的可信 CA),与服务端证书中的颁发者 CA 比对,验证证书是否为合法机构颁发。如果服务端证书不是授信 CA 颁发的证书,则浏览器会提示服务端证书不可信。

  • 验证服务端证书的完整性,客户端在授信 CA 列表中找到服务端证书的上级证书,后使用授信上级证书的公钥验证服务端证书中的签名哈希值。

  • 在确认服务端证书是由国际授信 CA 签发,且完整性未被破坏后,客户端信任服务端证书,也就确认了服务端的正确身份。


SSL 证书


正如上一节介绍,SSL 证书在 HTTPS 协议中扮演着至关重要的作用,即验证服务端身份,协助对称密钥协商。只有配置了 SSL 证书的网站才可以开启 HTTPS 协议。在浏览器中,使用 HTTP 的网站会被默认标记为“不安全”,而开启 HTTPS 的网站会显示表示安全的锁图标。


image015.png
使用 HTTP 协议的网站


image028.gif
使用 HTTPS 协议的网站


从保护范围、验证强度和适用类型出发, SSL 证书会被分成不同的类型。只有了解类型之间的区别,才能根据实际情况选择更适合的证书类型,保障通信传输安全。


从保护范围分,SSL 证书可以分为单域名证书、通配符证书、多域名证书。



  • 单域名证书:单域名证书只保护一个域名,这些域名形如 http://www.test.com 等。

  • 通配符证书:通配符证书可以保护基本域和无限的子域。通配符 SSL 证书的公用名中带有星号 ,其中,星号表示具有相同基本域的任何有效子域。例如,。test.com 的通配符证书可用于保护 a.test.com、 b.test.com……

  • 多域名证书:多域证书可用于保护多个域或子域。包括完全唯一的域和具有不同顶级域的子域(本地/内部域除外)的组合。


从验证强度和适用类型进一步区分,SSL 证书可以分为 DV、OV、EV 证书。



  • DV(Domain Validated):域名验证型。在颁发该类型证书时,CA 机构仅验证申请者对域名的所有权。CA 机构会通过检查 WHOIS、DNS 的特定记录来确认资格。一般来说,DV 证书适用于博客、个人网站等不需要任何私密信息的网站。

  • OV(Organization Validated):组织验证型。OV 证书的颁发除了要验证域名所有权外,CA 还会额外验证申请企业的详细信息(名称、类型、地址)等。一般来说,OV 证书适用于中级商业组织。

  • EV(Extended Validation):扩展验证型。EV 证书的颁发除了 CA 对 DV 和 OV 证书所采取的所有身份验证步骤之外,还需要审查商业组织是否在真实运营、其实际地址,并致电以验证申请者的就业情况。一般来说,EV 证书适用于顶级商业组织。


结尾


随着互联网应用的普及,网络诈骗的方式也越发花样百出,让人防不胜防。


除了文内提到的网页环境,在软件应用、邮件、文档、物联网等领域同样存在恶意软件、钓鱼邮件、文档篡改、身份认证的问题。幸运的是,作为 PKI 体系下的优秀产品,证书体系同样在这些领域发挥着重要作用,软件签名证书、邮件签名证书、文档签名证书、私有证书等保护着各自领域的信息安全。


总有不法分子企图通过漏洞牟利,而证书体系在保护数据机密性、完整性、可用性以及身份验证场景上有着无可取代的地位,牢牢守护着用户信息,保障通信安全。


作者:火山引擎边缘云
来源:juejin.cn/post/7273685263841263672
收起阅读 »

使用 Vim 两年后的个人总结

为什么要使用 Vim 学习动机非常重要。并不是很多大牛程序员用 Vim 编程,你就应该去学习 Vim,如果你是这种心态,很大的概率,你会在几次尝试以后最终放弃,就像我曾经做过的一样。因为 Vim 的学习曲线很陡峭,没有强烈的学习动机很难坚持下来。 那我为什么后...
继续阅读 »

为什么要使用 Vim


学习动机非常重要。并不是很多大牛程序员用 Vim 编程,你就应该去学习 Vim,如果你是这种心态,很大的概率,你会在几次尝试以后最终放弃,就像我曾经做过的一样。因为 Vim 的学习曲线很陡峭,没有强烈的学习动机很难坚持下来。


那我为什么后来又重新开始学习 Vim,并在两年多后已经习惯、喜欢甚至离不开 Vim?原因很简单,我必须掌握 Vim。


我是一个很爱折腾的人,自己买过很多云服务器,也经常会在服务器上写一些程序,编辑器当然首选 Vim。日复一日,当有一天我实在无法忍受自己在服务端极其低效的编程体验后,我决定真正掌握 Vim。从那时候起,我开始刻意频繁练习,也终于有一天,我发现我完全存活了下来,并且喜欢上了 Vim。


我并不是说你一定要买个云服务器,然后在云服务器上写代码(其实现在你可以用 VSCode 的远程功能在服务器上写代码),我想表达的是,你一定要有足够的学习动机,这个学习动机往往来自于必要性,不管是工作上的必要性,还是自己业余项目上的必要性。也许,有强烈的炫耀动机可能也行。


当然了,当你真正喜欢上 Vim,你会有新的理解,比如 Vim 某种意义上代表了一些正向的价值,文章最后我会提到这一点。


关于 Normal 模式的最佳隐喻


《代码大全》(Code Complete)开头就讲了“软件构建的隐喻”,隐喻是非常好的方式,能够通过熟悉的事物帮我们建立正确的思维模型。关于 Vim 为什么要有 Normal 模式,我看过的最好的隐喻来自《Practical Vim》这本书,我摘录几个关键的段落:



Think of all of the things that painters do besides paint. They study their subject, adjust the lighting, and mix paints into new hues. And when it comes to applying paint to the canvas, who says they have to use brushes? A painter might switch to a palette knife to achieve a different texture or use a cotton swab to touch up the paint that's already been applied.




The painter does not rest with a brush on the canvas. And so it is with Vim. Normal mode is the natural resting state. The clue is in the name, really.




Just as painters spend a fraction of their time applying paint,programmers spend a fraction of their time composing code . More time is spent thinking, reading, and navigating from one part of a codebase to another. And when we do want to make a change, who says we have to switch to Insert mode? We can reformat existing code, duplicate it, move it around, or delete it. From Normal mode, we have many tools at our disposal.



作者把编程比喻成绘画,把 Normal 模式比喻成画家作画的间隙。就像画家要经常放下画笔,走远处看看,或用小刀、棉球等工具修改画作一样,程序员也不会一直输入代码(Insert 模式),程序员也需要思考,需要对程序做一些修改(不一定是插入内容),那么这个时候就应该进入 Normal 模式。Normal 模式让程序员休息、思考,同时提供了更多的工具,比如删除、复制、黏贴、跳转光标等等。每当写程序需要停顿思考的时候,就可以进入 Normal 模式。


一个最重要的模式


这里的模式,不是指“Normal”或“Insert”模式。而是我们在使用 Vim 组合快捷键时候的“操作模式”。这个最重要的模式如下:

Action = Operator + Motion

举一个例子,“删除当前到句尾的所有字符”的操作是d$d$ = d + $,其中的 d 即为 Operator,也即操作,$ 即为 Motion,也即操作的范围。这个模式在 Vim 中无处不在,再举一些例子:

  • dap,删除一整个段落;
  • yG,复制当前行到文件末尾所有内容;
  • cw, 修改当前单词(删除单词并进入 Insert 模式);

这是最基本的模式,也是 Vim 编辑器能高效编辑文本的基础,它把常用的 Operator 和 Motion 做了抽象,抽象成了一些简单字母,比如 d 代表删除操作,$代表句子末尾,而这些抽象符号又可以通过同一个公式组合使用,减轻了记忆负担。这是 Vim 非常优雅的地方。 不过有一个例外,如果你连续输入两个 Operator,就表示对当前行进行操作。比如 yy 表示复制当前行。


那 Vim 中有哪些常用的 Operator 呢,有以下这些:


至于 Motion,有更多,以下也是一些常用的:


当然还有更多,如果你感兴趣,可以在 Vim 的 Normal 模式下,输入以下命令查看完整的文档:

:h motion.txt

先存活下来


在成为 Vim 高手之前,我们的首要目标是先存活下来。这个目标其实并不难。


掌握基本的光标跳转,比如hlkj0^$ggG 等等,以及以上说的基本操作模式后,你大概率可以生存下来。当然知道不等于掌握,你需要频繁地练习把基本操作变成肌肉记忆。我一开始是跟着左耳朵耗子(在此纪念耗子叔)的文章《简明 VIM 练级攻略》练习,当时一旦有时间就打开文章,跟着内容逐条操作,一段时间后,我就真的存活下来了。


如果你也顺利存活了下来,在实际的开发过程中就已经可以使用 Vim 做一些编辑工作了,但可能总还是觉得哪儿哪儿不对劲,要完全行云流水还欠缺更多技巧。这个时候或许有必要去看看《Practival Vim》或者类似的书,更好地掌握 Vim 的设计理念以及许多细微的地方,同样配合不断的练习,我相信你迟早有一天会欣喜地发现自己在编码的时候几乎可以放弃鼠标了,这种喜悦或许类似于修仙小说中的破境。


恭喜你。


成为 Vim 高手的终极秘诀


其实没有秘诀。Vim 很快,但成为 Vim 高手是一个相对漫长的过程,在这个过程中你会掌握更多微妙的技能,比如如何更高效地使用 f{char} 命令更快地定位到某个字符。在生存下来以后,你唯一能做的就是每天使用 Vim。慢慢地, Vim 的使用会变成水和空气一样的自然存在,你从此离不开它。


如何每天使用 Vim 呢,以下是我的一些建议:

  • 把 Vim 变成日常开发工具。学习使用 Neovim,它提供了更好的插件和扩展机制,你如果愿意你甚至可以把 Neovim 配置成强大的 IDE。这里推荐一下掘金小册 Neovim 配置实战
  • 如果习惯使用 VSCode 或其他编辑器,可以安装相应的 Vim 插件
  • 如果你使用 Chrome 浏览器,你可以安装相应的 Vim 插件来提升浏览效率。
  • 平时习惯做笔记?那就使用一款支持 Vim 快捷键的笔记软件,比如我最喜欢的 Obsidian
  • 经常在 Cloud IDE 上写代码?建议使用一款支持 Vim 快捷键的 IDE,比如我常用的 Replit

总之,在我决定使用 Vim 提高编程效率以后,在任何编辑场景我都变得无法忍受没有 Vim 的存在,就是这么自然,它变成了我工作的一部分。Reddit 上有这样一条讨论,If using vim is a lifestyle/philosophy, what other products also fits into this lifestyle?,把 Vim 隐喻成一种生活方式/哲学确实很合适,Vim 和学习使用 Vim 隐含了一些有价值的东西,我相信大约有追求极致——更快更强的精神,坚持长期主义——忍受暂时痛苦,着眼长远的精神。或许也可以这么说,如果你有朝一日能成为 Vim 高手,你大概率也能做成其他许多困难的事。


少年们,加油。


作者:chaosflutter
链接:https://juejin.cn/post/7263676990736220220
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

把数据库里的未付款订单改成已付款,会发生什么

导言 不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改? 大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,...
继续阅读 »

导言


不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改?



大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,求转发,谢谢。



理论


在介绍具体的内容之间,先介绍MD5算法,简单的来说,MD5能把任意大小、长度的数据转换成固定长度的一串字符,经常玩大型游戏的朋友应该都注意到过,各种补丁包、端游客户端之类的大型文件一般都附有一个MD5值,用于确保你下载文件的完整性。那么在这里,我们可以借鉴其思想,对订单的某些属性进行加密计算,得出来一个 MD5值一并保存在数据库当中。从数据库取出数据后第一时间进行校验,如果有异常更改,那么及时抛出异常进行人工处理。


实现


道理我都懂,但是我要如何做呢,别急,且听我一一道来。


这种需求听起来并不强绑定于某个具体的业务需求,这就要用到了我们熟悉的鼎鼎有名的AOP(面向切面编程)来实现。


首先定义四个类型的注解作为AOP的切入点。@Sign@Validate都是作用在方法层面的,分别用于对方法的入参进行加签和验证方法的返回值的签名。@SignField用于注解关键的不容篡改的字段。@ValidateField用于注解保存计算后得出的签名值。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sign {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validate {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignField {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidField {
}

以订单的实体为例 sn,amt,status,userId就是关键字段,绝不能允许有人在落单到数据库后对这些字段偷偷篡改。

public class Order {
@SignField
private String sn;
@SignField
private String amt;
@SignField
private int status;
@SignField
private int userId;
@ValidField
private String sign;
}

下面就到了重头戏的部分,如何通过AOP来进行实现。


1. 定义切入点

@Pointcut("execution(@com.example.demo.annotations.Sign * *(..))")
public void signPointCut() {

}

@Pointcut("execution(@com.example.demo.annotations.Validate * *(..))")
public void validatePointCut() {

}

2.环绕切入点

@Around("signPointCut()")
public Object signAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
for (Object o : args) {
System.out.println(o);
sign(o);
}
Object res = pjp.proceed(args);
return res;
}

@Around("validatePointCut()")
public Object validateAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
Object res = pjp.proceed(args);
valid(res);
return res;
}

3. 签名的实现

  • 获取需要签名字段
private Map<String, String> getSignMap(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = new HashMap<>();
for (Field f : o.getClass().getDeclaredFields()) {
System.out.println(f.getName());
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(SignField.class)) {
String value = "";
f.setAccessible(true);
fieldNameToValue.put(f.getName(), f.get(o).toString());
}
}
}
return fieldNameToValue;
}
  • 计算出签名值,这里在属性名和属性值以外加入了我的昵称以防止他人猜测,同时使用了自定义的分隔符来加强密码强度。
private String getSign(Map<String, String> fieldNameToValue) {
List<String> names = new ArrayList<>(fieldNameToValue.keySet());
StringBuilder sb = new StringBuilder();
for (String name : names)
sb.append(name).append("@").append(fieldNameToValue.get(name));
System.out.println(sb.append("日暮与星辰之间").toString());
String signValue = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
return signValue;
}

  • 找到保存签名的字段
private Field getValidateFiled(Object o) {
for (Field f : o.getClass().getDeclaredFields()) {
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(ValidField.class)) {
return f;
}
}
}
return null;
}

  • 对保存签名的字段进行赋值
public void sign(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
if (validateField == null)
return;
String signValue = getSign(fieldNameToValue);
validateField.setAccessible(true);
validateField.set(o, signValue);
}

  • 对从数据库中取出的对象进行验证
public void valid(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
validateField.setAccessible(true);
String signValue = getSign(fieldNameToValue);
if (!Objects.equals(signValue, validateField.get(o))) {
throw new RuntimeException("数据非法");
}

}

使用示例


对将要保存到数据库的对象进行签名

@Sign
public Order save( Order order){
orderList.add(order);
return order;
}

验证从数据库中取出的对象是否合理

@Validate
public Order query(@ String sn){
return orderList.stream().filter(e -> e.getSn().equals(sn)).findFirst().orElse(null);
}

好文分享 ⬇️
从Offer收割机到延毕到失业再到大厂996,二零二二我的兵荒马乱 - 掘金


另类年终总结:在煤老板开的软件公司实习是怎样一种体验? - 掘金


第一次值守双十一,居然没有任何意外发生?! - 掘金


大厂996三个月,我曾迷失了生活的意义,努力找回中 - 掘金


阿里实习三个月,我学会了面试时讲好自己的项目,欢迎提问 - 掘金


迟到的苏州微软实习历险记 - 掘金


什么时候要用到本地缓存,比Redis还要快?怎么用? - 掘金


作者:日暮与星辰之间
链接:https://juejin.cn/post/7144378128914186270
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

聊一聊过度设计!

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码...
继续阅读 »

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码,过度设计的代码有比较高的理解成本。说这么多,到底什么是过度设计?


什么是过度设计?


  为了解释清楚,我这里用个类比,假如你想拧一颗螺丝,正常的解决方案是找一把螺丝刀,这很合理对吧。 但是有些人就想:“我就要一个不止能拧螺丝的工具,我想要一个可以干各种事的工具!”,于是就花大价钱搞了把瑞士军刀。在你解决“拧螺丝”问题的时候,重心早已从解决问题转变为搞一个工具,这就是过度设计。

   再举个更技术的例子,假设你出去面试,面试官让你写一个程序,可以实现两个数的加减乘除,方法出入参都给你提供好了 int calc(int x, int y, char op),普通程序员可能会写出以下实现。

    public int calc(int x, int y, int op) {
if (op == '+') {
return x + y;
} else if (op == '-') {
return x - y;
} else if (op == '*') {
return x * y;
} else {
return x / y;
}
}

  而高级程序员会运用设计模式,写出这样的代码:

public interface Strategy {
int calc(int x, int y);
}

public class AddStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x + y;
}
}

public class MinusStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x - y;
}
}
/**
* 其他实现
*/
public class Main {
public int calc(int x, int y, int op) {
Strategy add = new AddStrategy();
Strategy minux = new MinusStrategy();
Strategy multi = new MultiStrategy();
Strategy div = new DivStrategy();
if (op == '+') {
return add.calc(x, y);
} else if (op == '-') {
return minux.calc(x, y);
} else if (op == '*') {
return multi.calc(x, y);
} else {
return div.calc(x, y);
}
}
}

  策略模式好处在于将计算(calc)和具体的实现(strategy)拆分,后续如果修改具体实现,也不需要改动计算的逻辑,而且之后也可以加各种新的计算,比如求模、次幂……,扩展性明显增强,很是牛x。 但光从代码量来看,复杂度也明显增加。回到我们原始的需求上来看,如果我们只是需要实现两个整数的加减乘除,这明显过度设计了。


过度设计的坏处


  个人总结过度设计有两大坏处,首先就是前期的设计和开发的成本问题。过度设计的方案,首先设计的过程就需要投入额外的时间成本,其次越复杂的方案实现成本也就越高、耗时越长,如果是在快速迭代的业务中,这些可能都会决定到业务的生死。其次即便是代码正常上线后,其复杂度也会导致后期的维护成本高,比如当你想将这些代码交接给别人时,别人也需要付出额外的学习成本。


  如果成本问题你都可以接受,接下来这个问题可能影响更大,那就是过度设计可能会影响到代码的灵活性,这点听起来和做设计的目的有些矛盾,做设计不就是为了提升代码的灵活性和扩展性吗!实际上很多过度设计的方案搞错了扩展点,导致该灵活的地方不灵活,不该灵活的地方瞎灵活。在机器学习领域,有个术语叫做“过拟合”,指的是算法模型在测试数据上表现完美,但在更广泛的数据上表现非常差,模式缺少通用性。 过度设计也会出现类似的现象,就是缺少通用性,在面对稍有差异的需求上时可能就需要伤筋动骨级别的改造了。


如何避免过度设计


  既然过度设计有着成本高和欠灵活的问题,那如何避免过度设计呢!我这里总结了几个方法,希望可以帮到大家。


充分理解问题本身


  在设计的过程中,要确保充分理解了真正的问题是什么,明确真正的需求是什么,这样才可以避免做出错误的设计。


保持简单


  过度设计毫无例外都是复杂的设计,很多时候未来有诸多的不确定性,如果过早的针对某个不确定的问题做出方案,很可能就白做了,等遇到真正问题的时候再去解决问题就行。


小步快跑


  不要一开始就想着做出完美的方案,很多时候优秀的方案不是设计出来的,而是逐渐演变出来的,一点点优化已有的设计方案比一开始就设计出一个完美的方案容易得多。


征求其他人的意见


  如果你不确定自己的方案是不是过度设计了,可以咨询下其他人的,尤其是比较资深的人,交叉验证可以快速让你确认问题。


总结


  其实在业务的快速迭代之下,很难判定当前的设计是欠设计还是过度设计,你当前设计了一个简单的方案,未来可能无法适应更复杂的业务需求,但如果你当前设计了一个复杂的方案,有可能会浪费时间……。 在面对类似这种不确定性的时候,我个人还是比较推崇大道至简的哲学,当前用最简单的方案,等需要复杂性扩展的时候再去重构代码。


作者:xindoo
链接:https://juejin.cn/post/7204423284905738298
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Moshi:现代 Json 解析库全解析

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。 前言 Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 k...
继续阅读 »

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。


前言


Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 kotlin ,我们经常使用的 data class,其往往没有无参构造函数,Gson 便会通过 UnSafe 的方式创建实例,成员无法正常初始化默认值。为了勉强能用,只能将构造参数都加上默认值才行,不过这种兼容方式太过隐晦,有潜在的维护风险。


另外,Gson 无法支持 kotlin 空安全特性。定义为不可空且无默认值的字段,在没有该字段对应的 json 数据时会被赋值为 null,这可能导致使用时引发空指针问题。


Moshi


Moshi 是一个适用于 Android、Java 和 Kotlin 的现代 JSON 库。它可以轻松地将 JSON 解析为 Java 和 Kotlin 类。


val json: String = ...

val moshi: Moshi = Moshi.Builder().build()
val jsonAdapter: JsonAdapter = moshi.adapter()

val person = jsonAdapter.fromJson(json)

通过类型适配器 JsonAdapter 可以对数据类型 T 进行序列化/反序列化操作,即 toJsonfromJson 方法。


内置类型适配器


moshi 内置支持以下类型的类适配器:

  • 基本类型
  • Arrays, Collections, Lists, Sets, Maps
  • Strings
  • Enums


直接或间接由它们构成的自定义数据类型都可以直接解析。


反射 OR 代码生成


moshi 支持反射和代码生成两种方式进行 Json 解析。


反射的好处是无需对数据类做任何变动,可以解析 private 和 protected 成员,缺点是引入反射相关库,包体积增大2M多,且反射在性能上稍差。


代码生成的好处是速度更快,缺点是需要对数据类添加注解,无法处理 private 和 protected 成员,用于编译时生成代码,影响编译速度,且注解使用越来越多生成的代码也会越来越多。


反射方案依赖:


implementation("com.squareup.moshi:moshi-kotlin:1.14.0")

代码生成方案依赖(ksp):


plugins {
id("com.google.devtools.ksp").version("1.6.10-1.0.4") // Or latest version of KSP
}

dependencies {
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}

使用代码生成,需要使用注解 @JsonClass(generateAdapter = true) 修饰数据类:


@JsonClass(generateAdapter = true)
data class Person(
val name: String
)

使用反射时,需要添加 KotlinJsonAdapterFactoryMoshi.Builder


val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()

💡 注意:这里要使用 addLast 添加 KotlinJsonAdapterFactory,因为 Adapter 是按添加顺序排列和使用的,如果有自定义的 Adapter,为确保自定义的始终在前,建议通过 addLastKotlinJsonAdapterFactory 始终放在最后。


我们目前使用的是反射方案,主要考虑到侵入性低,数据类几乎无改动。


其实也可以两种方案都使用,Moshi 会优先使用代码生成的 Adapter,没有的话则走反射。


解析 JSON 数组


对于 json 数据:


[
{
"rank": "4",
"suit": "CLUBS"
},
{
"rank": "A",
"suit": "HEARTS"
}
]

解析:


String cardsJsonResponse = ...;
Type type = Types.newParameterizedType(List.class, Card.class);
JsonAdapter> adapter = moshi.adapter(type);
List cards = adapter.fromJson(cardsJsonResponse);

和 Gson 类似,为了运行时获取泛型信息,稍微麻烦点,可以定义扩展函数简化用法:


inline fun <reified T> Moshi.listAdapter(): JsonAdapter> {
val type = Types.newParameterizedType(List::class.java, T::class.java)
return adapter(type)
}

简化后:


String cardsJsonResponse = ...
val cards = moshi.listAdapter().fromJson(cardsJsonResponse)

自定义字段名


如果Json 中字段名和数据类中字段名不一致,或 json 中有空格,可以使用 @Json 注解修饰别名。


{
"username": "jesse",
"lucky number": 32
}

class Player {
val username: String
@Json(name = "lucky number") val luckyNumber: Int

...
}

忽略字段


使用 @Json(ignore = true) 可以忽略字段的解析,java 中的 @Transient 注解也可以。


class BlackjackHand(...) {
@Json(ignore = true)
var total: Int = 0

...
}

Java 支持


Moshi 同样支持 Java。需要注意的是,和 Gson 一样,Java 类需要有无参构造方法,否则成员变量的默认值无法生效。


public final class BlackjackHand {
private int total = -1;
...

public BlackjackHand(Card hidden_card, List visible_cards) {
...
}
}

如上,total 的默认值会为 0.


另外,和 Gson 不一样的是,Moshi 并不支持 JsonElement 这种中间产物,它只支持内置类型如 List、Map。


自定义 JsonAdapter


如果 json 的数据格式和我们想要的不一样,就需要我们自定义 JsonAdapter 来解析了。有意思的是,任何拥有 @Json@ToJson 注解的类都可以成为 Adapter,无需继承 JsonAdapter。


例如 json 格式:


{
"title": "Blackjack tournament",
"begin_date": "20151010",
"begin_time": "17:04"
}

目标数据类定义:


class Event(
val title: String,
val beginDateAndTime: String
)

我们希望 json 中日期 begin_date 和时间 begin_time 组成 beginDateAndTime 字段。moshi 支持我们在 json 和目标数据转换间定义一个中间类,json 和中间类转换后再转换为最终类型。


定义中间类型,本例中即和 json 匹配的数据类型:


class EventJson(
val title: String,
val begin_date: String,
val begin_time: String
)

定义 Adapter :


class EventJsonAdapter {
@FromJson
fun eventFromJson(eventJson: EventJson): Event {
return Event(
title = eventJson.title,
beginDateAndTime = "${eventJson.begin_date} ${eventJson.begin_time}"
)
}

@ToJson
fun eventToJson(event: Event): EventJson {
return EventJson(
title = event.title,
begin_date = event.beginDateAndTime.substring(0, 8),
begin_time = event.beginDateAndTime.substring(9, 14),
)
}
}

将 adapter 注册到 moshi:


val moshi = Moshi.Builder()
.add(EventJsonAdapter())
.build()

这样就可以使用 moshi 直接将 json 转换成 Event 了。本质是将 Json 和目标数据的相互转换加了个中间步骤,先转换为中间产物,再转为最终 Json 或数据实例。


@JsonQualifier:自定义字段类型解析


如下 json,color 为十六进制 rgb 格式的字符串:


{
"width": 1024,
"height": 768,
"color": "#ff0000"
}

数据类,color 为 Int 类型:


class Rectangle(
val width: Int,
val height: Int,
val color: Int
)

Json 中 color 字段类型是 String,数据类同名字段类型为 Int,除了上面介绍的自定义 JsonAdapter 外,还可以自定义同一数据的不同数据类型间的转换。


首先自定义注解:


@Retention(RUNTIME)
@JsonQualifier
annotation class HexColor

使用注解修饰字段:


class Rectangle(
val width: Int,
val height: Int,
@HexColor val color: Int
)

自定义 Adapter:


/** Converts strings like #ff0000 to the corresponding color ints.  */
class ColorAdapter {
@ToJson fun toJson(@HexColor rgb: Int): String {
return "#x".format(rgb)
}

@FromJson @HexColor fun fromJson(rgb: String): Int {
return rgb.substring(1).toInt(16)
}
}

通过这种方式,同一字段可以有不同的解析方式,可能不多见,但的确有用。


适配器组合


举个例子:


class UserKeynote(
val type: ResourceType,
val resource: KeynoteResource?
)

enum class ResourceType {
Image,
Text
}

sealed class KeynoteResource(open val id: Int)

data class Image(
override val id: Int,
val image: String
) : KeynoteResource(id)

data class Text(
override val id: Int,
val text: String
) : KeynoteResource(id)

UserKeynote 是目标类,其中的 KeynoteResource 可能是 ImageText ,具体是哪个需要根据 type 字段来决定。也就是说 UserKeynote 的解析需要 Image 或 Text 对应的 Adapter 来完成,具体是哪个取决于 type 的值。


显然自带的 Adapter 不能满足需求,需要自定义 Adapter。


先看下 Adapter 中签名要求(参见源码 AdapterMethodsFactory.java):


@FromJson


 R fromJson(JsonReader jsonReader) throws 

R fromJson(JsonReader jsonReader, JsonAdapter delegate, ) throws

R fromJson(T value) throws

@ToJson


 void toJson(JsonWriter writer, T value) throws 

void toJson(JsonWriter writer, T value, JsonAdapter delegate, ) throws

R toJson(T value) throws

前面分析了我们需要借助 Image 或 Text 对应的 Adapter,所以使用第二组函数签名:


class UserKeynoteAdapter {
private val namesOption = JsonReader.Options.of("type")

@FromJson
fun fromJson(
reader:
JsonReader,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
: UserKeynote {
// copy 一份 reader,得到 type
val newReader = reader.peekJson()
newReader.beginObject()
var type: String? = null
while (newReader.hasNext()) {
if (newReader.selectName(namesOption) == 0) {
type = newReader.nextString()
}
newReader.skipName()
newReader.skipValue()
}
newReader.endObject()

// 根据 type 做解析
val resource = when (type) {
ResourceType.Image.name -> {
imageJsonAdapter.fromJson(reader)
}

ResourceType.Text.name -> {
textJsonAdapter.fromJson(reader)
}

else -> throw IllegalArgumentException("unknown type $type")
}
return UserKeynote(ResourceType.valueOf(type), resource)
}

@ToJson
fun toJson(
writer:
JsonWriter,
userKeynote:
UserKeynote,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
{
when (userKeynote.resource) {
is Image -> imageJsonAdapter.toJson(writer, userKeynote.resource)
is Text -> textJsonAdapter.toJson(writer, userKeynote.resource)
null -> {}
}
}
}

函数接收一个 JsonReader / JsonWriter 以及若干 JsonAdapter,可以认为该 Adapter 由其他多个 Adapter 组合完成。这种委托的思路在 Moshi 中很常见,比如内置类型 List 的解析,便是委托给了 T 的适配器,并重复调用。


限制



  • 不要 Kotlin 类继承 Java 类

  • 不要 Java 类继承 Kotlin 类


这是官方强调不要做的,如果你那么做了,发现还没问题,不要侥幸,建议修改,毕竟有有维护风险,且会误导其他维护的人以为这样是可靠合理的。


作者:Aaron_Wang
来源:juejin.cn/post/7273516671575113743
收起阅读 »

面试官:如何防止重复提交订单?

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。 说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。 大有一种”无秒杀,不面试“的感觉了。...
继续阅读 »

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。


说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。


大有一种”无秒杀,不面试“的感觉了。


重复提交原因


其实原因无外乎两种:



  • 一种是由于用户在短时间内多次点击下单按钮,或浏览器刷新按钮导致。

  • 另一种则是由于Nginx或类似于SpringCloud Gateway的网关层,进行超时重试造成的。


常见解决方案


方案一:提交订单按钮置灰


这种解决方案在注册登录的场景下比较常见,当我们点击”发送验证码“按钮的时候,会进行手机短信验证码发送,且按钮就会有一分钟左右的置灰。


有些经验不太丰富的同学,通常会简单粗暴地把这个方案直接照搬过来。


但这种方案只能解决多次点击下单按钮的问题,对于Nginx或类似于SpringCloud Gateway的超时重试所导致的问题是无能为力的。


当然,这种方案也不是真的没有价值。它可以在高并发场景下,从浏览器端去拦住一部分请求,减少后端服务器的处理压力。


说到底,“下单防重”的问题是属于“接口幂等性”的问题范畴。



幂等性


接口幂等性是指:以相同的参数,对一个接口进行多次调用,所产生的结果和一次调用是完全相同的。


下面的情况就是幂等的:


student.setName("张三");

而这种情况就是非幂等的,因为每次调用,年龄都会增加一岁。


student.increaseAge(1);

现在我们的思路需要切换到幂等性的解决方案来。


同样是幂等性场景,“如何防止重复提交订单” 比 “如何防止订单重复支付” 的解决方案要难一些。


因为,后者在常规情况下,一个订单都是对应一笔支付单,所以orderID可以作为一个幂等性校验、防止订单重复支付的天然神器。


但这个方案在“如何防止重复提交订单”就不适用了,需要其他的解决方案,请继续看下文。


方案二:预生成全局唯一订单号


(1)后端新增一个接口,用于预生成一个“全局唯一订单号”,如:UUID 或 NanoID。


(2)进入创建订单页面时,前端请求该接口,获取该订单号。


(3)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。


btw:该“全局唯一订单号”不能代替数据库主键,在未分库分表场景下,主键还是用数据库自增ID比较好。



方案二


优点:彻底解决了重复下单的问题;


缺点:方案复杂,前后端都有开发工作量,还要新增接口,新增字段。


另外,网上还有同学说,要单独弄一个生成“全局唯一订单号”的服务,我觉得还是免了吧,这不是更麻烦了吗?


方案三:前端生成全局唯一订单号


这种方案是在借鉴了“方案二”的基础上,做了一些实现逻辑的简化。


(1)用户进入下页面时,前端程序自己生成一个“全局唯一订单号”。


(2)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。



方案三


优点:彻底解决了重复下单的问题,且技术方案做了一定简化;


缺点:前后端仍然都有开发工作量,且需要新增字段;


方案四:从订单业务的本质入手


先跟大家探讨一个概念,什么是订单?


其实,订单就是某个用户用特定的价格购买了某种商品,即:用户和商品的连接。


那么,“如何防止重复提交订单”,其实就是防止在短时间内,用户和商品进行多次连接。弄明白问题本质,接下来我们就着手制定技术方案了。


可以用 ”用户ID + 分隔符 + 商品ID“ 作为唯一标识,让持有相同标识的请求在短时间内不能重复下单,不就可以了吗?而且,Redis不正是做这种解决方案的利器吗?


Redis命令如下:


SET key value NX EX seconds


把”用户ID + 分隔符 + 商品ID“作为Redis key,并把”短时间所对应的秒数“设置为seconds,让它过期自动删除。


这样一来,整体业务步骤如下:


(1)在提交订单时,我们可以把”用户ID + 分隔符 + 商品ID“作为Redis key,并设置过期时间,让它可以到期自动删除。


(2)若Redis命令执行成功,则可以继续走下单的业务逻辑,执行不成功,直接返回给前端”下单失败“就可以了。



方案四


从上图来看,是不是实现方式越来越简单了?


优点:彻底解决了重复下单的问题,且在技术方案上,不需要前端参与,不需要添加接口,不需要添加字段;


缺点:综合比较而言,暂无明显缺点,如果硬要找缺点的话,可能强依赖于Redis勉强可以算上吧;


结语


在真正的生产环境下,我们最终选择了”方案四:从订单业务的本质入手“。


原因很简单,整体改动范围比较小,测试的回归范围也比较可控,且技术方案复杂度最低。


这样做技术选型的话,也比较符合百度一直倡导的”简单可依赖“原则。


作者:库森学长
来源:juejin.cn/post/7273024681631776829
收起阅读 »

聊聊Java中浮点丢失精度的事

在说这个之前,我们先看看十进制到二进制的转换过程 整数的十进制到二进制的转换过程 用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图 说一下为什么倒着排列就是二进制结果哈 通俗点说就...
继续阅读 »

在说这个之前,我们先看看十进制到二进制的转换过程


整数的十进制到二进制的转换过程


用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图


整数十进制转二进制.jpg
说一下为什么倒着排列就是二进制结果哈


通俗点说就是整数是一步一步除下来的,那回去不得一步一步乘上去?也就是说从上到下就是二进制从低位到高位的过程。


小数十进制到二进制的转换过程


小数的十进制到二进制的转换其实和整数类似,只不过算的方式变成了乘法,也就是用小数不断的乘2,然后得到的结果的整数部分拿出来,接着剩下的小数部分继续乘2,直到小数部分为0为止,直接上图~


小数十进制转二进制过程(不循环).jpg
二进制结果中的二分之一是转换后的,其实就是2的-1次方,-2次方。。。


当然了,小数转二进制的过程中,很多情况下都是无尽的,接着上图


小数十进制转二进制过程(循环).jpg
所以可以看到这样的循环下去是得不到二进制的结果的,所以计算机就要进行取舍。也就是IEEE 754规范


IEEE 754规范


IEEE 754规定了四种标识浮点数值的方式,单精确度(32位),双精确度(64位),延伸单精确度(43比特以上,很少用)和延伸双精确度(79比特以上,通常80位)


最常用的还是单精确度和双精确度,也就是对标的float和double。但是IEEE 754规范并没有解决精确标识小数的问题,只是提供了一种用近似值标识小数的方式。而且精确度不同近似值也会不同。# 为什么会精度丢失?教你看懂 IEEE-754!


下面有个例子来看一下丢失精度的问题,如0.1+0.2
0.1的64位二进制:0.00011001100110011001100110011001100110011001100110011001
0.2的64位二进制:0.00110011001100110011001100110011001100110011001100110011
二者相加的结果为:0.30000000000000004


那么如何解决精度问题呢?


BigDecimal


BigDecimal使用java.math包提供的,在涉及到金钱相关的计算的时候都需要使用它,而且其中提供了大量的方法,比如加减乘除都是可以直接调用的。


先看这个问题,BigDecimal中的比较问题


先看下面这个例子


public class ReferenceDemo {

public static void main(String[] args) {

BigDecimal bigDecimal1 = new BigDecimal(1);
BigDecimal bigDecimal2 = new BigDecimal(1);
System.out.println(bigDecimal1.equals(bigDecimal2));

BigDecimal bigDecimal3 = new BigDecimal(1);
BigDecimal bigDecimal4 = new BigDecimal(1.0);
System.out.println(bigDecimal3.equals(bigDecimal4));

BigDecimal bigDecimal5 = new BigDecimal("1");
BigDecimal bigDecimal6 = new BigDecimal("1.0");

System.out.println(bigDecimal5.equals(bigDecimal6));
}


}

结果为:


image.png
其中第二个例子和第三个例子的不同是需要聊一聊的。为什么会出现这种呢?下面是BigDecimal中的equals的源码。


public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
//关键在这一行,比较了scale
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);

return this.inflated().equals(xDec.inflated());
}

由上面的注释可以看到BigDecimal中有一个很关键的属性,就是scale,标度。标度是什么?
首先看一下BigDecimal的结构


public class BigDecimal extends Number implements Comparable<BigDecimal> {
/**
* The unscaled value of this BigDecimal, as returned by {@link
* #unscaledValue}.
*
* @serial
* @see #unscaledValue
*/

private final BigInteger intVal;

/**
* The scale of this BigDecimal, as returned by {@link #scale}.
*
* @serial
* @see #scale
*/

private final int scale; // Note: this may have any value, so
// calculations must be done in longs

/**
* If the absolute value of the significand of this BigDecimal is
* less than or equal to {@code Long.MAX_VALUE}, the value can be
* compactly stored in this field and used in computations.
*/

private final transient long intCompact;
}

我截取了几个关键字段,依次看一下:


intVal: 无标度值


scale: 标度


intCompact: 当intVal超过阈值(默认为Long.MAX_VALUE)时,进行压缩运算,结果存到这个字段上,用于后续计算。


注释中解释到,scale为0或者正数的时候代表数字小数点之后的位数,如果scale为负数,代表数字的无标度值需要乘10的该负数的绝对值的幂,即末尾有几个0


比如123.123这个数,他的intVal就是123123,scale就是3了


而二进制无法标识0.1,通过BigDecimal标识的话,它的intVal就是1,scale也是1。


接着看回上面的例子,传入的参数是字符串的bigDecimal5和bigDecimal6,为什么就返回了false。上图


image.png


他们的标度是不同的,所以直接返回了false,那么在看bigDecimal3和bigDecimal4的比较,为什么就返回了true呢,同样上图


image.png
可以看到他们的intVal和scale都是相等的,但是明明传入了不同的,有兴趣的可以取看看源码,找一些资料,对于1.0这个数,它本质上也是一个整数,经过一系列的运算他的intVal还是1,scale还是0,所以比较之后返回的是true。


这时候就能看出来equals方法的一些问题了,用equals涉及到scale的比较,实际的结果可能和预期不一样,所在BigDecimal的比较推荐用compareTo方法,如果返回0,代表相等


BigDecimal bigDecimal5 = new BigDecimal("1");
BigDecimal bigDecimal6 = new BigDecimal("1.0");

System.out.println(bigDecimal5.compareTo(bigDecimal6));

说到这里同时提一下,不要用传参为double的构造方法,同样会丢失精度,如果需要小数,需要传入字符串的小数来获取BigDecimal的实例对象。


说到这其实应该明白了他是怎么保证精度的了,其实关键点就是scale,这个标度贯穿了整个过程,加减乘除的运算都需要它来把控。上面说了其实2个参数最为关键,一个是无标度值,一个是标度,无标度值就是整数了,以加法为例子,不就可以变成整数的加法了吗,然后用scale控制小数点,说是这么说,实现过程还是很复杂的,有兴趣的可以自己查资料去学习。


除了用字符串代替double来表示BigDecimal的小数,其实也可以通过BigDecimal.valueOf()方法,它传入double之后可以和字符串一样的效果,为啥呢?上代码


public static BigDecimal valueOf(double val) {
// Reminder: a zero double returns '0.0', so we cannot fastpath
// to use the constant ZERO. This might be important enough to
// justify a factory approach, a cache, or a few private
// constants, later.
return new BigDecimal(Double.toString(val));
}

它把传入的double给toString了。。。。


作者:yulbo
来源:juejin.cn/post/7274692953058082877
收起阅读 »

谷歌是如何写技术文档的

Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。 作为软件工程师,我们的任务不仅仅...
继续阅读 »

Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。


作为软件工程师,我们的任务不仅仅是生成代码,而更多地是解决问题。像设计文档这样的非结构化文本可能是项目生命周期早期解决问题更好的工具,因为它可能更简洁易懂,并且以比代码更高层次的方式传达问题和解决方案。


除了原始软件设计文件外,设计文档还在以下方面发挥着作用:


在进行变更时及早识别出设计问题仍然较便宜。


在组织内达成对某个设计方案的共识。


确保考虑到跨领域关注点。


将资深工程师们掌握知识扩展到整个组织中去。


形成围绕设计决策建立起来的组织记忆基础。


作为技术人员投资组合中一份摘要性产物存在于其中。


设计文档的构成


设计文档是非正式的文件,因此其内容没有严格的指导方针。第一条规则是:以对特定项目最有意义的形式编写。


话虽如此,事实证明,某种结构已经被证明非常有用。


上下文和范围


本节为读者提供了新系统构建的大致概述以及实际正在构建的内容。这不是一份需求文档。保持简洁!目标是让读者迅速了解情况,但可以假设有一些先前的知识,并且可以链接到详细信息。本节应完全专注于客观背景事实。


目标和非目标


列出系统目标的简短项目列表,有时更重要的是列出非目标。请注意,非目标并不是否定性的目标,比如“系统不应崩溃”,而是明确选择不作为目标而合理可能成为目标的事项。一个很好的例子就是“ACID兼容性”;在设计数据库时,您肯定想知道是否将其作为一个目标或非目标。如果它是一个非目标,则仍然可以选择提供该功能的解决方案,前提是它不会引入阻碍实现这些目标的权衡考虑。


实际设计


这一部分应该以概述开始,然后进入细节。


image.png


设计文档是记录你在软件设计中所做的权衡的地方。专注于这些权衡,以产生具有长期价值的有用文档。也就是说,在给定上下文(事实)、目标和非目标(需求)的情况下,设计文档是提出解决方案并展示为什么特定解决方案最能满足这些目标的地方。


撰写文件而不是使用更正式的媒介之一的原因在于提供灵活性,以适当方式表达手头问题集。因此,并没有明确指导如何描述设计。


话虽如此,已经出现了一些最佳实践和重复主题,在大多数设计文档中都很合理:


系统上下文图


在许多文档中,系统上下文图非常有用。这样的图表将系统显示为更大的技术环境的一部分,并允许读者根据他们已经熟悉的环境来理解新设计。


image.png
一个系统上下文图的示例。


APIs


如果设计的系统暴露出一个API,那么草拟出该API通常是个好主意。然而,在大多数情况下,应该抵制将正式接口或数据定义复制粘贴到文档中的诱惑,因为这些定义通常冗长、包含不必要的细节,并且很快就会过时。相反,重点关注与设计及其权衡相关的部分。


数据存储


存储数据的系统可能需要讨论如何以及以什么样的形式进行存储。与对API的建议类似,出于同样的原因,应避免完全复制粘贴模式定义。而是专注于与设计及其权衡相关的部分。


代码和伪代码


设计文档很少包含代码或伪代码,除非描述了新颖的算法。在适当的情况下,可以链接到展示设计可实现性的原型。


约束程度


影响软件设计和设计文档形状的主要因素之一是解决方案空间的约束程度。


在极端情况下,有一个“全新软件项目”,我们只知道目标,解决方案可以是任何最合理的选择。这样的文档可能涉及范围广泛,但也需要快速定义一组规则,以便缩小到可管理的解决方案集。


另一种情况是系统中可能存在非常明确定义的解决方案,但如何将它们结合起来实现目标并不明显。这可能是一个难以更改且未设计为满足您期望功能需求的遗留系统,或者是需要在主机编程语言约束下运行的库设计。


在这种情况下,您可能能够相对容易地列举出所有可以做到的事情,但需要创造性地将这些事物组合起来实现目标。可能会有多个解决方案,并且没有一个真正很好,在识别了所有权衡后该文档应专注于选择最佳方式。


考虑的替代方案


本节列出了其他可能达到类似结果的设计方案。重点应放在每个设计方案所做的权衡以及这些权衡如何导致选择文档主题中所述设计的决策上。


尽管对于最终未被选中的解决方案可以简洁地进行描述,但是这一部分非常重要,因为它明确展示了根据项目目标而选择该解决方案为最佳选项,并且还说明了其他解决方案引入了不太理想的权衡,读者可能会对此产生疑问。


交叉关注点


这是您的组织可以确保始终考虑到安全、隐私和可观察性等特定的交叉关注点的地方。这些通常是相对简短的部分,解释设计如何影响相关问题以及如何解决这些问题。团队应该在他们自己的情况下标准化这些关注点。


由于其重要性,Google项目需要有专门的隐私设计文档,并且还有专门针对隐私和安全进行Review。尽管Review只要求在项目启动之前完成,但最佳实践是尽早与隐私和安全团队合作,以确保从一开始就将其纳入设计中。如果针对这些主题有专门文档,则中央设计文档当然可以引用它们而不详述。


设计文档的长度


设计文档应该足够详细,但又要短到忙碌的人实际上能读完。对于较大的项目来说,最佳页数似乎在10-20页左右。如果超过这个范围,可能需要将问题拆分成更易管理的子问题。还应注意到,完全可以编写一个1-3页的“迷你设计文档”。这对于敏捷项目中的增量改进或子任务尤其有帮助 - 你仍然按照长篇文档一样进行所有步骤,只是保持简洁,并专注于有限的问题集合。


何时不需要编写设计文档


编写设计文档是一种额外的工作量。是否要编写设计文档的决策取决于核心权衡,即组织共识在设计、文档、高级Review等方面的好处是否超过了创建文档所需的额外工作量。这个决策的核心在于解决设计问题是否模糊——因为问题复杂性或解决方案复杂性,或者两者都有。如果不模糊,则通过撰写文档来进行流程可能没有太大价值。


一个明确的指标表明可能不需要文档是那些实际上只是实施手册而非设计文档。如果一个文件基本上说“这就是我们将如何实现它”,而没有涉及权衡、替代方案和解释决策(或者解决方案显然意味着没有任何权衡),那么直接编写程序可能会更好。


最后,创建和Review设计文档所需的开销可能与原型制作和快速迭代不兼容。然而,大多数软件项目确实存在一系列已知问题。遵循敏捷方法论并不能成为对真正已知问题找到正确解决方案时间投入不足的借口。此外,原型制作本身可以是设计文档创建的一部分。“我尝试过,它有效”是选择一个设计方案的最佳论据之一。


设计文档的生命周期


设计文档的生命周期包括以下步骤:


创建和快速迭代
Review(可能需要多轮)
实施和迭代
维护和学习


创作和快速迭代


你撰写文档。有时与一组合著者共同完成。


这个阶段很快进入了一个快速迭代的时间,文档会与那些对问题领域最了解的同事(通常是同一个团队的人)分享,并通过他们提出的澄清问题和建议来推动文档达到第一个相对稳定版本。


虽然你肯定会找到一些工程师甚至团队更喜欢使用版本控制和代码Review工具来创建文档,但在谷歌,绝大多数设计文档都是在Google Docs中创建并广泛使用其协作功能。


Review


在Review阶段,设计文档会与比原始作者和紧密合作者更广泛的受众分享。Review可以增加很多价值,但也是一个危险的开销陷阱,所以要明智地对待。


Review可以采取多种形式:较轻量级的版本是将文档发送给(更广泛的)团队列表,让大家有机会看一下。然后主要通过文档中的评论线程进行讨论。在Review方面较重型的方式是正式的设计评审会议,在这些会议上作者通常通过专门制作的演示文稿向经验丰富、资深工程师们展示该文档内容。谷歌公司许多团队都定期安排了此类会议,并邀请工程师参加审核。自然而然地等待这样的会议可能会显著减慢开发过程。工程师可以通过直接寻求最关键反馈并不阻碍整体审核进度来缓解这个问题。


当谷歌还是一家较小的公司时,通常会将设计发送到一个中央邮件列表,高级工程师会在自己的闲暇时间进行Review。这可能是处理公司事务的好方法。其中一个好处是确立了相对统一的软件设计文化。但随着公司规模扩大到更庞大的工程团队,维持集中式方法变得不可行。


此类Review所添加的主要价值在于它们为组织的集体经验提供了融入设计的机会。最重要的是,在Review阶段可以确保考虑到横切关注点(如可观察性、安全性和隐私)等方面。Review的主要价值并非问题被发现本身,而是这些问题相对早期地在开发生命周期内被发现,并且修改仍然相对廉价。


实施和迭代


当事情进展到足够程度,有信心进一步Review不需要对设计进行重大更改时,就是开始实施的时候了。随着计划与现实的碰撞,不可避免地会出现缺陷、未解决的需求或者被证明错误的猜测,并且需要修改设计。强烈建议在这种情况下更新设计文档。作为一个经验法则:如果设计系统尚未发布,则绝对要更新文档。在实践中,我们人类很擅长忘记更新文件,并且由于其他实际原因,变更通常被隔离到新文件中。这最终导致了一个类似于美国宪法带有一堆修正案而不是一份连贯文档的状态。从原始文档链接到这些修正案可以极大地帮助那些试图通过设计文档考古学来理解目标系统的可怜未来维护程序员们。


维护和学习


当谷歌工程师面对一个之前没有接触过的系统时,他们经常会问:“设计文档在哪里?”虽然设计文档(像所有文档一样)随着时间推移往往与现实脱节,但它们通常是了解系统创建背后思考方式最容易入手的途径。


作为作者,请你给自己一个方便,并在一两年后重新阅读你自己的设计文档。你做得对了什么?你做错了什么?如果今天要做出不同决策,你会怎么选择?回答这些问题是作为一名工程师进步并改善软件设计技能的好方法。


结论


设计文档是在软件项目中解决最困难问题时获得清晰度和达成共识的好方法。它们可以节省金钱,因为避免了陷入编码死胡同而无法实现项目目标,并且可以通过前期调查来避免这种情况;但同时也需要花费时间和金钱进行创建和Review。所以,在选择项目时要明智!


在考虑撰写设计文档时,请思考以下几点:


您是否对正确的软件设计感到不确定,是否值得花费前期时间来获得确定性?


相关地,是否有必要让资深工程师参与其中,即使他们可能无法Review每个代码更改,在设计方面能提供帮助吗?


软件设计是否模糊甚至具有争议性,以至于在组织中达成共识将是有价值的?


我的团队是否有时会忘记在设计中考虑隐私、安全、日志记录或其他横切关注点?


组织中对遗留系统的高层次洞察力提供文档存在强烈需求吗?


如果您对以上3个或更多问题回答“是”,那么撰写一个设计文档很可能是开始下一个软件项目的好方法。


Reference


http://www.industrialempathy.com/posts/desig…


作者:dooocs
来源:juejin.cn/post/7272730352710418447
收起阅读 »

浅谈多人游戏原理和简单实现

一、我的游戏史 我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿...
继续阅读 »



一、我的游戏史


我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。


后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。


再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!


最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。


不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?


二、解惑


在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!


参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!


直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章


知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。




三、简单实现


客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。
为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!


3.1 客户端实现步骤


我在这里客户端使用HTML+JQ实现


客户端——1代码:


(1)创建画布

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas Game</title>
<style>
canvas {
border: 1px solid black;
}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<canvas id="gameCanvas" width="800" height="800"></canvas>
</body>
</html>

(2)设置1s60帧更新页面

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function gameLoop() {
clearCanvas();
players.forEach(player => {
player.draw();
});
}
setInterval(gameLoop, 1000 / 60);
//清除画布方法
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}

(3)连接游戏服务器并处理指令


这里使用websocket链接游戏服务器

 //连接服务器
const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName);
//向服务器发送消息
function sendMessage(userId,keyCode){
const messageData = {
playerId: userId,
keyCode: keyCode
};
websocket.send(JSON.stringify(messageData));
}
//接收服务器消息,并根据不同的指令,做出不同的动作
websocket.onmessage = event => {
const data = JSON.parse(event.data);
// 处理服务器发送过来的消息
console.log('Received message:', data);
//创建游戏对象
if(data.type == 1){
console.log("玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
console.log("玩家id:"+playerOfIds);
createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);
}
}
//销毁游戏对象
if(data.type == 2){
console.log("玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
destroyPlayer(data.players[i].playerId)
}
}
//移动游戏对象
if(data.type == 3){
console.log("移动;玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)
}
}
};

(4)创建玩家对象

//存放游戏对象
let players = [];
//playerId在此写死,正常情况下应该是用户登录获取的
const userId = "1"; // 用户的 id
const userName = "逆风笑"; // 用户的名称
//玩家对象
class Player {
constructor(id,x, y, color) {
this.id = id;
this.x = x;
this.y = y;
this.size = 30;
this.color = color;
}
//绘制游戏角色方法
draw() {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.size, this.size);
}
//游戏角色移动方法
move(keyCode) {
switch (keyCode) {
case 37: // Left
this.x = Math.max(0, this.x - 10);
break;
case 38: // Up
this.y = Math.max(0, this.y - 10);
break;
case 39: // Right
this.x = Math.min(canvas.width - this.size, this.x + 10);
break;
case 40: // Down
this.y = Math.min(canvas.height - this.size, this.y + 10);
break;
}
this.draw();
}
}

(5)客户端创建角色方法

//创建游戏对象方法
function createPlayer(id,x, y, color) {
const player = new Player(id,x, y, color);
players.push(player);
playerOfIds.push(id);
return player;
}

(6)客户端销毁角色方法


在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。

//角色销毁
function destroyPlayer(playId){
players = players.filter(player => player.id !== playId);
}

客户端——2代码:


客户端2的代码只有玩家信息不一致:

  const userId = "2"; // 用户的 id
const userName = "逆风哭"; // 用户的名称

3.2 服务器端


服务器端使用Java+websocket来实现!


(1)引入依赖:

 <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.3.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.6.3</version>
</dependency>

(2)创建服务器

@Component
@ServerEndpoint("/websocket")
@Slf4j
public class Server {
/**
* 服务器玩家池
* 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题
* 使用 static fina修饰 是为了保证 playerPool 全局唯一
*/
private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();
/**
* 存储玩家信息
*/
private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();
/**
* 已经被创建了的玩家id
*/
private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();

private Session session;

private Player player;

/**
* 连接成功后调用的方法
*/
@OnOpen
public void webSocketOpen(Session session) throws IOException {
Map<String, List<String>> requestParameterMap = session.getRequestParameterMap();
String userId = requestParameterMap.get("userId").get(0);
String userName = requestParameterMap.get("userName").get(0);
this.session = session;
if (!playerPool.containsKey(userId)) {
int locationX = getLocation(151);
int locationY = getLocation(151);
String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1);
Player newPlayer = new Player(userId, userName, locationX, locationY,color,null);
playerPool.put(userId, this);
this.player = newPlayer;
//存放玩家信息
playerInfo.put(userId,newPlayer);
}
log.info("玩家:{}|{}连接了服务器", userId, userName);
// 创建游戏对象
this.createPlayer(userId);
}

/**
* 接收到消息调用的方法
*/
@OnMessage
public void onMessage(String message, Session session) throws IOException, InterruptedException {
log.info("用户:{},消息{}:",this.player.getPlayerId(),message);
PlayerDTO playerDTO = new PlayerDTO();
Player player = JSONObject.parseObject(message, Player.class);
List<Player> players = new ArrayList<>();
players.add(player);
playerDTO.setPlayers(players);
playerDTO.setType(OperationType.MOVE_OBJECT.getCode());
String returnMessage = JSONObject.toJSONString(playerDTO);
//广播所有玩家
for (String key : playerPool.keySet()) {
synchronized (session){
String playerId = playerPool.get(key).player.getPlayerId();
if(!playerId.equals(this.player.getPlayerId())){
playerPool.get(key).session.getBasicRemote().sendText(returnMessage);
}
}
}
}

/**
* 关闭连接调用方法
*/
@OnClose
public void onClose() throws IOException {
String playerId = this.player.getPlayerId();
log.info("玩家{}退出!", playerId);
Player playerBaseInfo = playerInfo.get(playerId);
//移除玩家
for (String key : playerPool.keySet()) {
playerPool.remove(playerId);
playerInfo.remove(playerId);
createdPlayer.remove(playerId);
}
//通知客户端销毁对象
destroyPlayer(playerBaseInfo);
}

/**
* 出现错误时调用的方法
*/
@OnError
public void onError(Throwable error) {
log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage());
}
/**
* 获取随即位置
* @param seed
* @return
*/
private int getLocation(Integer seed){
Random random = new Random();
return random.nextInt(seed);
}
}

websocket配置:

@Configuration
public class ServerConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}


(3)创建玩家对象


玩家对象:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
/**
* 玩家id
*/
private String playerId;
/**
* 玩家名称
*/
private String playerName;
/**
* 玩家生成的x坐标
*/
private Integer pointX;
/**
* 玩家生成的y坐标
*/
private Integer pointY;
/**
* 玩家生成颜色
*/
private String color;
/**
* 玩家动作指令
*/
private Integer keyCode;
}

创建玩家对象返回给客户端DTO:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlayerDTO {
private Integer type;
private List<Player> players;
}

玩家移动指令返回给客户端DTO:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlayerMoveDTO {
private Integer type;
private List<Player> players;
}


(4)动作指令

public enum OperationType {
CREATE_OBJECT(1,"创建游戏对象"),
DESTROY_OBJECT(2,"销毁游戏对象"),
MOVE_OBJECT(3,"移动游戏对象"),
;
private Integer code;
private String value;

OperationType(Integer code, String value) {
this.code = code;
this.value = value;
}

public Integer getCode() {
return code;
}

public String getValue() {
return value;
}
}

(5)创建对象方法

  /**
* 创建对象方法
* @param playerId
* @throws IOException
*/
private void createPlayer(String playerId) throws IOException {
if (!createdPlayer.containsKey(playerId)) {
List<Player> players = new ArrayList<>();
for (String key : playerInfo.keySet()) {
Player playerBaseInfo = playerInfo.get(key);
players.add(playerBaseInfo);
}
PlayerDTO playerDTO = new PlayerDTO();
playerDTO.setType(OperationType.CREATE_OBJECT.getCode());
playerDTO.setPlayers(players);
String syncInfo = JSONObject.toJSONString(playerDTO);
for (String key :
playerPool.keySet()) {
playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
}
// 存放
createdPlayer.put(playerId, this);
}
}

(6)销毁对象方法

   /**
* 销毁对象方法
* @param playerBaseInfo
* @throws IOException
*/
private void destroyPlayer(Player playerBaseInfo) throws IOException {
PlayerDTO playerDTO = new PlayerDTO();
playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());
List<Player> players = new ArrayList<>();
players.add(playerBaseInfo);
playerDTO.setPlayers(players);
String syncInfo = JSONObject.toJSONString(playerDTO);
for (String key :
playerPool.keySet()) {
playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
}
}

四、演示


4.1 客户端1登陆服务器




4.2 客户端2登陆服务器




4.3 客户端2移动




4.4 客户端1移动




4.5 客户端1退出



 完结撒花


完整代码传送门


五、总结


以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!
我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~


后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~


作者:是江迪呀
链接:https://juejin.cn/post/7273429629398581282
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

往往排查很久的问题,最后发现都非常简单。。。

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。 大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommi...
继续阅读 »

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。


大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommitException,并且集中在灰度机器上。

E20:21:59.770 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR [Consumer clientId=xx-xx.4-0, groupId=xx-xx-consumer_[gray]] Offset commit with offsets {xx-xx-xx-callback-1=OffsetAndMetadata{offset=181894918, leaderEpoch=4, metadata=''}, xx-xx-xx-callback-0=OffsetAndMetadata{offset=181909228, leaderEpoch=5, metadata=''}} failed org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to send request after 30000 ms.


排查


检查了这个 Topic 的流量流入、流出情况,发现并不是很高,至少和 QA 环境的压测流量对比,连零头都没有达到。


但是从发生异常的这个 Topic 的历史流量来看的话,发生问题的那几个时间点的流量又确实比平时高出了很多。



同时我们检查 Broker 集群的负载情况,发现那几个时间点的 CPU 负载也比平时也高出很多(也只是比平时高,整体并不算高)。



对Broker集群的日志排查,也没发现什么特殊的地方。


然后我们对这个应用在QA上进行了模拟,尝试复现,遗憾的是,尽管我们在QA上把生产流量放大到很多倍并尝试了多次,问题还是没能出现。


此时,我们把问题归于当时的网络环境,这个结论在当时其实是站不住脚的,如果那个时刻网络环境发生了抖动的话,其它应用为什么没有这类异常?


可能其它的服务实例网络情况是好的,只是发生问题的这个灰实例网络发生了问题。


那问题又来了,为什么这个实例的其它 Topic 没有报出异常,偏偏问题只出现在这个 Topic 呢?。。。。。。。。。


至此,陷入了僵局,无从下手的感觉。


从这个客户端的开发、测试到压测,如果有 bug 的话,不可能躲过前面那么多环节,偏偏爆发在了生产环境。


没办法了,我们再次进行了一次灰度发布,如果过了一夜没有事情发生,我们就把问题划分到环境问题,如果再次出现问题的话,那就只能把问题划分到我们实现的 Kafka 客户端的问题了。


果不其然,发布后的第二天凌晨1点多,又出现了大量的 RetriableCommitFailedException,只是这次换了个 Topic,并且异常的原因又多出了其它Caused by 。

org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.DisconnectException
...
...
E16:23:31.640 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR 
...
...
org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: The request timed out.

分析


这次出现的异常与之前异常的不同之处在于:

  1. 1. Topic 变了
  2. 2. 异常Cause变了

而与之前异常又有相同之处:

  1. 1. 只发生在灰度消费者组
  2. 2. 都是RetriableCommitFailedException

RetriableCommitFailedException 意思很明确了,可以重试提交的异常,网上搜了一圈后仅发现StackOverFlow上有一问题描述和我们的现象相似度很高,遗憾的是没人回复这个问题:StackOverFlow。


我们看下 RetriableCommitFailedException 这个异常和产生这个异常的调用层级关系。



除了产生异常的具体 Cause 不同,剩下的都是让我们再 retry,You should retry Commiting the lastest consumed offsets。



从调用层级上来看,我们可以得到几个关键的信息,commit 、 async。


再结合异常发生的实例,我们可以得到有用关键信息: 灰度、commit 、async。


在灰度消息的实现上,我们确实存在着管理位移和手动提交的实现。



看代码的第 62 行,如果当前批次消息经过 filter 的过滤后一条消息都不符合当前实例消费,那么我们就把当前批次进行手动异步提交位移。结合我们在生产的实际情况,在灰度实例上我们确实会把所有的消息都过滤掉,并异步提交位移。


为什么我们封装的客户端提交就会报大量的报错,而使用 spring-kafka 的没有呢?


我们看下Spring对提交位移这块的核心实现逻辑。



可以同步,也可以异步提交,具体那种提交方式就要看 this.containerProperties.isSyncCommits() 这个属性的配置了,然而我们一般也不会去配置这个东西,大部分都是在使用默认配置。



人家默认使用的是同步提交方式,而我们使用的是异步方式。


同步提交和异步提交有什么区别么?


先看下同步提交的实现:



只要遇到了不是不可恢复的异常外,在 timer 参数过期时间范围内重试到成功(这个方法的描述感觉不是很严谨的样子)。



我们在看下异步提交方式的核心实现:



我们不要被第 645 行的 RequestFuture future = sendOffsetCommitRequest(offsets) 所迷惑,它其实并不是发送位移提交的请求,它内部只是把当前请求包装好,放到 private final UnsentRequests unsent = new UnsentRequests(); 这个属性中,同时唤醒真正的发送线程来发送的。



这里不是重点,重点是如果我们的异步提交发生了异常,它只是简单的使用 RetriableCommitFailedException 给我们包装了一层。


重试呢?为什么异步发送产生了可重试异常它不给我们自动重试?


如果我们对多个异步提交进行重试的话,很大可能会导致位移覆盖,从而引发重复消费的问题。


正好,我们遇到的所有异常都是 RetriableCommitException 类型的,也就是说,我们把灰度位移提交的方式修改成同步可重试的提交方式,就可以解决我们遇到的问题了。


作者:艾小仙
链接:https://juejin.cn/post/7214398563023274021
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

刚咬了一口馒头,服务器突然炸了!

首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。 其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket...
继续阅读 »

首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。
其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket管理器。


看着群里一群“小可爱”疯狂乱叫,我被吵的头都炸了,赶紧尝试定位问题。



  1. 查看是否存在Jenkins发版 -> 无

  2. 查看最新提交记录 -> 最后一次提交是下午,到晚上这段时间系统一直是稳定的

  3. 查看服务器资源,htop后,发现三台相关的云服务器资源都出现闲置状态

  4. 查看PolarDB后,既MySQL,连接池正常、吞吐量和锁正常

  5. 查看Redis,资源正常,无异常key

  6. 查看前端控制台,出现一些报错,但是这些报错经常会变化


  7. 查看前端测试环境、后端测试环境,程序全部正常

  8. 重启前端服务、后端服务、NGINX服务,好像没用,过了5分钟后,咦,好像可以访问了


就在我们组里的“小可爱”通知系统恢复正常后,20分钟不到,再一次处于无法打开的状态,沃焯!!!
完蛋了,找不出问题在哪里了,实在想不通问题究竟出在哪里。


我不服啊,我不理解啊!


咦,Nginx?对呀,我瞅瞅访问日志,于是我浏览了一下access.log,看起来貌似没什么问题,不过存在大量不同浏览器的访问记录,刷的非常快。


再瞅瞅error.log,好像哪里不太对


2023/04/20 23:15:35 [alert] 3348512#3348512: 768 worker_connections are not enough
2023/04/20 23:33:33 [alert] 3349854#3349854: *3492 open socket #735 left in connection 1013

这是什么?貌似是连接池问题,赶紧打开nginx.conf看一下配置


events {
worker_connections 666;
# multi_accept on;
}

???


运维小可爱,你特么在跟我开玩笑?虽然我们这个系统是给B端用的,还是我们自己组的不到100人,但是这连接池给的也太少了吧!


另外,前端为什么会开到 1000 个 WS 连接呢?后端为什么没有释放掉FD呢?


询问后,才知道,前端的Socket管理器,会在连接超时或者其它异常情况下,重新开启一个WS连接。


后端的心跳配置给了300秒


Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket 协议
Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,
Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,

此时修改nginx.conf的配置,直接拉满!!!


worker_connections 655350;

重启Nginx,哇绰,好像可以访问了,但是每当解决一个问题,总会产生新的问题。


此时error.log中出现了新的报错:


2023/04/20 23:23:41 [crit] 3349767#3349767: accept4() failed (24: Too many open files)

这种就不怕了,貌似和Linux的文件句柄限制有关系,印象中是1024个。
至少有方向去排查,不是吗?而且这已经算是常规问题了,我只需小小的百度一下,哼哼~


拉满拉满!!


worker_rlimit_nofile 65535;

此时再次重启Nginx服务,系统恢复稳定,查看当前连接数:


netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

# 打印结果
TIME_WAIT 1175

FIN_WAIT1 52

SYN_RECV 1

FIN_WAIT2 9

ESTABLISHED 2033

经过多次查看,发现TIME_WAITESTABLISHED都在不断减少,最后完全降下来。


本次问题排查结束,问题得到了解决,但是关于socket的连接池管理器,仍然需要优化,及时释放socket无用的连接。


作者:兰陵笑笑生666
来源:juejin.cn/post/7224314619865923621
收起阅读 »

浅谈多人游戏原理和简单实现

一、我的游戏史 我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿...
继续阅读 »

在这里插入图片描述


一、我的游戏史


我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。


后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。


再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!


最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。


不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?


二、解惑


在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!


参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!


直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章


知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。


在这里插入图片描述


三、简单实现


客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。
为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!


3.1 客户端实现步骤


我在这里客户端使用HTML+JQ实现


客户端——1代码:


(1)创建画布


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas Game</title>
<style>
canvas {
border: 1px solid black;
}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<canvas id="gameCanvas" width="800" height="800"></canvas>
</body>
</html>

(2)设置1s60帧更新页面


const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function gameLoop() {
clearCanvas();
players.forEach(player => {
player.draw();
});
}
setInterval(gameLoop, 1000 / 60);
//清除画布方法
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}

(3)连接游戏服务器并处理指令


这里使用websocket链接游戏服务器


 //连接服务器
const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName);
//向服务器发送消息
function sendMessage(userId,keyCode){
const messageData = {
playerId: userId,
keyCode: keyCode
};
websocket.send(JSON.stringify(messageData));
}
//接收服务器消息,并根据不同的指令,做出不同的动作
websocket.onmessage = event => {
const data = JSON.parse(event.data);
// 处理服务器发送过来的消息
console.log('Received message:', data);
//创建游戏对象
if(data.type == 1){
console.log("玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
console.log("玩家id:"+playerOfIds);
createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);
}
}
//销毁游戏对象
if(data.type == 2){
console.log("玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
destroyPlayer(data.players[i].playerId)
}
}
//移动游戏对象
if(data.type == 3){
console.log("移动;玩家信息:" + data.players.length)
for (let i = 0; i < data.players.length; i++) {
players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)
}
}
};

(4)创建玩家对象


//存放游戏对象
let players = [];
//playerId在此写死,正常情况下应该是用户登录获取的
const userId = "1"; // 用户的 id
const userName = "逆风笑"; // 用户的名称
//玩家对象
class Player {
constructor(id,x, y, color) {
this.id = id;
this.x = x;
this.y = y;
this.size = 30;
this.color = color;
}
//绘制游戏角色方法
draw() {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.size, this.size);
}
//游戏角色移动方法
move(keyCode) {
switch (keyCode) {
case 37: // Left
this.x = Math.max(0, this.x - 10);
break;
case 38: // Up
this.y = Math.max(0, this.y - 10);
break;
case 39: // Right
this.x = Math.min(canvas.width - this.size, this.x + 10);
break;
case 40: // Down
this.y = Math.min(canvas.height - this.size, this.y + 10);
break;
}
this.draw();
}
}

(5)客户端创建角色方法


//创建游戏对象方法
function createPlayer(id,x, y, color) {
const player = new Player(id,x, y, color);
players.push(player);
playerOfIds.push(id);
return player;
}

(6)客户端销毁角色方法


在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。


//角色销毁
function destroyPlayer(playId){
players = players.filter(player => player.id !== playId);
}

客户端——2代码:


客户端2的代码只有玩家信息不一致:


  const userId = "2"; // 用户的 id
const userName = "逆风哭"; // 用户的名称

3.2 服务器端


服务器端使用Java+websocket来实现!


(1)引入依赖:


 <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.3.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.6.3</version>
</dependency>

(2)创建服务器


@Component
@ServerEndpoint("/websocket")
@Slf4j
public class Server {
/**
* 服务器玩家池
* 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题
* 使用 static fina修饰 是为了保证 playerPool 全局唯一
*/

private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();
/**
* 存储玩家信息
*/

private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();
/**
* 已经被创建了的玩家id
*/

private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();

private Session session;

private Player player;

/**
* 连接成功后调用的方法
*/

@OnOpen
public void webSocketOpen(Session session) throws IOException {
Map<String, List<String>> requestParameterMap = session.getRequestParameterMap();
String userId = requestParameterMap.get("userId").get(0);
String userName = requestParameterMap.get("userName").get(0);
this.session = session;
if (!playerPool.containsKey(userId)) {
int locationX = getLocation(151);
int locationY = getLocation(151);
String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1);
Player newPlayer = new Player(userId, userName, locationX, locationY,color,null);
playerPool.put(userId, this);
this.player = newPlayer;
//存放玩家信息
playerInfo.put(userId,newPlayer);
}
log.info("玩家:{}|{}连接了服务器", userId, userName);
// 创建游戏对象
this.createPlayer(userId);
}

/**
* 接收到消息调用的方法
*/

@OnMessage
public void onMessage(String message, Session session) throws IOException, InterruptedException {
log.info("用户:{},消息{}:",this.player.getPlayerId(),message);
PlayerDTO playerDTO = new PlayerDTO();
Player player = JSONObject.parseObject(message, Player.class);
List<Player> players = new ArrayList<>();
players.add(player);
playerDTO.setPlayers(players);
playerDTO.setType(OperationType.MOVE_OBJECT.getCode());
String returnMessage = JSONObject.toJSONString(playerDTO);
//广播所有玩家
for (String key : playerPool.keySet()) {
synchronized (session){
String playerId = playerPool.get(key).player.getPlayerId();
if(!playerId.equals(this.player.getPlayerId())){
playerPool.get(key).session.getBasicRemote().sendText(returnMessage);
}
}
}
}

/**
* 关闭连接调用方法
*/

@OnClose
public void onClose() throws IOException {
String playerId = this.player.getPlayerId();
log.info("玩家{}退出!", playerId);
Player playerBaseInfo = playerInfo.get(playerId);
//移除玩家
for (String key : playerPool.keySet()) {
playerPool.remove(playerId);
playerInfo.remove(playerId);
createdPlayer.remove(playerId);
}
//通知客户端销毁对象
destroyPlayer(playerBaseInfo);
}

/**
* 出现错误时调用的方法
*/

@OnError
public void onError(Throwable error) {
log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage());
}
/**
* 获取随即位置
* @param seed
* @return
*/

private int getLocation(Integer seed){
Random random = new Random();
return random.nextInt(seed);
}
}

websocket配置:


@Configuration
public class ServerConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}


(3)创建玩家对象


玩家对象:


@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
/**
* 玩家id
*/

private String playerId;
/**
* 玩家名称
*/

private String playerName;
/**
* 玩家生成的x坐标
*/

private Integer pointX;
/**
* 玩家生成的y坐标
*/

private Integer pointY;
/**
* 玩家生成颜色
*/

private String color;
/**
* 玩家动作指令
*/

private Integer keyCode;
}

创建玩家对象返回给客户端DTO:


@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlayerDTO {
private Integer type;
private List<Player> players;
}

玩家移动指令返回给客户端DTO:


@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlayerMoveDTO {
private Integer type;
private List<Player> players;
}


(4)动作指令


public enum OperationType {
CREATE_OBJECT(1,"创建游戏对象"),
DESTROY_OBJECT(2,"销毁游戏对象"),
MOVE_OBJECT(3,"移动游戏对象"),
;
private Integer code;
private String value;

OperationType(Integer code, String value) {
this.code = code;
this.value = value;
}

public Integer getCode() {
return code;
}

public String getValue() {
return value;
}
}

(5)创建对象方法


  /**
* 创建对象方法
* @param playerId
* @throws IOException
*/

private void createPlayer(String playerId) throws IOException {
if (!createdPlayer.containsKey(playerId)) {
List<Player> players = new ArrayList<>();
for (String key : playerInfo.keySet()) {
Player playerBaseInfo = playerInfo.get(key);
players.add(playerBaseInfo);
}
PlayerDTO playerDTO = new PlayerDTO();
playerDTO.setType(OperationType.CREATE_OBJECT.getCode());
playerDTO.setPlayers(players);
String syncInfo = JSONObject.toJSONString(playerDTO);
for (String key :
playerPool.keySet()) {
playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
}
// 存放
createdPlayer.put(playerId, this);
}
}

(6)销毁对象方法


   /**
* 销毁对象方法
* @param playerBaseInfo
* @throws IOException
*/

private void destroyPlayer(Player playerBaseInfo) throws IOException {
PlayerDTO playerDTO = new PlayerDTO();
playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());
List<Player> players = new ArrayList<>();
players.add(playerBaseInfo);
playerDTO.setPlayers(players);
String syncInfo = JSONObject.toJSONString(playerDTO);
for (String key :
playerPool.keySet()) {
playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
}
}

四、演示


4.1 客户端1登陆服务器


在这里插入图片描述


4.2 客户端2登陆服务器


在这里插入图片描述


4.3 客户端2移动


在这里插入图片描述


4.4 客户端1移动


在这里插入图片描述


4.5 客户端1退出


在这里插入图片描述
完结撒花


完整代码传送门


五、总结


以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!
我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~


后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~


作者:是江迪呀
来源:juejin.cn/post/7273429629398581282
收起阅读 »

向前兼容与向后兼容

2012年3月发布了Go 1.0,随着 Go 第一个版本发布的还有一份兼容性说明文档。该文档说明,Go 的未来版本会确保向后兼容性,不会破坏现有程序。 即用10年前Go 1.0写的代码,用10年后的Go 1.18版本,依然可以正常运行。即较高版本的程序能正常...
继续阅读 »

2012年3月发布了Go 1.0,随着 Go 第一个版本发布的还有一份兼容性说明文档。该文档说明,Go 的未来版本会确保向后兼容性,不会破坏现有程序。


即用10年前Go 1.0写的代码,用10年后的Go 1.18版本,依然可以正常运行。即较高版本的程序能正常处理较低版本程序的数据(代码)


反之则不然,如之前遇到过的这个问题[1]:在Mac上用Go 1.16可正常编译&运行的代码,在cvm服务器上Go 1.11版本,则编译不通过;


再如部署Spring Boot项目[2]时遇到的,在Mac上用Java 17开发并打的jar包,在cvm服务器上,用Java 8运行会报错




一般会认为向前兼容是向之前的版本兼容,这理解其实是错误的。


注意要把「前」「后」分别理解成「前进」和「后退」,不可以理解成「从前」和「以后」


线上项目开发中,向后(后退)兼容非常重要; 向后兼容就是新版本的Go/Java,可以保证之前用老版本写的程序依然可以正常使用




前 forward 未来拓展。


后 backward 兼容以前。







  • 向前兼容(Forward Compatibility):指老版本的软/硬件可以使用或运行新版本的软/硬件产生的数据。“Forward”一词在这里有“未来”的意思,其实翻译成“向未来”更明确一些,汉语中“向前”是指“从前”还是“之后”是有歧义的。是旧版本对新版本的兼容 (即向前 到底是以前还是前面?实际是前面





  • 向上兼容(Upward Compatibility):与向前兼容相同。









  • 向后兼容(Backward Compatibility):指新的版本的软/硬件可以使用或运行老版本的软/硬件产生的数据。是新版本对旧版本的兼容





  • 向下兼容(Downward Compatibility):与向后兼容相同。











软件的「向前兼容」和「向后兼容」如何区分?[3]


参考资料


[1]

这个问题: https://dashen.tech/2021/05/30/gvm-%E7%81%B5%E6%B4%BB%E7%9A%84Go%E7%89%88%E6%9C%AC%E7%AE%A1%E7%90%86%E5%B7%A5%E5%85%B7/#%E7%BC%98%E8%B5%B7

[2]

部署Spring Boot项目: https://dashen.tech/2022/02/01/%E9%83%A8%E7%BD%B2Spring-Boot%E9%A1%B9%E7%9B%AE/

[3]

软件的「向前兼容」和「向后兼容」如何区分?: https://www.zhihu.com/question/47239021



作者:fliter
来源:mdnice.com/writing/b8eb5fdae77f42e897ba69898a58e0d8
收起阅读 »

对负载均衡的全面理解

title: 对负载均衡的全面理解 date: 2021-07-10 21:41:24 tags: TCP/IP 对负载均衡服务(LBS)大名入行不多久就一直听闻,后来的工作中,也了解到 软件负载均衡器,如被合入Linux内核的章文嵩的LVS,还有...
继续阅读 »


title: 对负载均衡的全面理解 date: 2021-07-10 21:41:24 tags: TCP/IP





负载均衡服务(LBS)大名入行不多久就一直听闻,后来的工作中,也了解到 软件负载均衡器,如被合入Linux内核的章文嵩的LVS,还有以应用程序形式出现的HAProxy、KeepAlived,以及更熟悉的Nginx 等


也知道价格高昂的硬件负载均衡器如F5,A10 (甚至搬运过报废的F5)



















但长期以来,也有一些疑惑不解,比如





  • 常说的四层负载均衡是不是就是在传输层实现负载均衡?





  • 四层负载均衡中常听到的三角传输模式IP隧道模式NAT模式,有何区别?哪个性能最好?





  • 四层负载均衡性能好,那为何还有如nginx这样名气更大的七层负载均衡的出现?(Nginx也可以用来做四层代理)





  • 负载均衡与反向代理有何异同?





  • 转发和代理有何本质不同?




这是几年前记的笔记,显然存有谬误。





计算机网络中常见缩略词翻译及简明释要




通读 凤凰架构--负载均衡一章,可知





  • 四层负载均衡 主要工作在第二层和第三层,即 数据链路层和网络层 (通过改写 MAC 地址IP 地址 实现转发)​​​





  • “三角传输模式”(Direct Server Return,DSR),是作用于 数据链路层负载均衡,也称“单臂模式”(Single Legged Mode)或者“直接路由”(Direct Routing)。 通过修改请求数据帧中的 MAC 目标地址,让用户原本是发送给负载均衡器的请求的数据帧,被二层交换机根据新的 MAC 目标地址转发到服务器集群中对应的服务器(“真实服务器”)的网卡上。 效率高性能好,但有些场景不能满足










  • 网络层负载均衡:IP隧道模式,NAT模式


IP隧道模式:





NAT模式:







在流量压力比较大的时候,NAT 模式的负载均衡会带来较大的性能损失,比起直接路由和 IP 隧道模式,甚至会出现数量级上的下降






  • 四层负载均衡进行转发,只有一条TCP通道; 七层负载均衡只能进行代理,需要有两条TCP通道








  • 七层负载均衡器就属于反向代理中的一种;





  • 如果只论网络性能,七层均衡器肯定是无论如何比不过四层均衡器的;但其工作在应用层,可以感知应用层通信的具体内容,往往能够做出更明智的决策,玩出更多的花样来。









负载均衡的两大职责是“选择谁来处理用户请求”和“将用户请求转发过去”。上面讲的都是怎样将用户请求转发过去


至于选择哪台应用服务器来处理用户请求(翻牌子),则有很多算法,如下图就是F5的一些选择算法












B站:一次性讲清楚四层负载均衡中的NAT模式和IP隧道模式


Shadowsocks源码解读——什么是代理?什么是隧道?


NAT模式、路由模式、桥接模式的区别


VLAN是二层技术还是三层技术?


四层负载均衡详解


作者:fliter
来源:mdnice.com/writing/c5b54a9bdd78478a87c6d39e38572358
收起阅读 »

Apipost: 开发者们的瑞士军刀

在当今的数字化时代,数据流通是推动社会进步的关键因素之一。其中,API(应用编程接口)已经成为跨平台数据交互的标准。然而,API开发和管理并非易事,Apipost一体化研发协作赋能平台,支持从API设计到API调试再到API测试覆盖整个API生命周期的API管...
继续阅读 »

在当今的数字化时代,数据流通是推动社会进步的关键因素之一。其中,API(应用编程接口)已经成为跨平台数据交互的标准。然而,API开发和管理并非易事,Apipost一体化研发协作赋能平台,支持从API设计到API调试再到API测试覆盖整个API生命周期的API管理平台,一起来看看Apipost有什么不同吧。


一、Apipost是什么?


Apipost是一个专为开发者设计的API管理工具,提供了全面的API文档生成、调试、测试和分享功能。它的目标是帮助开发者简化API开发流程,提高工作效率。


二、如何使用Apipost?


安装:


进入官网下载安装或直接使用web端



使用:


可以从其他平台如postman导入脚本文件,或创建接口。



接口调试:


输入接口URL后点击发送即可模拟接口请求,上方为请求区,下方为响应区



生成接口文档:


点击分享文档即可生成标准的接口文档,可以将链接分享给需要查看接口的其他同事



一键压测


接口调试完成后可以在一键压测页面进行并发测试,看看接口在高并发情况下的运行情况



总结


Apipost作为一款专为开发者设计的API管理工具,凭借其强大的功能和易用性,已经在开发者社区中积累了良好的口碑。通过使用Apipost,开发者可以节省大量时间,专注于创新和打造卓越的产品。如果你正在寻找一款强大且易用的API管理工具,那么Apipost无疑是一个值得考虑的选择。


作者:刘天瑜
链接:https://juejin.cn/post/7273024616522891301
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

哇咔咔,体验了一把抖音的ChatGPT

说实在的,看到这里,让我忽然想吃过年时候蒸的豆包,不知道字节起这样一个名字有什么用意 最近一直在分享各种AIGC类的东西,感兴趣的可以看下主页历史干货。 你好,我是豆包 礼貌性的回复一句,你好,我是1点东西,我要开始使用你了。豆包是谁呢,可能有些朋友还不知道 ...
继续阅读 »


说实在的,看到这里,让我忽然想吃过年时候蒸的豆包,不知道字节起这样一个名字有什么用意


最近一直在分享各种AIGC类的东西,感兴趣的可以看下主页历史干货。


你好,我是豆包


礼貌性的回复一句,你好,我是1点东西,我要开始使用你了。豆包是谁呢,可能有些朋友还不知道


据悉,“豆包”的前身正是字节内部代号为“Grace”的AI项目。目前在AI浪潮下已经形成独立的AIGC产品供用户使用




刚进来可以看到经典的左右格局,左侧依然是历史问题记录区域,和其他国产GPT产品一样,有一些聚焦的功能模块。


不同的是,显的更加简洁大气,使用柔和不僵硬。毋庸置疑抖音的模型是基于字节产品多年数据沉淀最终服务于子节用户以及更好的发展。



可以看到回答问题响应很快,问题回答干脆不拖泥带水,同样在问题的最下方有点赞、复制、重新生成等功能。需要注意的是最下面有一个搜索功能,点击会跳转到今日头条进行搜索。


体验能力


左侧有英语小助手,先来看下英文能力怎么样




这能力杠杠的,学英语再也不是难事。接下来看全能写作助手体验。






接着问,测试上下文能力。




总体上还算总结的不错,我们问下小日本核废水排海事件




很明显,并不支持联网。




而且没有文生图功能




看下编程能力




编程能力也毫不逊色,最后可以问下GPT3.5都回答错误的问题。看看国产大模型咋样。




OK,今天的一个小分享暂时先到这里。上面的抖音的的申请体验链接:http://www.doubao.com/chat


最近涉猎于AIGC,总结了一些AI资料(实时更新),无套路分享给大家


1点东西AI资料地址



标签:

作者:1点东西
链接:https://juejin.cn/post/7273024681641689149
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为啥count(*)会这么慢?

背景 本没想着写这篇文章的,因为我觉得这个东西大多数有经验的开发遇到过,肯定也了解过相关的原因,但最近我看到有几个关注的技术公众号在推送相关的文章。实在令我吃惊! 先上公众号文章的结论: count(*) :它会获取所有行的数据,不做任何处理,行数加1。 c...
继续阅读 »

背景


本没想着写这篇文章的,因为我觉得这个东西大多数有经验的开发遇到过,肯定也了解过相关的原因,但最近我看到有几个关注的技术公众号在推送相关的文章。实在令我吃惊!


先上公众号文章的结论:



  • count(*) :它会获取所有行的数据,不做任何处理,行数加1。

  • count(1):它会获取所有行的数据,每行固定值1,也是行数加1。

  • count(id):id代表主键,它需要从所有行的数据中解析出id字段,其中id肯定都不为NULL,行数加1。

  • count(普通索引列):它需要从所有行的数据中解析出普通索引列,然后判断是否为NULL,如果不是NULL,则行数+1。

  • count(未加索引列):它会全表扫描获取所有数据,解析中未加索引列,然后判断是否为NULL,如果不是NULL,则行数+1。


结论:count(*) ≈ count(1) > count(id) > count(普通索引列) > count(未加索引列)


我也不想卖关子了,以上结论纯属放屁。根本就是个人yy出来的东西,甚至不愿意去验证一下,哪怕看一眼执行计划,也得不出这么离谱的结论。


我不敢相信这是一篇被多个技术公众号转载的文章!


以下所有的内容均是基于,mysql 5.7 + InnoDB引擎, 进行的分析。


拓展:


MyISAM 如果没有查询条件,只是简单的统计表中数据总数,将会返回的超快,因为service层中获取到表信息中的总行数是准确的,而InnoDB只是一个估值。


实例


废话不多说,先看一个例子。


以下是一张表数据量有100w,表中字段相对较短,整体数据量不算大。


CREATE TABLE `hospital_statistics_data` (
`pk_id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL COMMENT '外键',
`hospital_code` varchar(36) COLLATE utf8mb4_general_ci NOT NULL COMMENT '医院编码',
`biz_type` tinyint NOT NULL COMMENT '1服务流程 2管理效果',
`item_code` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '考核项目编码',
`item_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '考核项目名称',
`item_value` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '考核结果',
`is_deleted` tinyint DEFAULT NULL COMMENT '是否删除 0否 1是',
`gmt_created` datetime DEFAULT NULL COMMENT '创建时间',
`gmt_modified` datetime DEFAULT NULL COMMENT 'gmt_modified',
`gmt_deleted` datetime(3) DEFAULT '9999-12-31 23:59:59.000' COMMENT '删除时间',
PRIMARY KEY (`pk_id`)
) DEFAULT CHARSET=utf8mb4 COMMENT='医院统计数据';

此表初始状态只有一个聚簇索引


以下分不同索引情况,看一下COUNT(*)的执行计划。


1)在只有一个聚簇索引的情况下看一下执行计划。


EXPLAIN select COUNT(*) from hospital_statistics_data;

结果:



关于执行计划的各个参数的含义,不在本文的讨论范围内,可自行了解。


这里只关注以下几个属性。



  1. type: 这里显示index,说明使用了索引。

  2. key:PRIMARY使用了主键索引。

  3. key_len: 索引长度8字节。


这里有很关键的一点:count(*)也会走索引,在当前情况下使用了聚簇索引。


好,再往下看。


2)存在一个非聚簇索引(二级索引)


给表添加一个hospital_code索引。


alter table hospital_statistics_data add index idx_hospital_code(hospital_code)

此时表中存在2个索引,主键 hospital_code


同样的,再执行一下:


EXPLAIN select COUNT(*) from hospital_statistics_data;

结果:



同样的,看一下 type、key和key_len三个字段。


是不是觉得有点“神奇”。


为何索引变成刚添加的idx_hospital_code了。


先别急着想结论,再看下面一种情况。


3)存在两个非聚簇索引(二级索引)


在上面的基础上,再添加一个二级索引。


alter table hospital_statistics_data add index idx_biz_type(biz_type)

此时表中存在3个索引,主键 、hospital_code 和 biz_type。


同样的,执行一下:


EXPLAIN select COUNT(*) from hospital_statistics_data;

结果:



是不是更困惑了,索引又..又又...变了.


变成新添加的idx_biz_type。


先不说为何会产生以上的变化,继续往下分析。


在以上3个索引的基础上,分别看一下,count(1)count(id)count(index)count(无索引)


这4种情况,与count(*)的执行计划有何区别。



  1. count(1)




  1. count(id)
    对于样例表来说是,主键是pk_id


image.png



  1. count(index)


这里选取biz_type索引字段。




  1. count(无索引)



小结:




  1. count(index) 会使用当前index指定的索引。




  2. count(无索引) 是全表扫描,未走索引。




  3. count(1) , count(*), count(id) 一样都会选择idx_biz_type索引




看到这,你还觉得那些千篇一律的公众号文章的结论正确吗?


必要知识点




  1. mysql 分为service层引擎层




  2. 所有的sql在执行前会经过service层的优化,优化分为很多类型,简单的来说可分为成本规则




  3. 执行计划所反映的是service层经过sql优化后,可能的执行过程。并非绝对(免得有些人说我只看执行计划过于片面)。绝大多数情况执行计划是可信的




  4. 索引类型分为聚簇索引非聚簇索引(二级索引)。其中数据都是挂在聚簇索引上的,非聚簇索引上只是记录的主键id。




  5. 抛开数据内存,只谈数据量,都是扯淡。什么500w就是极限,什么2个表以上的join都需要优化了,什么is null不会走索引等,纯纯的放屁。




  6. 相信一点,编写mysql代码的人比,看此文章的大部分人都要优秀。他们会尽可能在执行前,对我这样菜逼写的乱七八糟的sql进行优化。




原因分析


其实原因非常非常简单,上面也说了,service层会基于成本进行优化


并且,正常情况下,非聚簇索引所占有的内存要远远小于聚簇索引。所以问题来了,如果你是mysql的开发人员,你在执行count(*)查询的时候会使用那个索引?


我相信正常人都会使用非聚簇索引


那如果存在2个甚至多个非聚簇索引又该如何选择呢?


那肯定选择最短的,占用内存最小的一个呀,在回头看看上面的实例,还迷惑吗。


同样都是非聚簇索引。idx_hospital_codelen146字节;而idx_biz_typelen只有1。那还要选吗?


那为何count(*)走了索引,却还是很慢呢?


这里要明确一点,索引只是提升效率的一种方式,但不能完全的解决效率问题。count(*)有一个明显的缺陷,就是它要计算总数,那就意味着要遍历所有符合条件的数据,相当于一个计数器,在数据量足够大的情况下,即使使用非聚簇索引也无法优化太多。


官方文档:



InnoDBhandlesSELECT COUNT(*)andSELECT COUNT(1)operations in the same way. There is no performance difference.



简单的来说就是,InnoDB下 count(*) 等价于 count(1)


既然会自动走索引,那么上面那个所谓的速度排序还觉得对吗? count(*)的性能跟数据量有很大的关系,此外最好有一个字段长度较短的二级索引。


拓展:


另外,多说一下,关于网上说的那些索引失效的情况,大多都是片面的,我这里只说一点。量变才能引起质变,索引的失效取决于你圈定数据的范围,若你圈定的数据量占整体数据量的比例过高,则会放弃使用索引,反之则会优先使用索引。但是此规则并不是完美的,有时候可能与你预期的不同,也可以通过一些技巧强制使用索引,但这种方式少用。


举个栗子:


通过上面这个表hospital_statistics_data,我进行了如下查询:


select * from hospital_statistics_data where hospital_code is not null;

此时这个sql会使用到hospital_code的索引吗?


这里也不卖关子了,若hospital_code只有很少一部分数据是null值,那么将不会走索引,反之则走索引。


原因就2个字:回表


好比去买砂糖橘,如果你只买几斤,那么你随便挑筐里面好的就行。但是如果你要买一筐,我相信老板不会让你在里面一个个挑,而是一次给你一整筐,当然大家都不傻,都知道筐里里面肯定有那么几个坏果子。但是这样效率最高,而且对老板来说损失更小。


执行过程


摘抄自《从根上理解mysql》。我强烈推荐没有系统学过mysql的,看看这本书。


1.首先在server层维护一个count变量


2.server层向InnoDB引擎要第一条记录


3.InnoDB找到第一条二级索引记录,并返回给server层(注意:由于此时只是统计记录数量,所以并不需要回表)


4.由于COUNT函数的参数是*,MySQL会将*当作常数0处理。由于0并不是NULL,server层给count变量加1。


5.server层向InnoDB要下一条记录。


6.InnoDB通过二级索引记录的next_record属性找到下一条二级索引记录,并返回给server层。


7.server层继续给count变量加1。


8.重复上述过程,直到InnoDB向server层返回没记录可查的消息。


9.server层将最终的count变量的值发送到客户端。


总结


写完后还是心中挺郁闷的,现在能从公众号获取到的好文章越来越少了,现在已经是知识付费的时代了。


挺怀念刚工作的时候,那时候每天上午都花点时间看看公众号文章,现在全都是广告。哎!


不过也正常,谁也不能一直为爱发电。


学习还是建议多看看书籍,一般能成书的都不会太差。现在晚上能搜到的都

作者:微笑兔
来源:juejin.cn/post/7182491131651817531
是千篇一律的文章,对错不知。网上

收起阅读 »

Git 合并冲突不知道选哪个?试试开启 diff3 吧

iOS
导读:Git 早在 2008 年就提供 diff3,用于冲突展示时额外提供该区域的原始内容(两个分支公共祖先节点在此区域的内容),帮助更好的合并冲突。在 2022 年 Q1 发布的 Git 2.35 ,提供了一个新的选项 zdiff3,进一步优化了diff3 ...
继续阅读 »

导读:Git 早在 2008 年就提供 diff3,用于冲突展示时额外提供该区域的原始内容(两个分支公共祖先节点在此区域的内容),帮助更好的合并冲突。在 2022 年 Q1 发布的 Git 2.35 ,提供了一个新的选项 zdiff3,进一步优化了diff3 的展现。



Git 合并冲突,常见的展示形式分为 Current Change (ours, 当前分支的变更)和 Incoming Change (theirs, 目标分支的变更),两者针对的是同一区域的变化。



观察上面这个冲突示例,我们并不清楚两个分支各自都发生了什么变化,有两种可能:

  1. 两个分支同时增加了一行代码 "pkg": xxx
  2. 原先的提交记录里就有 "pkg": xxx ,只是两个分支同时修改了版本号

实际上这个例子,是第二种情况,两个分支都对 pkg 的版本做了改变。




这样的场景还有很多,如果不知道上下文,在解决冲突的时候容易束手束脚。


现在,我们可以使用 git 提供的 diff3 选项来调整合并冲突的展示效果



红框区域(|||||||=======)表示的就是改动前的上下文,确切的说, 当前分支 目标合并分支 的最近公共祖先节点在该区域的内容。


如何开启


冲突展示有两个选项 diff3merge(默认选项),可以通过以下方法进行配置



在 v2.35 新增了 zdiff3 选项,下文会提到

  • 对单个文件开启
git checkout --conflict=diff3 <文件名>
# 示例
git checkout --conflict=diff3 package.json
# 使用默认配置
git checkout --conflict=merge package.json
  • 项目配置
git config merge.conflictstyle diff3
# 删除配置
git config --unset merge.conflictstyle
# 使用默认配置
git config merge.conflictstyle merge
  • 全局配置
git config --global merge.conflictstyle diff3
# 删除配置
git config --global --unset merge.conflictstyle

示例展示


在同一位置添加代码行

<<<<<<< HEAD
import 'some_pkg';
||||||| merged common ancestor
=======
c
>>>>>>> merged-branch

如上示例,合并的公共祖先节点在该位置是空白,每个分支都在相同的位置添加代码行。


我们通常希望保留两者,并按照最有意义的顺序排序,也可能选择只保留其中一个。以下是一个冲突修复后的示例:

import 'some_pkg';
import 'some_pkg';

一方修改一方删除

<<<<<<< HEAD
||||||| merged common ancestor
console.log('调试信息')
=======
console.log('调试信息2')
>>>>>>> merged-branch

如上示例,一方把调试信息删除,而另一方修改了调试信息内容。对于这个示例,我们通常是选择删除而不保留修改。


为什么不是默认选项


经常需要知道祖先节点的内容来确保正确的合并,而 diff3 解决了这个痛点。同时,diff3 没有任何弊端(除了冲突区域行数变多🌝),没有理由不启用它。


那为什么 Git 不将 diff3 作为默认的合并冲突展示选项呢?


stackoverflow 上有人回答了这个问题,大概意思是说可能和 Unix diff 有关,早前默认的 Unix diff 不支持展示 3-way diff (待考证)。


之后的新版本也不方便调整默认值,否则会对用户造成困扰 — “合并冲突区域怎么多了一块内容?”。


zdiff3 (zealous diff3)


2022 年 Q1 ,Git 发布 v2.35,其中有个变化是冲突展示新增了 zdiff3 的配置选项。


zdiff3 基于 diff3 ,并对冲突块两侧的公共代码行做了压缩。


举个例子:




使用默认配置,合并冲突展示如下:

1
2
3
4
A
<<<<<<< ours
B
C
D
=======
X
C
Z
>>>>>>> theirs
E
7
8
9

使用 diff3 后,合并冲突展示如下:

1
2
3
4
<<<<<<
A
B
C
D
E
||||||
5
6
======
A
X
C
Z
E
>>>>>>
7
8
9

通过观察可以发现,冲突区域两侧有公共的代码行 A、E 。而这些代码行在默认配置下会被提取到外部。


而用了 zdiff3 之后,A、E 两行又将移到冲突之外。

1
2
3
4
A
<<<<<<
B
C
D
||||||
5
6
======
X
C
Z
>>>>>>
E
7
8
9

一句话总结 zdiff3 的优化:即展示公共祖先节点内容,又能够充分压缩冲突的公共部分。


最后


解决 Git 合并冲突是一个难题,diff3 并不是一个“银弹”,它只能帮助提供更多的信息,减少决策成本。


推荐读者尝试下 zdiff3 ,至少使用 diff3 ,并将其作为默认配置。


最后,如果看完本文有收获,欢迎一键三连(点赞、收藏、分享)🍻 ~


拓展阅读


作者:francecil
链接:https://juejin.cn/post/7267477916744630308
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Go 负责人说以后不会有 Go2 了

大家好,我是煎鱼。 最近 Go 核心团队负责人 @Russ Cox(下称:rsc)专门写了一篇文章《Backward Compatibility, Go 1.21, and Go 2》为 Go 这门编程语言的 Go1 兼容性增强和 Go2 的情况说明做诠释和宣...
继续阅读 »

大家好,我是煎鱼。


最近 Go 核心团队负责人 @Russ Cox(下称:rsc)专门写了一篇文章《Backward Compatibility, Go 1.21, and Go 2》为 Go 这门编程语言的 Go1 兼容性增强和 Go2 的情况说明做诠释和宣传。


今天希望能够帮助你获悉 Go 未来的规划、方向以及 rsc 的思考。


Go1 破坏兼容性的往事


新增结构体字段


第一个案例,比较经典。在 Go1 的时候,这段代码是可以正常运行的。如下演示代码:

// 脑子进煎鱼了
package main

import "net"

var myAddr = &net.TCPAddr{
net.IPv4(18, 26, 4, 9),
80,
}

但在 Go1.1,这段代码就跑不起来。必须要改成如下代码:

var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}

因为在当时的新版本中,对 net.TCPAddr 新增了 Zone 字段。原先的未声明值对应字段的方式就会出现一些问题。


后续在新版本的规范中,官方直接对标准库提交的代码增加了要求,赋值时必须声明字段名。以此避免该问题的产生。


改进排序/压缩的算法实现


第二个案例,Go1.6 时,官方修改了 Sort 的排序实现,使得运行速度提高了 10% 左右。以下是演示代码,将根据名称长度对颜色列表进行排序并输出结果:

colors := strings.Fields(
`black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

一切听起来是那么的美好。


真实世界是改变排序算法通常会改变相等元素的排序方式。导致了 Go1.5 和 Go1.6 所输出的结果不一致:

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6: [red blue white green black orange yellow indigo violet]

按照顺序排序后,结果集的差异点在于:


  • Go1.5 返回 green, white, black。
  • Go1.6 返回 white, green, black。

如果说程序依赖了结果集的输出顺序,这将是一个影响不小的兼容性破坏。


第三个案例,类似的还有在 Go1.8 中,官方改进了 compress/flate 的算法,达到了在 CPU 和 Memory 没有什么明显变化下,压缩后的结果集更小了。听起来是个很好的成果。


但实际上自己内部却翻车了,因为 Google 内部有一个需要可重现归档构建的项目,依赖了原有的算法。最后自己 fork 了一份来解决。


Go1.21 起增强兼容性(GODEBUG)


从上面的部分破坏兼容性示例来看,可以知道 Go 官方也不是刻意破坏的。但又存在必然要修改的各种原因和考量。


为此在 Go1.21 起,正式输出了 GODEBUG 的机制,相当于是开了个官方 “后门” 了。将其作为破坏性变更后的门把手。


允许设置 GODEBUG,来开关新功能特性。例如以下选项:

  • GODEBUG=asyncpreemptoff=1:禁用基于信号的 Goroutine 抢占,这偶尔会发现操作系统的错误。
  • GODEBUG=cgocheck=0:禁用运行时的 CGO 指针检查。
  • GODEBUG=cpu.<extension>=off:在运行时禁止使用某个特定的 CPU 扩展。

也会根据根据 go.mod 中的 Go 版本号来设置对应 GODEBUG,以提供版本所约定的 Go1 兼容性保障策略。


如果对这块感兴趣,可以查看《加大力度!Go 将会增强 Go1 向后兼容性》,有完整的增强兼容性的规范说明。


Go2 的情况和规划


Go 官方(via @rsc)正式回答了之前画的饼,也就是什么时候可以看到 Go2 的规范推出,打破 Go1 程序?


答案是永远不会。从与过去决裂、不再编译旧程序的意义上来说,Go 2 永远不会出现。从 Go 在 2017 年开始对 Go 1 进行重大修订的意义上来说,Go 2 已经发生了。


简而言之,透露出来的意思是:硬要说的话,Go2 已经套壳 Go1 上市了。


在未来规划上,不会出现破坏 Go1 程序的 Go2。工作方向会往将加倍努力保证兼容性的基础上,开展新的新工作。


总结


整体上 rsc 对破坏 Go1 兼容性做了很长时间规划的回溯和规划,释出了一大堆手段,例如:GODEBUG、go.mod 版本约束等。


从而引导了 Go2 直接可以借壳上的方向,也更好兑现了 Go1 兼容性保障的规范承诺。单从这方面来讲,还是非常的深思熟虑的。


也可能会有同学说,看 Go 现在这样,说不定下次就变了。这可能比较难,其实 rsc 才上任做团队负责人没几年,工作履历上和其他几位骨干大佬在 Google 已经有非常长年的在职经验了。



我目测一时半会是不会变的了。


想变,得等 Go 核心团队这一班子换了才有可能了。阻力也会很多,因为社区人多,一般会比较注重规范。



文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。



Go 图书系列


推荐阅读


作者:煎鱼eddycjy
链接:https://juejin.cn/post/7272378529211744317
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一个上午,我明白了,为什么总说人挣不到认知以外的钱

你好,我是刘卡卡,94年的大厂奶爸程序员,探索副业中 01 接下来,以我昨天上午的一段经历,讲述下为什么说我挣不到认知以外的钱 在昨天上班的路上,我在微信的订阅号推荐里面看到了下图 当时我的想法是:这东西阅读量好高噢,不过养老金和目前的我没什么关系。于是,我...
继续阅读 »

你好,我是刘卡卡,94年的大厂奶爸程序员,探索副业中


01


接下来,以我昨天上午的一段经历,讲述下为什么说我挣不到认知以外的钱


在昨天上班的路上,我在微信的订阅号推荐里面看到了下图



当时我的想法是:这东西阅读量好高噢,不过养老金和目前的我没什么关系。于是,我就划过去了。



(读者可以先停5s 思考下,假设是你看到这张图,会有什么想法)



02


当我坐上工位后,我看到我参加的社群里也人有发了上图,并附上了一段文字:


“养老金类型的公众号容易出爆文。


小白玩转职场这个号,篇篇10w+,而且这并不是一个做了很久的老号,而是今年5月才注册不久的号。 之前这个号刚做的时候是往职场方向发展,所以取名叫小白玩转职场,但是发了一阵后数据不是很好于是就换风格做了养老金的内容。


换到养老金赛道后就几乎篇篇10w+。 这些内容一般从官方网站找就好,选一些内容再加上自己想法稍微改下,或者直接通过Chatgpt辅助,写好标题就行”。


同时,文字下面有社群圈友留下评论说:“这是个好方向啊,虽然公众号文章已经同质化很严重了,但可以往视频号、带货等方向发展”。



读者可以先停5s 思考下,假设是你看到这段文字,会有什么想法。如果你不知道公众号赚钱的模式,那你大概率看不出这段话中的赚钱信息的



我想了想,对噢,确实可以挣到钱,于是将这则信息发到了程序员副业交流的微信群里。



然后,就有群友在交流:“他这是转载还是什么,不可能自己天天写吧”,“这种怎么冷启动呢,不会全靠搜索吧,“做他这种类型的公众号挺多吧,怎么做到每篇10w的”



有没有发现,这3个问题都是关注的怎么做的问题?怎么写的,怎么启动的,怎么每篇10w。


这就是我们大部分人的认知习惯,看到一个信息或别人赚钱的点子后,我们大部分人习惯去思考别人是如何做到的,是不是真的,自己可不可以做到。


可一般来说,我们当下的认知是有限的,大概率是想不出完整的答案的的,想不出来以后,然后就会觉得这个事情有点假,很难或者不适合。从而就错过这条信息了。



我当时觉得就觉得可能大部分群友会错过这则信息了,于是,在群里发了下面这段话


“分享一个点子后


首先去看下点子背后的商业变现机会,如带货/流量主/涨粉/等一系列


而后,才去考虑执行的问题,执行的话


1、首先肯定要对公众号流量主的项目流程进行熟悉


2、对标模仿,可以去把这个公众号的内容全看一看,看看别人为什么起号


3、做出差异化内容。”


发完后还有点小激动(嗯,我又秀了波自己的认知)。可到中午饭点,我发现我还是的认知太低了。


03


在中午吃饭时,我看到亦仁(生财有术的老大)发了这段内容



我被这段话震撼到了,我发现我现在的思考习惯,还是只停留在最表面的看山是山的地步。


我仅仅看到了这张图流量大,没有去思考它为什么这么大流量?



因为他通过精心制作的文章,为老年用户介绍了养老金的方方面面,所以,才会有流量


简单说,因为他满足了用户的需求



同样,我也没有思考还有没有其他产品可以满足用户的这个需求,我仅仅是停留在了视频号和公众号这两个产品砂锅。



只要满足用户需求,就不只有一个产品形态,对于养老金这个信息,我们可以做直播,做课程,做工具,做咨询,做1对1私聊的。这么看,就能有无数的可能



同时,我想到了要做差异化,但没有想到要通过关键字挖掘,去挖掘长尾词。



而亦仁,则直接就挖掘了百万关键字,并无偿分享了。



这才知道,什么叫做看山不是山了。


之前知道了要从“需求 流量 营销 变现”的角度去看待信息,也知道“需求为王”的概念。


可我看到这则信息时,还是没有考虑到需求这一层,更没有形成完整的闭环思路。


因此,以后要在看到这些信息时,去刻意练习“需求 流量 营销 变现”这个武器库,去关注他用什么产品,解决了用户什么需求,从哪里获取到的流量的,怎么做营销的,怎么做变现的。


04


于是,我就把这些思考过程也同样分享到了群里。


接着,下午我就看到有群友在自己的公众号发了篇和养老金相关的文章,虽然文章看上去很粗糙,但至少是起步了。


同时,我也建了个项目交流群,方便感兴趣的小伙伴交流进步(一群人走的更远)


不过我觉得在起步之前,也至少得花一两天时间去调研下,去评估这个需求有哪些地方自己可以切入进去,值不值得切入,能切入的话,怎么切入。


对了,可能你会关心我写了这么多,自己有没有做养老金相关的?


我暂时还没有,因为我目前关心的领域还在出海工具和个人IP上。


全文完结,如果对你有收获的话,关注公众号 刘卡卡 和我一起交流进步


作者:刘卡卡
链接:https://juejin.cn/post/7268590610189418533
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

👨‍💻 14 个最佳免费编程字体

我们整天都在使用代码编辑器、终端和其他开发工具,使用一种让眼睛舒服的字体可以大大改善我们的工作效率。 这篇文章汇总了 14 个免费的等宽编程字体,包括了每个字体的介绍、评价和下载链接。 😝 分享几个好玩的 VSCode 主题 🗂 让你的 VSCode 文件图...
继续阅读 »

我们整天都在使用代码编辑器、终端和其他开发工具,使用一种让眼睛舒服的字体可以大大改善我们的工作效率。
这篇文章汇总了 14 个免费的等宽编程字体,包括了每个字体的介绍、评价和下载链接。


😝 分享几个好玩的 VSCode 主题


🗂 让你的 VSCode 文件图标更好看的10个文件图标主题


🌈 冷门但好看的 VSCode 主题推荐


1. Fira Code




我曾使用 Monaco 字体超过十年的时间,直到我遇到了 Fira Code。这个字体在 Github 上面有超过 53,600 个 star,它这么受欢迎是有原因的。字体作者 Nikita Prokopov 在连字符(Ligature)上花了很多功夫,连字符可以把单独的字符合并成单一的逻辑标记。Fira Code 是我现在最喜欢的字体。




(Fira Code 中的连字符)


下载链接 • Github链接


2. IBM Plex Mono




Plex 系列字体是在 IBM 使用了 50 多年的 Helvetica 字体之后,被创建出来作为替代品的。它有着非常优雅的斜体字体,以及非常清晰易读的字形。美中不足的是,它没有包含连字符。


下载链接 • Github链接


3. Source Code Pro




Source Code Pro 是 Adobe 首先制作的开源字体之一。自2012年发布后,该字体大受欢迎,并被许多开发人员使用。它保留了 Source Sans 的设计特征和垂直比例,但改变了字形宽度,使其在所有粗细中保持一致。


下载链接 • Github链接


4. Monoid


如果你是那种讨厌水平滚动的人,这就是适合你的字体(因为这款字体比较细长)。它针对编程进行了优化,即使在低分辨率的显示器上也有 12px/9pt 的类似位图的清晰度。该字体还有一个名为 Monoisome 的 Font Awesome 集成。


下载链接 • Github链接




5. Hack


Hack 是所有字体中最可定制的之一,拥有1573个字形,你可以自行更改每一个字形的细节。此外,Powerline 字形也包含在其常规字体套件中。


下载链接 • Github链接




6. Iosevka


Iosevka 默认提供了苗条的字体形状:其字形宽度正好为1/2em。相比于其他的字体,你可以在同样的屏幕宽度下放置更多列的文字。它有两种宽度:普通和扩展。如果你希望字体间隔更大一点的话,就选择扩展版本的宽度。


下载链接 • Github链接




7. JetBrains Mono


IntelliJ、WebStorm 等诸多IDE背后的公司 —— JetBrains,在2020年出人意料地推出了自己的字体。他们的字体力求让代码行长度更符合开发人员的期望,使每个字母占据更多的像素。他们在保持字符的宽度标准的基础上最大化了小写字母的高度,从而实现这个目标。


下载链接 • Github链接




8. Fantasque Sans Mono


Fantasque Sans Mono 的设计以实用性为重点,它可以给你的代码增添一丝不一样的感觉。它手写风格的模糊感使其成为一个很酷的选择。


下载链接 • Github链接




9. Ubuntu Mono


这款字体是专门为了补充 Ubuntu 的语气而设计的。它拥有一种现代风格,并具有独特的 Ubuntu 品牌特性,传达出一种精准、可靠和自由的态度。如果你喜欢 Linux,但需要在 Windows 或 MacOS 上工作,这款字体将给你带来一点小小的慰藉和快乐。


下载链接 • 官网




10. Anonymous Pro


这种字体的出色之处在于,它特别区分了那些容易被误认为相同的字符,比如“0”(零)和“O”(大写字母O)。它是一个由四种固定宽度字体组成的字体族,特别针对编程人员而设计。


下载链接 • 官网




11. Inconsolata


这款字体可以作为微软的 Consolas 字体的开源替代。它是一种用于显示代码、终端等使用场景的等宽字体。它提供了连字符,能够给用户出色的编码体验。


下载链接 • GitHub链接




12. Victor Mono


这种字体简洁、清新且细长,具有较大的字母高度和清晰的标点符号,因此易读性强并且适合用于编程。它具有七种不同粗细和 Roman、Italic 和 Oblique 样式。它还提供可选的半连接草书斜体和编程符号连字符。


下载链接 • Github链接




13. Space Mono


这款字体专门为了标题和显示器排版而开发,它拥有几何板块的核心与新颖的近乎过度的合理化形式。它支持拉丁扩展字形集,可以用于英语和其他西欧语言的排版。


下载链接 • GitHub链接




14. Hasklig


在Source Code Pro的基础上,这款字体通过连字符来解决不合适的字符的问题,这也是排版师们一直以来使用的方式。底层代码保持不变——只有表现形式发生变化。


下载链接 • Github链接




哪个字体是你的最爱?


字体,就像颜色主题一样,是一个非常因人而异的话题。不同的开发者喜欢不同的字体。有些人喜欢连词,有些人不喜欢。有些人喜欢斜体字,有些人则讨厌。


希望这篇文章能帮助你找到喜欢的字体,给它一个机会,尝试使用几天,你会有不一样的感觉。



作者:wzkMaster
链接:https://juejin.cn/post/7243067221999894583
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

关于强制加班、人才培养、绩效考核的思考

来源于池老师星球里的一个提问,我也借此机会做一个小归纳,本来想直接贴问题截图的,想了想由于是池老师星球的里的提问,还是不要贴图片了。 大致问题描述:1.公司强制124加班,但是没那么多事情需要加班去做,如何让大家把这些时间利用起来去学习,提高团队能力2.作为研...
继续阅读 »

来源于池老师星球里的一个提问,我也借此机会做一个小归纳,本来想直接贴问题截图的,想了想由于是池老师星球的里的提问,还是不要贴图片了。


大致问题描述:

  • 1.公司强制124加班,但是没那么多事情需要加班去做,如何让大家把这些时间利用起来去学习,提高团队能力
  • 2.作为研发团队管理者绩效考核怎么设计,量化与非量化如何平衡

我的评论原文:


同样的情况我也有碰到,124强制加班,公司跟不上的意思猜测情况是“技术团队支撑业务需要绰绰有余,又或者是业务侧增速不够,总之就是没那么多工作需要加班去完成”


强制加班,至少公司看上去灯火通明的,很多时候是高管或老板要求的,有可能是一些对软件研发理解不足的老板他们需要安慰剂,也有可能是一些“政治原因”。这就不好揣测了,非心腹当然是没法知道,但此时尤为要注意做好管理工作,很多事情没法讲也讲不清楚,团队成员可能因此会对团队、公司失去信心。那么拥有健康的团队氛围,愿意帮助大家成长。规划有长期的团队目标,目标符合公司发展,符合团队成员成长需要,同时要具备一定的挑战性,具备这两点的团队这方面的问题会少很多。团队不可以长期处于磨洋工的状态,如果人心涣散,再聚极难。


研发团队怎么做人才培养:


有加班时间了才想到用这个时间帮助员工成长,之所以有这样的问题是不是平时做人才培养不到位,不够细致。比如项目空窗期的时候之前都是放任大家自由学习或“摸鱼“吗? 从团队管理者角度去看,大家自由学习不能算是好事,很有可能学完了就走了,毕竟你这里没啥挑战。


我的一些经验:


结合公司业务,比如toC 还是toB 去看公司下阶段的规模与增速,分析产研需要达到的能力,以此为基础去看行业内的标准与自己团队的落差,把落差放大一些 作为团队的长期目标,时间上至少是一个季度以上才够大。
这些目标的特点都是重要但不紧急,但具备一定的挑战性。既满足人才培养又能对应未来公司发展需要。


把这些目标作为OKR,分担到各个小组,各个小组再拆落实到个人,并至少最小以月为单位进行复核,协助他们分析解决碰到的阻力问题,同时很多一线同学向上管理做得不好,管理者需要时常主动了解情况,及时给予资源支持。


执行过程难免碰到阻力出现停滞,或速度不理想,那么配合KPI奖励或其他激励来提高成员的驱动力。
在完成的目标过程中,挑一些大里程碑收获拿出来做分享,做沉淀,结合业务做实际应用,大家也能感受到做这些事儿的实际意义,团队信任关系也会越牢固。


KPI设计权衡:


产研团队虽然很难量化指标,但是做一份大家都认可的KPI 是完全可行的。


员工自评+管理者补评只要达到双方的认可,保持公平,公开。


关于KPI第一是考虑清楚KPI是一个奖惩手段,奖惩要分开来看。不然他就单纯变成一把尺子,架在每个人都脖子上,为了KPI而KPI,黑魔法多了去了,比如池老师举过一个例子,为了提高pv,产品经理把一个页面拆成2个,得到2倍pv。


我个人倾向于把它的激励作用放大一些,惩罚效果降低。好的员工有荣辱心,反省能力强,反而是在出事时帮他一把作为激励效果很好。


展开挺复杂,我简单举2个例子表述我的理解:


开发侧好量化的软件交付质量:100分,那么出 bug,分测试阶段,线上阶段,等级从p1-p4 定级,定级之后要对应的扣分标准呢,主动或协助及时修复奖励多少分。这个比较典型,很好量化。


不好量化的沟通协作能力:


"能胜任跨部门沟通协调,高效协作 85~100分


能主导 1 V N 沟通协调 70~85分


能主导 1 V 1 沟通协调 60~70分


沟通能力一般 60分"


这可以用管理者+实例结合来做。员工自评写实例,在什么项目主导协作,这个大家都看得见,管理者再主观的做一些评价,稍微修正一下。


最后,以100为单位为每一项考核做好权重分配,假设考核一共分5项,软件交付质量,设置它的权重为25%,来控制它的影响幅度。


设置权重能很好的适应员工能力梯队,凸显他们各自的优点。


所有人都打完分了,不管是271,28的划分这对管理者都是个头疼的事情,这时候就需要花较多的时间综合考虑,绝对公平太难了,见仁见智了。


我见过的不好的例子就是:给A这个月打高分给B打低分,下个月给B打高分给A打低分,全年下来大家保个平安,上下串通一气,团队一片和谐。


一点题外话,我对设计流程,考核等工作内容有天然的兴趣,但不知道可以以此作为主要能力找工作吗?这会是什么样的工作呢?(我也去问下gpt)


GPT回复:


设计流程和考核等工作内容是非常重要的设计领域的核心能力,如果您对此有天然的兴趣,可以尝试在这方面深入学习和实践,成为一名设计项目经理、设计团队领导、设计流程咨询师等职业。


作为一名设计项目经理,您需要负责整个设计项目的流程管理、协调和监督,确保设计团队能够按时、按质完成设计任务。同时,您需要与客户和其他相关方保持良好的沟通,理解客户需求,并协调解决设计项目中的问题。


作为一名设计团队领导,您需要带领设计团队完成项目任务,并对团队成员进行管理和培训。您需要制定设计流程和考核标准,并监督团队成员按照标准完成任务。


作为一名设计流程咨询师,您需要帮助客户建立有效的设计流程,并优化设计流程中的各个环节,以提高设计效率和质量。您需要了解各种设计软件和工具,并根据客户需求推荐最佳的设计工具和流程。


需要注意的是,设计流程和考核等工作内容是设计领域的重要能力之一,但并不是唯一的能力。如果您想在设计领域发展,还需要具备良好的设计技能和创造力。


emm.....


作者:橘子没了
链接:https://juejin.cn/post/7232120266805493816
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

技术主管是否需要什么段位的技术

今天来跟大家讨论一下技术主管需要什么样段位的技术? 首先我要说明的一点,技术主管前提一定是技术出身。对于那些完全不懂技术,但是又身兼技术主管或者总监的同学,我这里就不再赘述,毕竟这个已经超出我目前理解力的范围。比如阿里云的王坚博士,基本上不懂技术细节,但是依然...
继续阅读 »

今天来跟大家讨论一下技术主管需要什么样段位的技术?


首先我要说明的一点,技术主管前提一定是技术出身。对于那些完全不懂技术,但是又身兼技术主管或者总监的同学,我这里就不再赘述,毕竟这个已经超出我目前理解力的范围。比如阿里云的王坚博士,基本上不懂技术细节,但是依然是阿里云的CTO,一手缔造了阿里云。


那我们这里再详细讨论一下,作为一名技术主管,到底应该有什么样的一个技术的段位?或者换句话来说,你的主管的技术水平需要到达什么样的一个水位?


先说结论,作为一名技术主管,一定是整个团队的技术架构师。像其他的一些大家所讨论的条件我觉得都是次要的,比如说写代码的多少,对于技术深度的钻研多少,带的团队人数多少等等,最核心的是技术主管一定要把控整个团队整个业务技术发展的骨架。


为什么说掌控团队技术架构是最重要的?因为对于一个团队来说无非就两点,第一点就是业务价值,第二点就是技术价值。


对于业务价值来说,有各种各样的同学都可以去负责业务上面的一些导向和推进,比如说产品经理,比如说运营同学。技术主管可以在一定程度上去帮助业务成功,甚至是助力业务成功,但是一定要明白技术同学一定要有自己的主轴,就是你对于整个技术的把握。因为业务上的决策说到底技术主管是只能去影响而非去决策,否则就是你们整体业务同学太过拉胯,无法形成战术合力的目的。


对于一线开发同学来说,你只要完成一个接一个的技术项目即可。但是对于技术主管来说,你就要把握整体的技术发展脉络。要清晰的明白什么样的技术架构是和当前的业务匹配的,同时又具备未来业务发展的可扩展性。


那为什么不能把整个技术架构的设计交给某一个核心的骨干研发同学呢?


所以这里就要明白,对于名技术主管来说,未必一定要深刻的钻研技术本身,一定要把技术在业务上的价值发挥到最大。所以在一定程度上来说,可以让适当的同学参与或者主导整个技术架构的设计,但是作为主管必须要了解到所谓的技术投入的产出比是什么。但是如果不对技术架构有一个彻底的理解,如何能决定ROI?



也就是在技术方案的选型里面一定要有一个平衡,能够用最小的技术投入获取到最大的技术利益,而非深究于技术本身的实习方式。如果一名技术主管不了解技术的框架或者某一些主干流程,那么就根本谈不上怎么样去评估这投入的技术产出比。一旦一名技术主管无法衡量整个技术团队的投入产出比,那就意味着整个团队的管理都是在抓虾和浑水摸鱼的状态,这时候就看你团队同学是否自觉了。


出现了这种情况下的团队,可能换一头猪在主管的位置上,业务依然运行良好。如果在业务发展好的时候,可能一直能够顺利推动,你只要坐享其成就可以了,但是一旦到了要突破困难的时期,或者在业务走下行的时候,这个时候你技术上面的优势就一点就没有了。而且在这种情况下,如果你跳槽到其他公司,作为一名技术主管,对方的公司对你的要求也是非常高的,所以这个时候你如果都说不出来你的技术价值对于业务上面的贡献是什么那想当然,你可能大概率就凉凉了。


那问题又回到了什么样的水平才能到达架构师这个话题,可以出来另一篇文章来描述,但是整体上来说,架构的本质首先一定要明白,为的就是业务的增长。


其次,架构的设计其实就是建造一个软件体系的结构,使得具备清晰度,可维护性和可扩展性。另外要想做好架构,基本的基础知识也必不可少,比如说数据库选型、分布式缓存、分库分表、幂等、分布式锁、消息架构、异步架构等等。所以本身来说做好架构师本身难度就非常大,需要长期的积累,实现厚积而薄发。如何成为一名优秀的架构师可以看我的公众号的其他文章,这里就不再详细的介绍了。



第二点是技术主管需要对于技术细节有敏感度。很多人在问一名主管到底应该具备什么样的综合能力,能不能用一种更加形象的方式来概括,我认为就有一句话就可以概括了。技术主管应该是向战略轰炸机在平常的时候一直遨游在大气的最上层能够掌控整个全局,当到了必须要战斗的时候,可以快速的补充下去,定点打击。


我参加过一次TL培训课程,讲师是阿里云智能交付技术部总经理张瑞,他说他最喜欢的一句管理概括,就是“心有猛虎,细嗅蔷薇”,也就是技术主管在平常的时候会关注于更大的宏观战略或策略,也就是注重思考全局,但是在关键的时候一定要关注和落地实际的细节。


换句更加通俗的话来说,就是管理要像战略轰炸机,平常的时候飞在万丈高空巡视,当发生了战斗的时候,立即能够实现定点轰炸。



所以如果说架构上面的设计就是对于整个团队业务和技术骨架的把握,那么对于细节的敏感度就是对于解决问题的落地能力。


那怎么样能够保证你自己有一个技术细节的敏感度?


我认为必要的代码量是需要的,也就是说对于一个主管来说,不必要写太多低代码,但一定要保证一定的代码量,让自己能够最好的,最快的,最贴近实际的理解实际的业务项目。自己写一些代码,其实好处非常多,一方面能够去巩固和加深自己对技术的理解,另外一方面也能够通过代码去更加理解业务。


当然贴近技术的方式有很多种,不一定要全部靠写代码来完成,比如说做code review的方式来完成,做技术方案的评审来完成,这都是可以的。对我来说,我就会强迫自己在每一个迭代会写上一个需求,需求会涉及到各方各面的业务点。有前端的,有后端的,也有数据库设计的。


自己亲自参与写代码或者code review,会让自己更加贴近同学,能够感知到同学的痛点,而不至于只是在空谈说教。


总结


所以对于一个技术主管来说,我认为首要的就是具备架构设计的能力,其次就是要有代码细节的敏感度,对全局和对细节都要有很强大的把控能力。


当然再总结一下,这一套理论只是适用于基础的管理者,而非高层的CTO等,毕竟不同的层级要求的能力和影响力都是不一样的。


作者:ali老蒋
链接:https://juejin.cn/post/7257784425044705340
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

兄弟,王者荣耀的段位排行榜是通过Redis实现的?

在王者荣耀中,我们会打排位赛,而且大家最关注的往往都是你的段位,还有在好友中的排名。 作为程序员的你,思考过吗,这个段位排行榜是怎么实现的?了解它的实现原理,会不会对上分有所帮助? 看看我的排名,你就知道了,答案是否定的,哈哈。 一、排行榜设计方案 从技...
继续阅读 »

在王者荣耀中,我们会打排位赛,而且大家最关注的往往都是你的段位,还有在好友中的排名。


作为程序员的你,思考过吗,这个段位排行榜是怎么实现的?了解它的实现原理,会不会对上分有所帮助?



看看我的排名,你就知道了,答案是否定的,哈哈。




一、排行榜设计方案


从技术角度而言,我们可以根据排行榜的类型来选择不同技术方案来进行排行榜设计。


1、数据库直接排序


在低数据量场景中,用数据库直接排序做排行榜的,有很多。


举个栗子,比如要做一个程序员薪资排行榜,看看哪个城市的程序员最有钱。


根据某招聘网站的数据,2023年中国国内程序员的平均月薪为1.2万元,其中最高的是北京,达到了2.1万元,最低的是西安,只有0.7万元。


以下是几个主要城市的程序员平均月薪排行榜:



  1. 北京:2.1万元

  2. 上海:1.9万元

  3. 深圳:1.8万元

  4. 杭州:1.6万元

  5. 广州:1.5万元

  6. 成都:1.3万元

  7. 南京:1.2万元

  8. 武汉:1.1万元

  9. 西安:0.7万元


从这个榜单中可以看出,我拖了大家的后腿,抱歉了。



这个就可以用数据库来做,一共也没有多少个城市,来个百大,撑死了。


对于这种量级的数据,加好索引,用好top,都不会超过100ms,在请求量小、数据量小的情况下,用数据库做排行榜是完全没有问题的。


2、王者荣耀好友排行


这类榜单是根据自己好友数据来进行排行的,这类榜单不用将每位好友的数据都存储在数据库中,而是通过获取自己的好友列表,获取好友的实时分数,在客户端本地进行本地排序,展现出王者荣耀好友排行榜,因为向数据库拉取数据是需要时间的,比如一分钟拉取一次,因为并非实时拉取,这类榜单对数据库的压力还是较小的。



下面探索一下在Java中使用Redis实现高性能的排行榜是如何实现的?



二、Redis实现计数器


1、什么是计数器功能?


计数器是一种常见的功能,用于记录某种事件的发生次数。在应用中,计数器可以用来跟踪用户行为、统计点击次数、浏览次数等。


例如,您可以使用计数器来记录一篇文章被阅读的次数,或者统计某个产品被购买的次数。通过跟踪计数,您可以了解数据的变化趋势,从而做出更明智的决策。


2、Redis实现计数器的原理


Redis是一款高性能的内存数据库,提供了丰富的数据结构和命令,非常适合实现计数器功能。在Redis中,我们可以使用字符串数据类型以及相关的命令来实现计数器。


(1)使用INCR命令实现计数器


Redis的INCR命令是一个原子操作,用于将存储在键中的数字递增1。如果键不存在,将会创建并初始化为0,然后再执行递增操作。这使得我们可以轻松地实现计数器功能。


让我们通过Java代码来演示如何使用Redis的INCR命令实现计数器:


import redis.clients.jedis.Jedis;

public class CounterExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String articleId = "article:123";
String viewsKey = "views:" + articleId;

// 使用INCR命令递增计数
long views = jedis.incr(viewsKey);

System.out.println("Article views: " + views);

jedis.close();
}
}

在上面的代码中,我们使用了Jedis客户端库来连接Redis服务器,并使用INCR命令递增一个存储在views:article:123键中的计数器。每次执行该代码,计数器的值都会递增,并且我们可以轻松地获取到文章的浏览次数。


(2)使用INCRBY命令实现计数器


除了单次递增1,我们还可以使用INCRBY命令一次性增加指定的数量。这对于一些需要一次性增加较大数量的场景非常有用。


让我们继续使用上面的例子,但这次我们使用INCRBY命令来增加浏览次数:


import redis.clients.jedis.Jedis;

public class CounterExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String articleId = "article:123";
String viewsKey = "views:" + articleId;

// 使用INCRBY命令递增计数
long views = jedis.incrBy(viewsKey, 10); // 一次增加10

System.out.println("Article views: " + views);

jedis.close();
}
}

在上述代码中,我们使用了INCRBY命令将文章浏览次数一次性增加了10。这在统计需要一次性增加较多计数的场景中非常有用。


通过使用Redis的INCRINCRBY命令,我们可以轻松实现高性能的计数器功能。这些命令的原子性操作保证了计数的准确性,而且非常适用于需要频繁更新计数的场景。


三、通过Redis实现“王者荣耀”排行榜?


王者荣耀的排行榜是不是用Redis做的,我不得而知,但,我的项目中,排行榜确实是用Redis做的,这是实打实的。



看见了吗?掌握算法的男人,到哪里都是无敌的。




1、什么是排行榜功能?


排行榜是一种常见的功能,用于记录某种项目的排名情况,通常按照某种规则对项目进行排序。在社交媒体、游戏、电商等领域,排行榜功能广泛应用,可以增强用户的参与度和竞争性。例如,社交媒体平台可以通过排行榜展示最活跃的用户,游戏中可以展示玩家的分数排名等。


2、Redis实现排行榜的原理


在Redis中,我们可以使用有序集合(Sorted Set)数据结构来实现高效的排行榜功能。有序集合是一种键值对的集合,每个成员都与一个分数相关联,Redis会根据成员的分数进行排序。这使得我们能够轻松地实现排行榜功能。


(1)使用ZADD命令添加成员和分数


Redis的ZADD命令用于向有序集合中添加成员和对应的分数。如果成员已存在,可以更新其分数。让我们通过Java代码演示如何使用ZADD命令来添加成员和分数到排行榜:


import redis.clients.jedis.Jedis;

public class LeaderboardExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String leaderboardKey = "leaderboard";
String player1 = "PlayerA";
String player2 = "PlayerB";

// 使用ZADD命令添加成员和分数
jedis.zadd(leaderboardKey, 1000, player1);
jedis.zadd(leaderboardKey, 800, player2);

jedis.close();
}
}

在上述代码中,我们使用ZADD命令将PlayerAPlayerB作为成员添加到leaderboard有序集合中,并分别赋予分数。这样,我们就在排行榜中创建了两名玩家的记录。


(2)使用ZINCRBY命令更新成员分数


除了添加成员,我们还可以使用ZINCRBY命令更新已有成员的分数。这在实时更新排行榜中的分数非常有用。


让我们继续使用上面的例子,但这次我们将使用ZINCRBY命令来增加玩家的分数:


import redis.clients.jedis.Jedis;

public class LeaderboardExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String leaderboardKey = "leaderboard";
String player1 = "PlayerA";
String player2 = "PlayerB";

// 使用ZINCRBY命令更新成员分数
jedis.zincrby(leaderboardKey, 200, player1); // 增加200分

jedis.close();
}
}

在上述代码中,我们使用了ZINCRBY命令将PlayerA的分数增加了200分。这种方式可以用于记录玩家的得分、积分等变化,从而实时更新排行榜数据。


通过使用Redis的有序集合以及ZADDZINCRBY等命令,我们可以轻松实现高性能的排行榜功能。这些命令的原子性操作保证了排行的准确性和一致性,非常适用于需要频繁更新排行榜的场景。



我的最强百里,12-5-6,这都能输?肯定是哪里出问题了,服务器性能?




四、计数器与排行榜的性能优化


在本节中,我们将重点讨论如何在高并发场景下优化计数器和排行榜功能的性能。通过合理的策略和技巧,我们可以确保系统在处理大量数据和用户请求时依然保持高性能。


1、如何优化计数器的性能?


(1)使用Redis事务


在高并发场景下,多个用户可能同时对同一个计数器进行操作,这可能引发并发冲突。为了避免这种情况,可以使用Redis的事务来确保原子性操作。事务将一组命令包装在一个原子性的操作中,保证这些命令要么全部执行成功,要么全部不执行。


下面是一个示例,演示如何使用Redis事务进行计数器操作:


import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.exceptions.JedisException;

public class CounterOptimizationExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String counterKey = "view_count";
try {
// 开始事务
Transaction tx = jedis.multi();
// 对计数器执行加1操作
tx.incr(counterKey);
// 执行事务
tx.exec();
} catch (JedisException e) {
// 处理事务异常
e.printStackTrace();
} finally {
jedis.close();
}
}
}

在上述代码中,我们使用了Jedis客户端库,通过MULTI命令开启一个事务,然后在事务中执行INCR命令来增加计数器的值。最后,使用EXEC命令执行事务。如果在事务执行期间出现错误,我们可以通过捕获JedisException来处理异常。


(2)使用分布式锁


另一种优化计数器性能的方法是使用分布式锁。分布式锁可以确保在同一时刻只有一个线程能够对计数器进行操作,避免了并发冲突。这种机制可以保证计数器的更新是串行化的,从而避免了竞争条件。


以下是一个使用Redisson框架实现分布式锁的示例:


import org.redisson.Redisson;
import org.redisson.api.RLock;

public class CounterOptimizationWithLockExample {

public static void main(String[] args) {
Redisson redisson = Redisson.create();
RLock lock = redisson.getLock("counter_lock");

try {
lock.lock(); // 获取锁
// 执行计数器操作
} finally {
lock.unlock(); // 释放锁
redisson.shutdown();
}
}
}

在上述代码中,我们使用了Redisson框架来创建一个分布式锁。通过调用lock.lock()获取锁,然后执行计数器操作,最后通过lock.unlock()释放锁。这样可以保证在同一时间只有一个线程能够执行计数器操作。



2、如何优化排行榜的性能?


(1)分页查询


在排行榜中,通常会有大量的数据,如果一次性查询所有数据,可能会影响性能。为了解决这个问题,可以使用分页查询。将排行榜数据分成多个页,每次查询一小部分数据,以减轻数据库的负担。


以下是一个分页查询排行榜的示例:


import redis.clients.jedis.Jedis;
import redis.clients.jedis.Tuple;
import java.util.Set;

public class LeaderboardPaginationExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String leaderboardKey = "leaderboard";
int pageSize = 10; // 每页显示的数量
int pageIndex = 1; // 页码

// 获取指定页的排行榜数据
Set<Tuple> leaderboardPage = jedis.zrevrangeWithScores(leaderboardKey, (pageIndex - 1) * pageSize, pageIndex * pageSize - 1);

for (Tuple tuple : leaderboardPage) {
String member = tuple.getElement();
double score = tuple.getScore();
System.out.println("Member: " + member + ", Score: " + score);
}

jedis.close();
}
}

在上述代码中,我们使用zrevrangeWithScores命令来获取指定页的排行榜数据。通过计算起始索引和结束索引,我们可以实现分页查询功能。


(2)使用缓存


为了进一步提高排行榜的查询性能,可以将排行榜数据缓存起来,减少对数据库的访问。例如,可以使用Redis缓存最近的排行榜数据,定期更新缓存以保持数据的新鲜性。


以下是一个缓存排行榜数据的示例:


import redis.clients.jedis.Jedis;
import redis.clients.jedis.Tuple;
import java.util.Set;

public class LeaderboardCachingExample {

public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);

String leaderboardKey = "leaderboard";
String cacheKey = "cached_leaderboard";
int cacheExpiration = 300; // 缓存过期时间,单位:秒

// 尝试从缓存中获取排行榜数据
Set<Tuple> cachedLeaderboard = jedis.zrevrangeWithScores(cacheKey, 0, -1);

if (cachedLeaderboard.isEmpty()) {
// 如果缓存为空,从数据库获取数据并更新缓存
Set<Tuple> leaderboardData = jedis.zrevrangeWithScores(leaderboardKey, 0, -1);
jedis.zadd(cacheKey, leaderboardData);
jedis.expire(cacheKey, cacheExpiration);
cachedLeaderboard = leaderboardData;
}

for

(Tuple tuple : cachedLeaderboard) {
String member = tuple.getElement();
double score = tuple.getScore();
System.out.println("Member: " + member + ", Score: " + score);
}

jedis.close();
}
}

在上述代码中,我们首先尝试从缓存中获取排行榜数据。如果缓存为空,我们从数据库获取数据,并将数据存入缓存。使用expire命令来设置缓存的过期时间,以保持数据的新鲜性。


五、实际应用案例


在本节中,我们将通过两个实际的案例,展示如何使用Redis的计数器和排行榜功能来构建社交媒体点赞系统和游戏玩家排行榜系统。这些案例将帮助您更好地理解如何将Redis的功能应用于实际场景中。


1、社交媒体点赞系统案例


(1)问题背景


假设我们要构建一个社交媒体平台,用户可以在文章、照片等内容上点赞。我们希望能够统计每个内容的点赞数量,并实时显示最受欢迎的内容。


(2)系统架构



  • 每个内容的点赞数可以使用Redis的计数器功能进行维护。

  • 我们可以使用有序集合(Sorted Set)来维护内容的排名信息,将内容的点赞数作为分数。


(3)数据模型



  • 每个内容都有一个唯一的标识,如文章ID或照片ID。

  • 使用一个计数器来记录每个内容的点赞数。

  • 使用一个有序集合来记录内容的排名,以及与内容标识关联的分数。


(4)Redis操作步骤



  1. 用户点赞时,使用Redis的INCR命令增加对应内容的点赞数。

  2. 使用ZADD命令将内容的标识和点赞数作为分数添加到有序集合中。


Java代码示例


import redis.clients.jedis.Jedis;

public class SocialMediaLikeSystem {

private Jedis jedis;

public SocialMediaLikeSystem() {
jedis = new Jedis("localhost", 6379);
}

public void likeContent(String contentId) {
// 增加点赞数
jedis.incr("likes:" + contentId);

// 更新排名信息
jedis.zincrby("rankings", 1, contentId);
}

public long getLikes(String contentId) {
return Long.parseLong(jedis.get("likes:" + contentId));
}

public void showRankings() {
// 显示排名信息
System.out.println("Top content rankings:");
jedis.zrevrangeWithScores("rankings", 0, 4)
.forEach(tuple -> System.out.println(tuple.getElement() + ": " + tuple.getScore()));
}

public static void main(String[] args) {
SocialMediaLikeSystem system = new SocialMediaLikeSystem();
system.likeContent("post123");
system.likeContent("post456");
system.likeContent("post123");

System.out.println("Likes for post123: " + system.getLikes("post123"));
System.out.println("Likes for post456: " + system.getLikes("post456"));

system.showRankings();
}
}

在上述代码中,我们创建了一个名为SocialMediaLikeSystem的类来模拟社交媒体点赞系统。我们使用了Jedis客户端库来连接到Redis服务器,并实现了点赞、获取点赞数和展示排名的功能。每当用户点赞时,我们会使用INCR命令递增点赞数,并使用ZINCRBY命令更新有序集合中的排名信息。通过调用zrevrangeWithScores命令,我们可以获取到点赞数排名前几的内容。



2、游戏玩家排行榜案例


(1)问题背景


在一个多人在线游戏中,我们希望能够实时追踪和显示玩家的排行榜,以鼓励玩家参与并提升游戏的竞争性。


(2)系统架构



  • 每个玩家的得分可以使用Redis的计数器功能进行维护。

  • 我们可以使用有序集合来维护玩家的排名,将玩家的得分作为分数。


(3)数据模型



  • 每个玩家都有一个唯一的ID。

  • 使用一个计数器来记录每个玩家的得分。

  • 使用一个有序集合来记录玩家的排名,以及与玩家ID关联的得分。


(4)Redis操作步骤



  1. 玩家完成游戏时,使用Redis的ZINCRBY命令增加玩家的得分。

  2. 使用ZREVRANK命令获取玩家的排名。


(5)Java代码示例


import redis.clients.jedis.Jedis;
import redis.clients.jedis.Tuple;

import java.util.Set;

public class GameLeaderboard {

private Jedis jedis;

public GameLeaderboard() {
jedis = new Jedis("localhost", 6379);
}

public void updateScore(String playerId, double score) {
jedis.zincrby("leaderboard", score, playerId);
}

public Long getPlayerRank(String playerId) {
return jedis.zrevrank("leaderboard", playerId);
}

public Set<Tuple> getTopPlayers(int count) {
return jedis.zrevrangeWithScores("leaderboard", 0, count - 1);
}

public static void main(String[] args) {
GameLeaderboard leaderboard = new GameLeaderboard();
leaderboard.updateScore("player123", 1500);
leaderboard.updateScore("player456", 1800);
leaderboard.updateScore("player789", 1600);

Long rank = leaderboard.getPlayerRank("player456");
System.out.println("Rank of player456: " + (rank != null ? rank + 1 : "Not ranked"));

Set<Tuple> topPlayers = leaderboard.getTopPlayers(3);
System.out.println("Top players:");
topPlayers.forEach(tuple -> System.out.println(tuple.getElement() + ": " + tuple.getScore()));
}
}

在上述代码中,我们创建了一个名为GameLeaderboard的类来模拟游戏玩家排行榜系统。我们同样使用Jedis客户端库来连接到Redis服务器,并实现了更新玩家得分、获取玩家排名和获取排名前几名玩家的功能。使用zincrby命令可以更新玩家的得分,而zrevrank命令则用于


获取玩家的排名,注意排名从0开始计数。通过调用zrevrangeWithScores命令,我们可以获取到排名前几名玩家以及他们的得分。


六、总结与最佳实践


在本篇博客中,我们深入探讨了如何使用Redis构建高性能的计数器和排行榜功能。通过实际案例和详细的Java代码示例,我们了解了如何在实际应用中应用这些功能,提升系统性能和用户体验。让我们在这一节总结Redis在计数器和排行榜功能中的价值,并提供一些最佳实践指南。


1、Redis在计数器和排行榜中的价值


通过使用Redis的计数器和排行榜功能,我们可以实现以下价值:




  • 实时性和高性能:Redis的内存存储和优化的数据结构使得计数器和排行榜功能能够以极高的性能实现。这对于需要实时更新和查询数据的场景非常重要。




  • 用户参与度提升:在社交媒体和游戏等应用中,计数器和排行榜功能可以激励用户参与。通过显示点赞数量或排行榜,用户感受到了更强的互动性和竞争性,从而增加了用户参与度。




  • 数据统计和分析:通过统计计数和排行数据,我们可以获得有价值的数据洞察。这些数据可以用于分析用户行为、优化内容推荐等,从而指导业务决策。




2、最佳实践指南


以下是一些使用Redis构建计数器和排行榜功能的最佳实践指南:




  • 合适的数据结构选择:根据实际需求,选择合适的数据结构。计数器可以使用简单的String类型,而排行榜可以使用有序集合(Sorted Set)来存储数据。




  • 保证数据准确性:在高并发环境下,使用Redis的事务、管道和分布式锁来保证计数器和排行榜的数据准确性。避免并发写入导致的竞争条件。




  • 定期数据清理:定期清理不再需要的计数器和排行数据,以减小数据量和提高查询效率。可以使用ZREMRANGEBYRANK命令来移除排行榜中的过期数据。




  • 适度的缓存:对于排行榜数据,可以考虑添加适度的缓存以提高查询效率。但要注意平衡缓存的更新和数据的一致性。




通过遵循这些最佳实践,您可以更好地应用Redis的计数器和排行榜功能,为您的应

作者:哪吒编程
来源:juejin.cn/post/7271908000414351400
用程序带来更好的性能和用户体验。

收起阅读 »