注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

新项目跑不起来,人和项目总得有一个能跑

web
前言 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。 每个人的花期不...
继续阅读 »

前言


  • 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。

  • 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。





每个人的花期不同。


就像万特特在《这世界很好,你也不差》中讲的,每个人花期不同,不必焦虑有人比你提前拥有。



1.jpg


一、问题剖析


那是一个风和日丽的早上,我想要去看漫天霞光。


要和心上人手挽手走在街上,


清晨的花香,傍晚的夕阳。


正当我沉迷于甜蜜的幻想中,前同事发来:我遇到一个问题,帮我看看呗!


真是的,慌慌张张的,说吧~


问题是这样子的:


现在有一个新项目,B项目从A项目复制过去,在原有的功能上扩展。


我又多嘴的问了一句,为啥要复制一份过去啊!


他回道:因为现在要做国际化的需求,之前的项目没有考虑到,改起来很麻烦,打算先拷贝一份出来,通过java程序执行一下,先把大部分的先转化一下,这样子不会影响原本项目的开发。


我又对嘴了一下,开新分支不可以嘛!


他说:因为是给两个地区用的,可能在A页面,两个区的需求不同,改起来也麻烦,需求不同,就没必要硬写在一起了。


懂了!


二、gitlab仓库中有两种方式拉取代码仓库:ssh和https


2.png


ssh


首先,确保你已经在 GitLab 上配置了 SSH 密钥。如果没有,你需要生成 SSH 密钥并将公钥添加到 GitLab 的个人设置中。


https


使用 HTTPS 协议拉取代码需要输入你的 GitLab 用户名和密码,或者是访问令牌(Access Token)。


两者



  • 使用 SSH 协议可以避免频繁输入用户名和密码,但需要提前配置好 SSH 密钥。

  • 使用 HTTPS 协议在拉取代码时需要提供用户名和密码或者访问令牌,相对来说更加方便快捷。


一般来说,都是用https拉取仓库,但我怎么拉都拉不下来。


提示:


SSL certificate problem: self signed certificate

翻译:SSL证书问题:自签名证书

于是,我尝试运行ssh的方式拉取。


我们首先需要先生成秘钥


ssh-keygen -t rsa -C “your_email@youremail.com”  
// 命令中的email,就是gitlab中的账号,需要保持一致

直接三个Enter就行,然后会提示输入密码(可输可不输)


3.png


在~/.ssh/下会生成两个文件,id_rsa和id_rsa.pub


4.png


id_rsa是私钥

id_rsa.pub是公钥

在我们c盘的用户里面有个.ssh文件夹


C:\Users\Lenovo\.ssh

gitlab添加秘钥


5.png


这样子就配置好了,可以通过ssh方式拉取了。


git clone 项目远程仓库ssh地址

项目已拉取。


此时要推送的时候,一直让我输入密码,但我输入完之后,仍然要求输入。


我使用的是tortoisegit


连接gitlab,总是弹出git@xxx.com’s password 对话框


然后打开TortoiseGit设置,如下图进入相应页面,选择相对路径的ssh.exe文件


6.png


查找git在哪里,只需要输入 where git


7.png


到这里项目已经拉取,也能推送了。


接下来运行项目。


三、运行的时候,先安装依赖,发现npm安装失败。


发现npm i


8.png


于是,我尝试cnpm i安装,安装是可以安装,但npm run serve的时候还是报错了。


四、于是,我找同事要来了他本地的依赖,想着在我本地看看能不能跑起来。


但因我和同事的环境(npm、node)环境不一样。


于是,我和他保持一致的版本node:12


但又因为还是有些不同,npm run serve的时候,报了:


error  in ./src/styles/element-variables.scss  
Syntax Error: Error: Missing binary. See message above.

看起来是,sass的问题,于是想着重新安装一下


一般来说sass、node-sass问题经常会出现。


npm install --save-dev sass-loader node-sass

仍然不行,那切换一下node的版本,从node10-node14都轮流切换尝试都不行。


我使用的是nvm管理node版本。


9.png


但点进去看只有10.14.1版本有node_modules文件夹依赖


10.png


其他的版本貌似没有,本来想着和同事保持12版本的node,然后把他的依赖复制给我,但我本地的node12没有node_modules文件夹


关于上面的scss文件引起的:Syntax Error: Error: Missing binary. See message above.


下载fibers


运行的时候报错:而且会弹出框说:中止/忽略(其实是fibers缺少二进制文件执行)


Try running this to fix the issue: D:\Program Files\nodejs\node.exe E:\vue-project\node_modules\fibers/build
Error: Cannot find module 'E:\vue-project\node_modules\fibers\bin\win32-x64-83\fibers'
Require stack:

我们发现了fibers引起的错误


有回答说:


项目node_moudules/fibers/bin文件夹中没有win32-x64-83模块,缺少win32-x64-83文件夹下的fibers.node。


11.png


1.在github下载对应系统版本的node文件


win32-x64-83_binding.node 文件下载地址:github-releases


2.下载后的win32-x64-83_binding.node文件改名为fibers.node。


3.保存fibers.node在项目node_moudules/fibers/bin新建的win32-x64-83文件夹中。


4.然后重新执行run serve就可以


rebuild node-sass

执行


npm rebuild node-sass

如果提示 stack Error: EACCES: permission denied, mkdir 错误,则执行命令:


npm rebuild node-sass --unsafe-perm

失败告终!



但我简单粗暴直接把fibers文件夹给删除重新跑就可以了。(√)



五、后面想了一下,项目的背景,是从A项目复制过来的,那我把A项目的依赖复制过来运行下看看。


复制过来后,可以运行了,但因为B新项目装了vue-i18n国际化依赖,于是我安装一下,运行,终于可以了。


但项目打开页面的时候,还是报错了。


12.webp


看起来,vue-router版本和i18n版本冲突了


我的解决办法是把i18n的版本 改为了 8.26.7 ,再启动项目就可以了


npm install vue-i18n@8.26.7 -S

虽然B项目的package.json写着是版本9的,但这不影响我运行。


只不过在页面用到这个依赖的时候可能会有一些不同。


完成搞定。


后记


那晚,我们一起找了家烧烤店,他说:今日之事,都在酒里了,一口闷。


道不清,理还乱,别是一般滋味在心头~


刚开始接手一个项目的时候,依赖啊,版本啊,什么的都很头疼,但一步一步来,见招拆招,无非就是node版本、npm版本、cnpm他们的故事。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。



作者:Dignity_呱
来源:juejin.cn/post/7339376488028307456
收起阅读 »

MyBatis实现多行合并(collection标签使用)

一、举个栗子 现有如下表结构,用户表、角色表、用户角色关联表。 一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景。 现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色。 从上面的表格我们可以看出,用...
继续阅读 »

一、举个栗子


现有如下表结构,用户表、角色表、用户角色关联表。
一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景
在这里插入图片描述


现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色
在这里插入图片描述
从上面的表格我们可以看出,用户有三个,但每个人的角色不止一个,而且有重复的角色,这里角色的数据从多行合并到了1行


二、难点分析


SQL存在的问题:



想使用SQL实现上面的效果不是不可以,但是很复杂且效率低下,尤其这个地方还需要分页,所以为了保证查询效率,我们需要把逻辑放到服务端来写;



服务端存在的问题:



服务端可以把需要的数据都查询出来,然后自己判断整合,首先十分复杂不说,而且这里有个问题:如果角色也是一个查询条件如何处理呢?



三、解决方案


核心方案就是使用Mybatis的collection标签自动实现多行合并。


下面是collection标签的一些介绍
在这里插入图片描述


常见写法


<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>

四、尝试一下


1. 准备材料


(1)数据库脚本


/*
Navicat Premium Data Transfer

Source Server : 本机
Source Server Type : MySQL
Source Server Version : 80021
Source Host : localhost:3306
Source Schema : mybatis-test

Target Server Type : MySQL
Target Server Version : 80021
File Encoding : 65001

Date: 23/06/2022 19:16:34
*/


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for my_role
-- ----------------------------
DROP TABLE IF EXISTS `my_role`;
CREATE TABLE `my_role` (
`role_id` int NOT NULL COMMENT '角色主键',
`role_code` varchar(32) DEFAULT NULL COMMENT '角色code',
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_role
-- ----------------------------
BEGIN;
INSERT INTO `my_role` VALUES (1, 'admin', '超级管理员');
INSERT INTO `my_role` VALUES (2, 'visitor', '游客');
COMMIT;

-- ----------------------------
-- Table structure for my_user
-- ----------------------------
DROP TABLE IF EXISTS `my_user`;
CREATE TABLE `my_user` (
`user_id` int NOT NULL COMMENT '用户主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称',
`user_gender` tinyint DEFAULT NULL COMMENT '用户性别,1:男/2:女/3:未知',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user
-- ----------------------------
BEGIN;
INSERT INTO `my_user` VALUES (1, '用户1', 1);
COMMIT;

-- ----------------------------
-- Table structure for my_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `my_user_role_rel`;
CREATE TABLE `my_user_role_rel` (
`rel_id` int NOT NULL COMMENT '角色主键',
`role_id` int DEFAULT NULL COMMENT '角色ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`rel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `my_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `my_user_role_rel` VALUES (2, 2, 1);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;


(2)pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<!-- 我这里使用的是mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>


(3)application.properties


# 数据库配置
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-test?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mybatis
mybatis.configuration.auto-mapping-behavior=full
mybatis.configuration.map-underscore-to-camel-case=true
mybatis-plus.mapper-locations=classpath*:/mybatis/mapper/*.xml

2. 项目代码


(1)目录结构


在这里插入图片描述


(2)各类代码


MybatisTestApplication.java


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan({"com.example.mybatistest.mapper"})
public class MybatisTestApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisTestApplication.class, args);
}

}


QueryController.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/mybatis")
public class QueryController {

@Autowired
private UserRoleRelService userRoleRelService;

@GetMapping("/queryList")
public List<UserInfoDO> queryList() {
return userRoleRelService.queryList();
}
}

UserRoleRelService.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface UserRoleRelService {
List<UserInfoDO> queryList();
}

UserRoleRelServiceImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserRoleRelServiceImpl implements UserRoleRelService {

@Autowired
private MyUserRoleRelRepository myUserRoleRelRepository;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelRepository.queryList();
}
}

MyUserRoleRelRepository.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface MyUserRoleRelRepository {
List<UserInfoDO> queryList();
}

MyUserRoleRelRepositoryImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.mapper.MyUserRoleRelMapper;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class MyUserRoleRelRepositoryImpl implements MyUserRoleRelRepository {
@Autowired
public MyUserRoleRelMapper myUserRoleRelMapper;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelMapper.queryList();
}
}

MyUserRoleRelMapper.java


import com.example.mybatistest.entity.UserInfoDO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface MyUserRoleRelMapper {
List<UserInfoDO> queryList();
}

MyRole.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;


@Builder
@Data
@TableName("my_role")
@NoArgsConstructor
@AllArgsConstructor
public class MyRole {

@Column(name = "role_id")
private Integer roleId;

@Column(name = "role_name")
private String roleName;
}

MyUserRoleRel.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;

@Builder
@Data
@TableName("my_user_role_rel")
@NoArgsConstructor
@AllArgsConstructor
public class MyUserRoleRel {

@Column(name = "rel_id")
private Integer relId;

@Column(name = "user_id")
private Integer userId;

@Column(name = "role_id")
private Integer roleId;
}

UserInfoDO.java


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoDO {
private Integer userId;

private String userName;

private List<MyRole> roleList;
}

MyUserRoleRelMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatistest.mapper.MyUserRoleRelMapper">
<resultMap id="BaseResultMap" type="com.example.mybatistest.entity.MyUserRoleRel">
<!--
WARNING - @mbg.generated
-->

<result column="rel_id" jdbcType="INTEGER" property="relId"/>
<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="user_id" jdbcType="INTEGER" property="userId"/>
</resultMap>
<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>
<select id="queryList" resultMap="ExtraBaseResultMap">
SELECT
t3.user_id,
t3.user_name,
t2.role_id,
t2.role_name
FROM
my_user_role_rel t1
LEFT JOIN my_role t2 ON t1.role_id = t2.role_id
LEFT JOIN my_user t3 ON t1.user_id = t3.user_id
</select>
</mapper>

3. 实现效果


在这里插入图片描述


这里可以看到roleList里面有两条数据,说明mybatis已经自动聚合完成了。


4. 一些缺点


缺点1、查询条件不能支持很多


虽然Mybatis可以帮我们实现多行合并的功能,但并不是没有问题的。
当使用角色当做查询条件时,由于角色已经指定了,那么roleList里面必定只有这一个角色,不再会有聚合效果,也就看不到这个用户所有的角色了。
我只能说具体看产品要求吧,大部分时候上面那种问题产品都是可以接受的。


缺点2、不支持分页


这个缺点也看业务场景吧,产品可以接受就用,不能接受就别用,我这里只是介绍有这么一个办法。


作者:summo
来源:juejin.cn/post/7337849561479708735
收起阅读 »

微服务下,如何实现多设备同时登录或强制下线?

分享技术,用心生活 前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。 先来看一下简单的时序图,方便后续理解。 sequenceDi...
继续阅读 »

分享技术,用心生活





前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。





先来看一下简单的时序图,方便后续理解。


sequenceDiagram
用户->>过滤器: 请求登录
过滤器->>业务系统: 是否允许同时登录
业务系统-->>过滤器: 返回是/否
过滤器-->>用户: 登录成功(踢下线)

首先我们需要有一个后台设置开关来控制允不允许用户多设备同时登录的功能(没有也无妨,假定允许),其次在登录后,需要保存用户的userId-token的关系缓存。再回头看上面的时序图,是不是已经能理解实现的原理了。


如果你的架构是微服务,那么可以使用redis来存登录关系缓存,单体架构可以直接存session即可。本文是微服务架构,所以采用的是redis。


本文的前提都是基于同一个用户的情况下,下文不再赘述。


1 构造登录缓存关系


如果要实现同一用户多设备同时登录,那必然需要在session(微服务中可以用redis做session共享)中能找到用户的每一个登录状态,如果只是简单的缓存用户信息是实现不了的,登录时那就必须要有一个唯一值token,这样每次登录token不一样,但是指向的用户是同一个。


user


usertoken中维护的是前缀:用户id,这里不需要维护多个,因为用的reids的hash数据类型,多个登录时,添加新行即可;user部分,这里维护的是多个,即登录一次就有一条记录;因为根据业务需要,后续需要从缓存中获取用户其他信息。



  • 允许多设备同时登录:usertoken只有1条,user可能会有多条

  • 不允许多设备同时登录(有则强制下线):usertoken只有1条,user只有1条


    /**
* 登录成功后缓存用户信息
*
* @param req
* @return
*/

public void cacheUserInfo(CacheUserInfoReqDTO req) {
// 1、缓存用户信息
cacheUser(req);
cacheAuth(req.getUid(), req.getRoles(), req.getPermissions());

// 2、更新token与userId关系
String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + req.getUid());
redisAdapter.set(userTokenRelationKey, req.getToken(), RedisTtl.USER_LOGIN_SUCCESS);
}

2 过滤器配置


登录鉴权部分和用户登录状态上下文不在本文范围内,此处忽略


登录成功后,每一个请求到达过滤器时,通过请求header中的token来获取登录信息;因为我们存的缓存key前缀都包含userId,所以要想得到用户信息,需要使用到redis的scan命令来获取。(scan最好配置count来限制,保证性能


@Override
protected Mono<Void> innerFilter(ServerWebExchange exchange, WebFilterChain chain) {
String token = filterContext.getToken();
if (StringUtils.isBlank(token)) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_MISSING);
}

// scan获取user的key
String userKey = "";
Set<String> scan = redisAdapter.scan(GatewayRedisKeyPrefix.USER_KEY.getKey() + "*" + token);
if (scan.isEmpty()) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}
userKey = scan.iterator().next();

MyUser myUser = (MyUser) redisAdapter.get(userKey);
if (myUser == null) {
throw new BusinessException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}

// 将用户信息塞入http header
// do something...
return chain.filter(exchange.mutate().request(newServerHttpRequest).build());
}

这样保证即使有多设备同时登录,也能获取到登录信息和上下文。


3 如何做强制下线呢?


其实也很简单,在登录前可以通过AOP方式做校验,如果已登录了,那么这里就清除session或用户缓存,再继续进行正常登录即可。再简单一点可以直接在登录service中添加校验


核心逻辑


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntList.get(0).getUserId());
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
if (StringUtils.isNotEmpty(redisToken) && !redisToken.equals(token)) {
throw new BusinessException(UserReturnCodes.MULTI_DEVICE_LOGIN);
}

这里用于判断是否已有登录,并返回给前端提示。用于前端其他业务处理
如果不需要给前端提示,不用返回前端,直接进行清除session或用户缓存逻辑。


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntity.getId());
// 获取当前已用户的登录token
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
// 踢下线之前全部登录
Response<Void> exitLoginResponse = gatewayRpc.allExit(ExitLoginReqDTO.builder().token(redisToken).userId(userEntity.getId()).build());

4 演示



  • 演示强制下线


这里我用用户id为4做演示


先正常第一次登录,提示成功,并且redis中有1条user记录
redis_succ
redis_succ


再次登录,我这里是返回给前端处理了,所以会有提示信息。


login


前端效果


message


最后,扩展一下,如果要实现登录后强制修改默认密码、登录时间段限制等场景,你会怎么实现呢?


作者:临时工
来源:juejin.cn/post/7258155447831920700
收起阅读 »

前端更新部署后通知用户刷新

web
前言 周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。 现在大部分的前端系统都是SPA,用户在使...
继续阅读 »

前言


周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。


现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。


那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。


解决方案



  1. 在public文件夹下加入manifest.json文件,记录版本信息

  2. 前端打包的时候向manifest.json写入当前时间戳信息

  3. 在入口JS引入检查更新的逻辑,有更新则提示更新

    • 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新

    • 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程




Public下的加入manifest.json文件


{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}

这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。


webpack向manifest.json写入当前时间戳信息


	// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})

如果你无需维护更新内容的话,可直接写入timestamp


// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)


检查更新的逻辑


入口文件main.js处引入


我这里检查更新的文件是放在utils/checkUpdate


// 检查版本更新
import '@/utils/checkUpdate'

checkUpdate文件内容如下


import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null

async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}

// 路由拦截
router.beforeResolve(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})

// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)

worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}


这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。


checkUpdate.worker.js文件如下


let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})


如果不使用worker直接讲轮询逻辑放在checkUpdate即可


Worker引入


从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader


new Worker(new URL('./worker.js', import.meta.url));

以下版本的就只能用worker-loader


也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:


function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}

createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})


worker数据通信



// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
 uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
 var uInt8Array = e.data;
 postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
 postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};


但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。


如果要直接转移数据的控制权,就要使用下面的写法。


// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);


然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。


作者:Zayn
来源:juejin.cn/post/7329280514628534313
收起阅读 »

决定了,转产品经理啦。

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何? 那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”天起来也特别丝滑(😂)。 那希望小姐姐的分享,能给大家带来一些启发...
继续阅读 »

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何?


那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”起来也特别丝滑(😂)。



那希望小姐姐的分享,能给大家带来一些启发和帮助🤔,尤其是 25 届 26 届,以及打算转行产品的小伙伴,注意啦。


一、你觉得找工作最关键的一环是什么?


首先首先,要有一份看的过去的简历,这里有个区别就是,如果你是 985 那种好学校没有实习经历投什么公司都可以。


但是如果你是双非一本二本之类的学校,没有实习经历就别去投大公司了,先把简历写好投个 500 人以内的小公司实习着。



7-10 月份之间挑两个月实习着,然后是多看网上的(牛客网,b 站,知乎产品面经非常多,b 站模拟面试视频也挺多的)一些面经,熟悉面试官的套路,懂得面试中常问的问题及思路。


深刻总结自己大学三年经历(实习,成绩,获奖经历),在反思自己的各个经历时,一定要有条理的、详尽的把自己的经历描述清楚,简历上写概要,但是当面试官问到你其中经历时,一定要能条理清晰地表述出来;


之后是在面试过程中根据自己的面试经验,总结面试官们对自己简历的关注重点,多次改版


Boss 直聘投递简历,大量投递简历(boss 记录我总共沟通过的 hr 是 982 位,目总共投递了 196 份简历,视频面试 23 家),说真的产品面试多面几次你就知道哪些问题会经常问了,面试期间录音,面试结束后立刻记录并分析面试过程中的问题,并不断反思自己的不足。


二、学生阶段对找工作最有用的是什么?


大一大二大三在学校都没怎么学习,基本上都是班级倒数前十,我记得上 Java 课那个老师每次都叫全班倒数前十坐第一排,所以我的学习是真的很差。


但是我这个人性格活泼开朗有社交牛杂证,喜欢玩各种 app,喜欢上网,性格内向的女生做产品不太推荐,产品面试有的时候还是要跟面试官画大饼的,要敢说敢想敢画饼。


三、自学了哪些课程?


纯纯靠在 b 站搜索产品经理学习,看视频自己总结复盘,做笔记。


四、大学四年过得怎么样?


大学前三年都在谈恋爱吧,反正没学习,不过也没挂过科,就是普通女生,后来失恋了才知道要好好考虑未来了。其实我也就是大三暑假才开始学习的,计算机专业应届生想面试产品实习好好学一个月都能行!



五、为什么走上了这条路?


因为对于我来说,计算机本身不喜爱,软件学习不好,不能硬去做软件开发,这样坐下来自己就很痛苦,软件技术就把自己的发展方向卡的死死的,转行产品经理是计算机专业学生一个更好的方向。



六、有去实习吗?


我是 9 月份才实习的,说实话有点晚了,你们最好别像我一样这么晚,那个时候找的是一个 300 多人的小公司实习,公司做的是 B 端的产品,我和带我那个导师玩的很好。


我当时跟她做一个她全程负责已经上线的项目,项目资料啥的都给我了,但是她把项目从头到尾细节都给我讲了十几遍,所以我能把这个项目完全梳理清楚。


还有产品经理的特质要求你必须要善于思考,所以虽然你做的事可能是基层的工作,但是你在整个过程中不断思考,这点是面试官比较看重的。


七、为什么选择了产品岗做了哪些准备?


本科双非软件工程专业女生,从大一开始就知道自己并不喜欢技术岗,也做不来,真正明确目标是在大三暑假,在 b 站刷到有关计算机专业做产品的视频,后来仔细了解了产品经理的相关工作,就想尝试入行。



八、推荐 B 站学习资料/或者其他资料?


B 站学习资料其实一搜产品经理学习会有一大堆,我觉得每个 up 主的视频都可以看看,另外可以关注一下人人都是产品经理这个网站,这个也可以下载 app,还有关注一些写的比较好的产品公众号,经常浏览一下培养产品思维。


九、有什么好的学习建议吗?


我觉得要多加一些产品交流学习群,因为一个人可以走得很快,但是一群人可以走得更远,一个人自学的同时也要看看别人的情况,多和未来要做产品以及现在已经做产品的人做朋友,在面试这块还能一起模拟一下。



十、工作后和学校时最大的区别?


工作后和在学校时最大的差别就是,工作后你是赚钱,而在学校你是花钱。


说真的我领第一个月实习工资的时候虽然只有 4000,但是我很开心,第二家公司实习 7000 的时候我存款都一万多了,感觉工作赚钱真好。


十一、工作后的心得体会?


其实工作后和大学时还是有一点比较相似的。


那就是“没人真正关心你的个人发展”。


你就是你,除了你自己和你的父母外,没有人会真正在意你的未来发展,你是出人头地还是泯然众人。每个人都很忙的,所以你要自己好好在意自己,自己去时常反省,自己去寻找资源学习提升,自己好好整自己的简历。



十二、对未来的展望?


因为我第一段实习和目前这家公司都是做 B 端产品的,自己本人也喜欢 B 端产品,我未来产品发展方向肯定是往 B 端走的,希望能在毕业后 3-5 年成长为高级产品经理。


十三、计算机专业做产品的优势?


很多产品招聘要求都是计算机相关专业优先,这一点在产品面试里面可以突出重点。懂一点技术这样可以和技术人员更好的沟通交流,解决 bug。



十四、有什么祝福语吗?


提前祝大家龙年暴富,也祝二哥掘金上早日百万粉。


希望每个和我一样同是计算机专业但是不想做开发的兄弟姐妹都不要放弃!可以来找我一起学习交流产品面试经验哦。


作者:沉默王二
来源:juejin.cn/post/7330933094159908916
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(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){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


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

解决前端跨团队统一的隐性拦路虎

前言 春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。 背景 过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实...
继续阅读 »

默认标题__2024-02-06+15_50_28.png


前言


春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。


背景


过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实的准备,最终都会有一种未竟全功的感觉。


推广过程中,总会有人摆出历史包袱过重这一拦路虎“说服”我们,比如”我这项目不维护了,无需升级“,”我这项目框架太老旧了,无法升级“,或两者兼有之,到底改哪些项目多取决于双方自行判断,说穿了其实是双方“非不能也,乃不欲也”。


危害


一方面前端项目下线充满不确定性,业务不维护不代表页面无访问,旧有项目中总有一些页面残留,需要长期持续跟进。


另一方面过去多年前端技术生态快速向前发展,造成了不同部门、时期,从jquery、vue2、vue3、react、angular到webpack3、4、5、gulp、vite等前端基建五花八门的场景,仅23年我们团队就先后接入过webpack5、vite、pinia、rspack等前端架构局部优化,跨团队统一需要做大量兼容工作,全量统一困难。


前端项目的业务和技术特点,造成了前端项目数量基本越垒越多,每个项目总有几个有流量的页面时不时跳出来恶心人。造成了前端基建越来越庞杂,兼容成本偏高,总是不能全量升级。形成了前端项目独特的长尾化问题,项目长尾化,基建长尾化,团队意识长尾化。随着时间延续会带来升级维护困难和难以言表的线上偶发惊吓。


根源和解决思路


基于我司经验,问题产生根源一是前端团队资源有限,并不能覆盖全部项目;二是没有统一标准,项目缺乏统一标准管理,各团队自我决策改动范围;三是缺乏强制机制,并不能保证完成效果和时间。


资源有限是个基本无法解决的问题,我们只能从标准和强制机制两个角度去解决,基于此我们针对性的制定了转转自己的项目动态分级标准和强制倒逼机制。


项目动态分级


分级指的是用客观统一的数据标准反映项目重要性,规避主观评判。动态指的是随着时间延续,项目走过新建、迭代、维护、下线的生命周期,客观数据也随生命周期波谷、波峰、波谷往复更替。项目动态分级的最大好处是将有限资源聚焦在重点项目上。


以过去我司推进项目代码规范为例,我们设计时采用项目月活跃分支数、月代码提交行数、项目用户日访问量几个指标确定项目级别;


044ab65d-0883-49c5-ac41-07a588af6c2b.png


比如只有同时满足日访问量UV高于10000、日PV高于100000、月活跃分支数多于4个、月代码提交行数多于200的项目才确定为移动端重点项目,其它项目为非重点项目,每日或每周可以跑定时任务更新项目分级数据,具体数据可以通过拉取git api和公司前端埋点数据获得。


针对重点项目,可以制定2-4个月的改造时间节点和达标标准,非重点项目可以不做改造或制定其它策略。


我司前端项目数不到千,仅将项目分成重要和不重要两个级别已经够用,如场景有必要,也可将项目进一步拆分为更多级别。


指标边界值


大家可能对上面UV > 10000或月活跃分支数 > 4 等指标边界值是怎么确定的感兴趣?


说一下我们的思路,相信每个前端团队过去都手动收集过团队中的重点项目有哪些,这些手动数据可以作为我们的衡量基础,可以不断尝试调整我们的指标边界值,计算出的重点项目一般达到基本覆盖我们手动收集的95%以上重点项目即可,以此来确定边界值指标,一般结果肯定会大于我们的手动集,大家拿到结果可以人工再去分析下具体项目,基本能够发现多出来的项目大部分都是真实的重点项目,大部分都是因为我们谈到的历史包袱问题,不提罢了。


注意如果动态项目池,每日或每周都有较大变化,可能是你的指标不太合理,可以考虑扩大或缩小边界值来解决。另外注意突然进入重点动态项目池的项目,一般下个周期有可能就波动出去了,此类项目可以不做处理,也可推动解决,留好一定改动时间即可。


多指标好处


相比单指标确定边界值,多指标有哪些好处呢?


多指标好处一个是覆盖全部场景,比如我司移动端项目框架为Vue,有大量用户访问,中后台项目技术栈为React,没有大量用户访问。如果仅使用用户访问量指标,中后台项目全部会被排除在重点项目外,如果仅使用项目活跃度,移动端最看重的用户访问量不被纳入,项目区分度不够,但是两者相结合,恰好能覆盖移动端和PC端的全量场景。


另一个是多指标得出的结果稳定性更好,不会因单指标剧烈变化造成结果波动太大的情况,比如去年618和双11期间,尽管我司用户访问量大增,但重点项目集并没有什么变化。


强制倒逼机制


另外怎么确保deadline无延期呢?


我司采用的解决办法是与上线环节绑定,通过强拦截和审批拦截的方式,确保各团队能在最终时间节点之前,完成全部项目的改造。除此之外,强制机制能够倒逼各团队提升生产率,将很多重复性工作自动化,比如上文说到的代码规范,总会有同学梳理出一套本地自动化方案,帮我们完成配套建设。当我们标准达成、配套健全后,就可以考虑下扩大范围或者提高标准的事情了。


另外大家需要注意,跨组推进工作中很重要的一点是前置沟通,给同学们留下足够的时间,比如2-6个月,达成时间共识很重要,不要自觉良好一刀切,想想过去经历的很多跨组项目,半年能彻底搞完就很不错了。


252bfa83-7993-42bc-9cea-799da0876e5d.png


思路&基建复用


本文谈到的整套分级和强制思路,后续逐步复用在我司代码重复度、复杂度、线上异常治理、性能指标等多项跨团队公共事项落地中,有些指标直接复用同一套项目分级标准即可,有些指标需要一定的改动,比如性能指标,就从重点项目,变为重点页面即可,大部分基础能力和解决问题的思路仍可复用。


总结


保持公司各团队基建、标准、机制统一,长期看是非常有必要的事,但过去受制于历史包袱问题这一隐性拦路虎,前端很难做到跨团队统一方案的效用最大化,总会留下一些尾巴,本文通过转转过去的实践,给出了项目分级和强制机制的解体思路,非常适合前端资源并不那么充足的公司,至少能够保证公司重要的项目长期标准统一。


另外整个思路的落地,除标准制定外,还涉及到数据抽取、各指标检测、CICD集成、报警机制、系统开发等多方面的具体工作,每一部分都能作为一个单独的模块去做分享,比如代码规范涉及到的项目代码增量存量检测、代码复杂度、重复度检测,线上异常治理涉及到的实时报警策略、错误分级策略等,因篇幅关系,本文不再赘述,期待后面其他转转er的文章吧!!!


作者:转转技术团队
来源:juejin.cn/post/7337589994464854016
收起阅读 »

用 Puppeteer 把繁琐工作给自动化了,太爽啦!

web
最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。 而很多内容我之前写过,所以想复制过来。 这时候我就遇到了一个令人头疼的问题: 知识星球的编辑器也太难用了! 比如我在掘金编辑器里这样的 markdown 内容: 复制到星球编辑器是这样的: markdow...
继续阅读 »

最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。


而很多内容我之前写过,所以想复制过来。


这时候我就遇到了一个令人头疼的问题:


知识星球的编辑器也太难用了!


比如我在掘金编辑器里这样的 markdown 内容:



复制到星球编辑器是这样的:



markdown 语法是识别了,但图片没有自动上传。


如果用富文本格式,格式又不对:



而且 gif 没有识别出来,还是需要手动传一次。


这意味着如果文中有几十张图片,那我需要单独把这几十张图片保存到本地,然后光标定位到对应位置,点击上传图片,把图片插进去。


也就是这样:




把每个图片下载下来,保存为不同的后缀名(png、jpg、gif),然后再定位到对应位置,删除原来的链接,插入图片。


然后这样重复十几次,每篇文章都这样来一遍。


是不是想想都觉得很痛苦。。。


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


于是我想到了 puppeteer。



它是一个网页自动化的 Node.js 工具,基本所有你手动在浏览器里做的事情,都可以用它来自动化完成。


比如点击、移动光标、输入等等。


那前面那个繁琐的问题自然也可以用 puppeteer 自动化来做,解放我们的生产力。


我们来分析下整个流程:


首先打开星球编辑器页面,如果没登录会跳到登录页:



这一步要扫码,没法自动化。


登录之后进入编辑器页面,输入内容:



这时候我们要把其中的图片链接分析出来,自动下载到本地的目录中。


然后记录每个链接所在的行数,把光标移动到对应的行数,点击上传按钮:



上传这一步也要手动来做,选择之前自动下载的图片就行。


然后光标会自动移动到下一个位置,再点击上传按钮,直到所有图片上传完。


文件浏览器这一步是操作系统的功能,没法自动化。


我们把下载图片、在对应位置插入图片的过程给自动化了。只有登录、选择文件这两步还要还要手动做。


但这样已经方便太多了。


流程理清了,我们就来写下代码吧:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('http://www.baidu.com');

await page.focus('#kw');

await page.keyboard.type('hello', {
delay: 200
});

await page.click('#su');

引入 puppeteer,跑一个 chrome 浏览器,创建一个页面,导航到 baidu,输入 hello,点击搜索。


puppeteer 的 api 还是很容易懂的。


其中 defaultViewport 设置宽高为 0 是让网页充满整个窗口。


然后我们把它跑起来,因为用到了 es module、顶层 await,需要在 package.json 声明 type 为 module:



声明 type 为 module 就是所有的模块都是 es module 的意思。


然后把它跑起来:



可以看到脚本正确执行了。


然后我们让它打开星球编辑器的网址:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');


确实跳到登录了:



扫码登录之后进入星球页面,就可以写文章了。



但是,下次跑脚本还是要再登录。


我们不是登录过了么?为啥还需要登录?


因为 chrome 默认的数据保存在一个目录中,叫 userDataDir,而这个目录默认是临时生成的,所以每次保存数据的目录都不一样。


这就导致了每次都需要登陆。


所以我们指定一个固定的 userDataDir 就好了。


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

通过 os.homedir() 拿到 home 目录,再下面新建一个 .puppeteer-data 的目录来保存用户数据。


这样登录一次之后,下次就不再需要登录了:



这时候可以看到 userDataDir 下是保存了用户数据的:



接下来就是编辑部分的自动化了。


我们要做的事情有这么两件:



  • 提取文本中的所有链接,自动下载。

  • 光标定位到每个链接的位置,自动点击上传按钮。


执行这俩自动化脚本的过程最好让用户控制,比如输入 download-img 就自动下载图片,输入 upload-next 光标就自动定位到下个位置,点击上传。


所以我们引入 readline 这个内置模块接收用户输入。


import readline from 'readline';

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});

async function uploadNext() {
console.log('------');
}
async function downloadImg() {
console.log('+++++++');
}

调用 creatInterface api,指定 input、output 为标准输入输出。


然后当收到一行的输入的时候,根据内容决定执行什么方法:



我们先实现 download-img 的部分:



可以看到,编辑器部分的内容就是 .ql-editor 下的一个个 p 标签。


那我们只要取出所有的 p 标签,选出 ![]() 格式的内容就好了。


这需要一个正则,我们先把这个正则写出来:


整体格式是这样的:


![]()

但[] 和 () 需要转义:


!\[\]\(\)

中间部分是除了 [] 和 () 的任意字符出现任意次,也就是这样:


[^\[\]\(\)]*

并且 () 里的内容需要提取,需要用小括号包裹。


完整正则就是这样的:


!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)

我们测试下:



可以看到 () 中的内容被正确提取出来了。


然后在网页里取出所有的 p 标签,根据内容过滤,把链接和行数记录下来:


const links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

用 page.evaluate 方法在网页里远程执行一段 js,拿到它的返回结果。


这里拿到的就是所有的图片链接:



其实严格来说这不叫行数,而是第几个 p 标签,想要定位到对应的 p 标签,只要点击它就好了。



我们记录的下标是从 0 开始,而 nth-child 从 1 开始,所以要加 1。


可以看到,光标定位到了正确的位置:



不过先不着急定位光标,我们先把图片下载给搞定。


下载部分的代码如下:


import https from 'https';
import fs from 'fs';

function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

用 https 模块的 get 方法请求 url,然后把 response 用流的方式写入文件,并且通过 content-length 的响应头拿到总长度。


这样,在每次 data 方法里就能根据总长度,当前 chunk 的长度,算出下载进度。


我们测试下:


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
downloadFile(url, './1.gif', (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;
})


可以看到,图片下载成功了!


但是,我们现在是知道这是个 gif 才给它加上 .gif 后缀,要是任意一个链接,怎么知道它的格式呢?


这个可以用 image-size 这个包:


import sizeOf from 'image-size';
import fs from 'fs';

const buffer = fs.readFileSync('./1.image');
const dimensions = sizeOf(buffer);

console.log(dimensions);

它能拿到图片的类型和宽高信息:



这样我们在下载完改下名就可以了。


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
let filePath = './1.image';
downloadFile(url, filePath, (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
}
})

当下载完之后,拿到图片信息,重命名一下,把后缀名改成新的。


注意下图中文件名字的变化:



这样,下载图片就搞定了。


我们把它集成到自动化流程中。


先指定下文件保存位置和文件名:


我们在 home 目录下创建一个 .img 目录吧,然后文件名是 1.image、2.image 的形式。


const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
fs.writeFileSync(filePath, 'aaaa')
}


每次先清空 .img 目录,再创建。


执行之后,确实在 .img 目录下创建了对应的图片文件:



然后把下载图片和重命名的逻辑集成进来:


fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

这里加了一个 setTimeout,1s 之后执行重命名的逻辑,保证在文件下载完之后再重命名。


效果是这样的:



在 .img 下可以看到所有的图片都下载并重命名成功了:



有 png 也有 gif



下一步只要在不同的位置插入就好了。


我们再来做光标定位的部分。


这部分前面演示过,就是触发对应 p 标签的 click 就好了。


let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

我们定义一个游标,从 0 开始,先点击第一个 link 的 p 标签,把它的内容清空,插入下载的图片。


然后再次执行就是插入下一个。


这样依次插入。


我们来试试:


首先,打开编辑器页面,自己登录和输入 markdown 内容:



然后输入 download-img 来下载图片:



之后执行 upload-next 插入第一张图片:



再执行 upload-next 插入第二张图片:



插入的位置非常正确!



依次 upload-next 就能把所有图片插入完成。


对比下之前的体验:


一张张下载图片,根据不同的格式来重命名,然后一张张找到对应的位置,删除原来的链接,插入图片。


现在的体验:


输入 download-img 自动下载图片,不断执行 upload-next 选择图片,自动插入到正确的位置。


这体验差距很明显吧!


这就是用 puppeteer 自动化以后的工作流。


全部代码如下:


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';
import fs from 'fs';
import readline from 'readline';
import sizeOf from 'image-size';
import downloadFile from './download.js';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});



let links = [];
async function downloadImg() {
links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);


for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

console.log(links);
}

let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

import https from 'https';
import fs from 'fs';

export default function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

总结


星球编辑器不好用,每次都要把图片手动下载下来然后插入对应位置,我们通过 puppeteer 把这个流程自动化了。


puppeteer 是一个自动化测试工具,基本所有浏览器手动的操作都能自动化。


我们用 readline 模块读取用户输入,当输入 download-img 的时候,拿到所有的 p 标签,过滤出链接的内容,把信息记录下来。


自动下载图片并用 image-size 读取图片类型来重命名。


然后输入 upload-next,会通过点击对应 p 标签实现光标定位,然后点击上传按钮来选择图片。


自动化以后的工作流程简单太多了,繁琐的工作都给自动化了,体验爽翻了!


作者:zxg_神说要有光
来源:juejin.cn/post/7230757380819812407
收起阅读 »

js如何实现当文本内容过长时,中间显示省略号...,两端正常展示

web
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。 产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。 关于鼠标悬浮展示全部内容的代码就不放在...
继续阅读 »

前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。


产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。


关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。


实现思路



  1. 获取标题盒子的真实宽度, 我这里用的是clientWidth;

  2. 获取文本内容所占的实际宽度;

  3. 根据文字的大小计算出每个文字所占的宽度;

  4. 判断文本内容的实际宽度是否超出了标题盒子的宽度;

  5. 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;

  6. 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;


代码


html代码


<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>

css代码: 设置文本不换行,同时设置overflow:hidden让文本溢出盒子隐藏


.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}

javascript代码:


获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px,可以用parseInt特殊处理一下。


获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。


判断文本内容是否超出标题盒子


 // 标题盒子dom
const dom = document.getElementById('test');

// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();

// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);

// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;

// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}

// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;

// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}

通过charCodeAt返回指定位置的字符的Unicode编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


截取和计算文本长度


// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}

// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');

// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);

// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');

// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}

最终实现的效果如下:


image.png


上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。


下面记录下从社区内学到的相关知识:



  1. js判断文字被溢出隐藏的几种方法;

  2. JS获取字符串长度的几种常用方法,汉字算两个字节;


1、 js判断文字被溢出隐藏的几种方法


1. Element-plus这个UI框架中的表格组件实现的方案。


通过document.createRangedocument.getBoundingClientRect()这两个方法实现的。也就是我上面代码中实现的checkLength方法。


2. 创建一个隐藏的div模拟实际宽度


通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。


function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`
;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}

3. 创建一个block元素来包裹inline元素


这种方法是在UI框架acro design vue中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。


// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>

// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}

4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度


通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。


// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}

2、JS获取字符串长度的几种常用方法


1. 通过charCodeAt判断字符编码


通过charCodeAt获取指定位置字符的Unicode编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}

2. 采取将双字节字符替换成"aa"的做法,取长度


function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};

参考文章


1. JS如何判断文字被ellipsis了?


2. Canvas API 中文网


3. JS获取字符串长度的常用方法,汉字算两个字节


4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象


作者:娜个小部呀
来源:juejin.cn/post/7329967013923962895
收起阅读 »

uniapp踩坑合集

web
1、onPullDownRefresh下拉刷新不生效 pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新 { "path" : "pages/list/list", "style" : ...
继续阅读 »

1、onPullDownRefresh下拉刷新不生效


pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新
{
"path" : "pages/list/list",
"style" :
{
"navigationBarTitleText": "页面标题名称",
"enablePullDownRefresh": true
}
}

2、onReachBottom上拉加载不生效


page中css样式设置了height:100%;
修改为height:auto;即可

3、onPageScroll生命周期不触发


最外层css样式设置了以下样式
height: 100%;
overflow: scroll;

4、onBackPress监听页面返回生命周期


使用场景:APP手机左滑返回时控制执行某些操作,不直接返回上一页(例如:弹框打开时关闭弹框)


注意事项


1、onBackPress上不可使用async,会导致无法阻止默认返回


2、支付宝小程序只有真机可以监听到非navigateBack引发的返回事件(使用小程序开发工具时不会触发onBackPress),不可以阻止默认返回行为


3、只有在该函数中返回值为 true 时,才表示不执行默认的返回,自行处理此时的业务逻辑


4、当不阻止页面返回却直接调用页面路由相关接口(如:uni.switchTab)时,可能会导致页面显示异常,可以通过延迟调用路由相关接口解决


5、H5 平台,顶部导航栏返回按钮支持 onBackPress(),浏览器默认返回按键及Android手机实体返回键不支持 onBackPress()


6、暂不支持直接在自定义组件中配置该函数,目前只能是在页面中来处理。


//场景1:弹框打开时,返回执行关闭弹框
//html
"searchPop" type="right" @change="popupChange">
<view class="popup-con">1111view>


//js
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}
//其他情况执行默认返回
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}

//场景2,多级返回时
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}else{
if (options.from === 'navigateBack') {
return false;
}
uni.navigateBack({
delta: 2
});
}
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}


5、遮罩层不能遮底部导航栏


应用场景:APP升级弹框提示


uni文档api界面——交互反馈中uni.showModal可以遮罩底部导航栏;
uni.showToast(OBJECT)、uni.showLoading(OBJECT)都无法遮罩底部导航栏;


目前可以采用两种方式解决:自定义底部导航栏、打开时隐藏底部导航栏


方法一:自定义底部导航栏


1、在app.vue页面的onLaunch生命周期中隐藏原生底部  
onLaunchfunction() {
console.log('App Launch')
uni.hideTabBar();
}

2、自己封装tab组件
<template>
<view class="foot-bar">
<view v-if="hasBorder" class="foot-barBorder">view>
<view class="foot-con">
<view class="foot-list" v-for="(item,index) in tabList" :key="index" @tap="tabJump(index,item.pagePath)">
<img v-if="index!=selectedIndex" class="foot-icon" :src="'/'+item.iconPath" mode="heightFix" />
<img v-else class="foot-icon" :src="'/'+item.selectedIconPath" mode="heightFix" />
<text v-if="index!=selectedIndex" :style="textStyle">{{item.text}}text>
<text v-else :style="textSelectStyle">{{item.text}}text>
view>
view>
view>
template>

<script>
export default {
name: "tabBar",
props: {
hasBorder: {
type: Boolean,
default: false
},
selectedIndex:{
type:[String,Number],
default:0
},
textStyle: {
type: Object,
default () {
return {
color:'#999'
}
}
},
textSelectStyle:{
type: Object,
default () {
return {
color: 'rgb(0, 122, 255)'
}
}
}
},
data() {
return {
tabList: [{
"pagePath": "pages/tabBar/component/component",
"iconPath": "static/component.png",
"selectedIconPath": "static/componentHL.png",
"text": "内置组件"
},
{
"pagePath": "pages/tabBar/API/API",
"iconPath": "static/api.png",
"selectedIconPath": "static/apiHL.png",
"text": "接口"
}, {
"pagePath": "pages/tabBar/extUI/extUI",
"iconPath": "static/extui.png",
"selectedIconPath": "static/extuiHL.png",
"text": "扩展组件"
}, {
"pagePath": "pages/tabBar/template/template",
"iconPath": "static/template.png",
"selectedIconPath": "static/templateHL.png",
"text": "模板"
}
]
};
},
methods:{
tabJump(index,url){
if( index == this.selectedIndex ){
return
}
uni.switchTab({
url: '/' + url
})
}
}
}
script>

<style lang="scss" scoped>
.foot-bar {
position: fixed;
left: 0px;
right: 0px;
bottom: 0px;
z-index: 998;
width: 100vw;
.foot-barBorder {
position: absolute;
left: 0px;
right: 0px;
top: -1px;
width: 100vw;
height: 1px;
background-color: #eee;
}
.foot-con {
background-color: #fff;
width: 100vw;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
.foot-list {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.foot-icon{
width: auto;
height:30px;
}
text{
font-size: 12px;
}
}
}
}
style>


3、需要底部导航的页面引入组件,当前页面是导航栏第几个,selectedIndex就等于几,从0开始
<template>
<view>
<TabBar :selectedIndex="0">TabBar>
view>
template>
<script>
import TabBar from "@/components/tabBar/tabBar";
export default {
components:{
TabBar
},
data() {
return {}
}
}
script>


方法二:打开时隐藏底部导航栏,关闭时打开导航栏


uni.hideTabBar();uni.showTabBar(); 官方文档


image.png


image.png


6、条件编译的正确写法


语法:以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾



  • #ifdef:if defined 仅在某平台存在

  • #ifndef:if not defined 除了某平台均存在

  • %PLATFORM%:平台名称


//仅出现在 App 平台下的代码  
#ifdef APP-PLUS
需条件编译的代码
#endif

//除了 H5 平台,其它平台均存在的代码  
#ifndef H5
需条件编译的代码
#endif

在 H5 平台或微信小程序平台存在的代码  
#ifdef H5 || MP-WEIXIN
需条件编译的代码
#endif

//css样式中  
page{
padding-top:24rpx;
/* #ifdef  H5 */
padding-top:34rpx;
/* #endif */
}

//.vue页面中  
<template>

<view>NFC扫码view>

template>

//page.json页面中  
//json文件中
//API 的条件编译
//生命周期中
//methods方法中
mounted(){
// #ifdef APP-PLUS
//APP更新
this.checkUpdate();
//#endif
}

7、限制input输入类型,replace不生效


//不生效代码
"Code" type="number" placeholder="请输入号码" clearable trim="all" :inputBorder="false" @input="gunChange" maxlength="11">

methods:{
gunChange(e){
this.addForm.oilGunCode = e.replace(/[^\d]/g, '');
},
}

使用v-model绑定值时,replace回显不生效;将v-model修改为:value即可生效;


//生效代码
all" :inputBorder="false" @input="gunChange" maxlength="11">

限制只能输入数字:/[^\d]/g/\D/g (但无法限制0开头)
限制只能输入大小写字母、数字、下划线:/[^\w_]/g
限制只能输入小写字母、数字、下划线:/[^a-z0-9_]/g
限制只能输入数字和点:/[^\d.]/g
限制只能输入中文:/[^\u4e00-\u9fa5]/g
限制只能输入英文(大小写均可):/[^a-zA-Z]/g
去除空格:/\s+/g

8、常见的登录验证


"name" placeholder="请输入用户名" clearable trim="all" maxlength="11">
<uni-easyinput v-model="tell" placeholder="请输入手机号" clearable trim="all" maxlength="11">uni-easyinput>

methods:{
submitHandle(){
//姓名 2-5为的汉字
var reg0 = /^[\u4e00-\u9fa5]{2,5}$/,
//用户名正则,4到16位(字母,数字,下划线,减号)
var reg = /^[a-zA-Z0-9_-]{4,16}$/;
//密码强度正则,最少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符
var reg2 = /^.*(?=.{6,})(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[!@#$%^&*? ]).*$/;
//Email正则
var reg3 = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/;
//手机号正则
var reg4 = /^((13[0-9])|(14[5|7])|(15([0-3]|[5-9]))|(18[0,5-9]))\d{8}$/;
//身-份-证号(18位)正则
var reg5 = /^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
//车牌号正则
var reg6 = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/;

if( !reg.test(this.name) ){
uni.showToast(
title:'用户名格式不正确!'
)
}
},
}

作者:CRMEB技术团队
来源:juejin.cn/post/7272185503822086203
收起阅读 »

React Server Components引发的分歧与机遇

web
介绍 React Server Components 在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网...
继续阅读 »

介绍 React Server Components


在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网页。


React Server Components(RSC)的出现拓展了 React 的范围。顾名思义,React Server Components 就是 React 的服务端组件,它们只在服务端运行,可以调用服务端的方法、访问数据库等。RSC 每次预渲染后把 HTML 发送到客户端,由客户端进行水合(hydrate)并正式渲染。这种做法的好处是,一部分原本要打包在客户端 JavaScript 文件里的代码,现在可以放在服务端运行了,从而减轻客户端的负担,提升应用的整体性能和响应速度。


「充分利用服务器资源」是发布 RSC 的最大动机,换句话说就是:一切不需要交互的内容都应当放到服务端。React 官方举了一个非常典型的例子——渲染 markdown 内容,


// 客户端组件渲染

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* 渲染 */);
}

这个例子中,如果用客户端组件渲染,客户端至少要下载 200 多k的文件才能渲染出内容,但这里的 markdown 内容其实不需要交互,也不会因为用户的操作产生更新信息的需求,非常符合使用 RSC 的理念。如果使用 RSC,


// 服务器组件渲染

import marked from 'marked'; // 零打包大小
import sanitizeHtml from 'sanitize-html'; // 零打包大小

function NoteWithMarkdown({text}) {
// 与之前相同
}

依赖包放在服务端,服务端只返回用户需要看到的内容,客户端包一下子就小了 200 多k。


直到这里,社区主流观点都是积极的,直到 Next.js 基于 RSC 的特性野蛮狂奔,分歧出现了。


社区分歧


出现分歧的最根本原因是 React 引入了服务端的概念,服务端组件和客户端组件有着明显差异:



  • 服务器组件不能使用像 useState 和 useEffect 这样的 React hook;客户端则可以;

  • 服务器组件无权访问浏览器 API;客户端有完整的浏览器 API 权限;

  • 服务端有权限直接访问服务端程序和 API;而客户端组件只能通过请求访问部分程序。


随着 Next.js v13 和 v14 版本发布,React 仍然是金丝雀版本的 RSC 被 Next.js 搬到生产环境,‘use client’‘use server’ 被越来越多人讨论,开发者们说现在有「两个 React」,社区开始争吵 React 这些年在进步还是在退步?


WechatIMG4559.jpeg


社区里反对的声音


首先是知名软件工程师 Cassidy Williams,她指出 React 这两年的发展问题:



  • 「两个 React」带来的新概念对大多数人来说并不是清晰易懂的知识,这种分裂可能导致了额外的混淆和学习障碍。

  • 自 2022 年 6 月以来 React 不仅没有新的发布,还鼓励开发者使用上层框架,而这些上层框架不等 RSC 升级成稳定版,就发布了基于 RSC 的特性(就差点名 Next.js 了)。

  • React 近些年有成员加入其他上层框架的团队,不仅疏于更新版本,还疏于更新文档。


React Query 的开发者 Tanner Linsley 也对 React 的发展表达了担忧和不满:



  • 自从 React 引入 hooks 和 suspense API 以来,React 过分专注于少数几个概念,这些新概念虽然在技术上推动了单线程 UI API 的极限和边界,但对他日常为用户提供价值的工作影响甚微。

  • 从 RSC 发布看出来,React 团队对客户端性能已经没有那么强烈的追求了。


地图技术和可视化技术专家 Tom MacWright 对 React 生态系统的分裂进行了批评:



  • 当前 React 更新缓慢,反而说两个上层框架Remix(由 Shopify 资助)和 Next.js(由 Vercel 资助)在激烈竞争。

  • React 团队和 Next.js 团队交集过多,让 Vercel 获得了领先优势,那些不属于 Vercel 和 Facebook 生态系统的其他框架,如 Remix,它们会受到 React 中已修复但未发布错误的影响。


社区里积极的态度


面对社区里越来越多的反对声音,React 主要贡献者 Dan Abramov 也多次发表里自己看法,他对技术的变革持开放态度:



  • Next.js 的 App Router 有着雄心壮志,但是现在还是发展初期,未来会迭代得更优秀。

  • 客户端组件的工作是 UI = f(state),服务端组件的工作是 UI = f(data),React 希望组合二者的优势,实现 UI = f(data, state),他号召社区共同推动实现这一目标。

  • 对于 Next.js 把 RSC 发布到生产版本,Dan 认为“生产就绪”是一个主观的判断,虽然 RSC 还是金丝雀版本,但是 Facebook 也已经大量使用了。他认为在实践中验证才能更快完善技术,最终达到成熟和稳定。

  • 新技术的发展是一个渐进的过程,涉及到不断的测试、反馈和迭代,社区的力量非常重要。


总的来说,Dan 是希望大家放下偏见,共同在实践中摸索出 React 下一阶段的变革。


我的观点


在 RSC 的讨论中,我比较认同 Dan 提出的开放和包容性的观点。我认为,面对技术的发展,要抛弃个人偏见,可以实践验证,也可以持续观察它们的发展。只有心态上拥抱变革,开发者才能在变革中找到机遇。


RSC 在提升现代 Web 应用开发绝对是有积极意义的,最显而易见的优势是它可以提高大型应用的性能、减少客户端负载、优化数据获取流程等,通过 RSC 完成这些工作会比以往的 SSR 方案要更加方便。


随着 Node v20 的发布和 RSC 的应用,前端和服务端的距离进一步缩小,我们有机会见证前端工作“后端化”——前端工程师会处理更多传统上属于后端的工作,如数据查询优化、服务器资源管理等。这实际上为前端工程师打开了一扇门,让我们有机会更全面地掌握整个 web 应用的开发流程,也就是我们常说的“全栈开发”。这样的转变势必会提高前端的职业天花板和扩大前端工作的广度。


作者:BigYe程普
来源:juejin.cn/post/7330602636934774823
收起阅读 »

React 19 发布在即,抢先学习一下新特性

web
React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧。 在社区不满的声音越来越...
继续阅读 »

React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧


在社区不满的声音越来越大的背景下,React 新版本的消息终于来了。


React 团队也回应了迟迟未发布新的正式版本的质疑:此前发布到 Canary 版本的多项特性,因为这些特性是相互关联的,所以 React 团队需要投入大量时间确保它们能够协同工作,然后才能逐步发布到 Stable 版本。


事实也确实如此,虽然在这将近两年的时间里 React 没有发布正式版本,但是 Canary 却有一些重磅更新,例如:useuseOptimistic hook,use clientuse server 指令。这些更新客观上丰富了 React 生态系统,特别是推动了 Next.js 和 Remix 等全栈框架的高速发展。


React 团队已经确定,下一个版本将是大版本号,即版本号会是 19.0.0。


v19 新特性预测


现在,让我们根据 React 团队最新发布的消息,来抢先学习一下 v19 版本可能正式发布的新特性。


自动记忆化


你是否还记得 React Conf 2021 上黄玄介绍的 React Forget?




现在,它来了。


它是一个编译器,目前已经在 instagram 的生产环境中应用,React 团队计划在 Meta 的更多平台中应用,并且未来会进行开源发布。


在使用新编译器以前,我们使用 useMemouseCallbackmemo 来手动缓存状态,以减少不必要的重新渲染,这种实现方式虽然可行,但 React 团队认为这并不是他们认为理想的方式,他们一直寻找让 React 在状态变化时自动且只重新渲染必要部分的方案。经过多年的攻坚,现在新的编译器成功落地了。


新的 React 编译器会是一个开箱即用的特性,对开发者来说是又一次开发范式的改变,这也是 v19 最让人期待的功能。


好玩的是,React 团队在介绍新编译器时完全没有提到“React Forget”,这也让好事的网友爆梗了:They forget React Forget & forget to mentioned Forget in the Forget section.🤣


Actions


React Actions 是 React 团队在探索客户端向服务器发送数据的解决方案过程中发展出来的,这个功能允许开发者向 DOM 元素(如 <form/>)传递一个函数:

<form action={search}>
<input name="query" />
<button type="submit">Search</button>
</form>

action 函数可以同步或异步操作。使用 action 时,React 将为开发者管理数据提交的生命周期,我们可以通过 useFormStatususeFormState 这两个 hook 来访问表单操作的当前状态和响应。


action 可以在执行数据库变更(如增加、删除、更新数据)和实现表单(如登录表单、注册表单)等客户端到服务器交互的场景中使用。


action 不仅可以与 useFormStatususeFormState 结合使用,还可以用与 useOptimisticuse server 结合使用。详细展开篇幅就会很长了,你可以关注我,很快我会单独写一篇文章介绍 action 的详细用法。


指令:use client 与 use server


use clientuse server 两个指令在 Canary 版本发布已久,终于也要在 v19 版本里加入 Stable 版本了。


此前社区频频有人因为 Next.js 在生产环境使用这两个指令而指责 Next.js 在破坏 React 生态、批评 React 团队纵容 Next.js 超前使用非稳定特性。其实大可不必,因为这两个指令就是为 Next.js 和 Remix 这样的全栈框架设计的,短期内普通开发者使用 React 开发应用几乎不会用到它们。


如果你是使用 React,而不是使用全栈框架,你只需要了解这两个指令的作用即可:use clientuse server 标记了前端和服务端两个环境的“分割点”,use client 指示打包工具生成一个 <script> 标签,而 use server 告诉打包工具生成一个POST端点。这两个指令能够让开发者在一份文件里同时写客户端代码和服务端代码。



💡 如果你对这两个指令感兴趣,可以来看我的另一篇文章:「🌍NextJS v13服务端组件和客户端组件及最佳实践



useOptimistic 乐观更新



💡 乐观更新:是一种在前端开发中常用的处理异步操作反馈的策略。它基于一种“乐观”的假设:即假设无论我们向服务器发送什么请求,这些操作都将成功执行,因此在得到服务器响应之前,我们就提前在用户界面上渲染这些改变。

使用场景:点赞、评论、任务添加编辑等。



useOptimistic 是一个新的 hook,很可能在 v19 版本中被标记为稳定版。useOptimistic 允许你在异步操作(如网络请求)进行时,乐观地更新 UI。它通过接受当前状态和一个更新函数作为参数,返回一个在异步操作期间可能会有所不同的状态副本。你需要提供一个函数,这个函数接收当前状态和操作的输入,并返回在操作等待期间使用的乐观状态。


它的用法定义如下:

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

// or

const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state with optimistic value
}
);

参数



  • state: 初始状态值,以及在没有操作进行时返回的值。

  • updateFn(currentState, optimisticValue) : 一个函数,接收当前状态和传递给 addOptimistic 的乐观值,返回结果乐观状态。updateFn 接收两个参数:currentStateoptimisticValue。返回值将是 currentStateoptimisticValue 的合并值。


返回值



  • optimisticState: 产生的乐观状态。当有操作正在进行,它等于 updateFn 返回的值,没有操作正在进行,它等于 state

  • addOptimistic: 这是在进行乐观更新时调用的调度函数。它接受一个参数 optimisticValue(任意类型),并调用带有 stateoptimisticValueupdateFn


更详细的用法如下:

import { useOptimistic } from 'react';

function AppContainer() {
const [state, setState] = useState(initialState); // 假设有一个初始状态
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// 合并返回:新状态、乐观值
return { ...currentState, ...optimisticValue };
}
);

// 假设有一个异步操作,如提交表单
function handleSubmit(data) {
// 在实际数据提交前,使用乐观更新
addOptimistic({ data: 'optimistic data' });

// 然后执行异步操作
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(response => response.json())
.then(realData => {
// 使用实际数据更新状态
setState(prevState => ({ ...prevState, data: realData }));
});
}

return (
// 使用 optimisticState 来渲染 UI
<div>{optimisticState.data}</div>
);
}



useOptimistic 会在异步操作进行时先渲染预期的结果,等到异步操作完成,状态更新后,再渲染真实的返回结果(无论成功和失败)。


其它更新


除此之外,React 团队成员 Andrew Clark 还透露2024年还会有以下变化:



  • forwardRef → ref is a prop:简化对子组件内部元素或组件的引用方式,使 ref 作为一个普通的prop传递

  • React.lazy → RSC, promise-as-child:增强了代码分割和懒加载能力

  • useContext → use(Context):提供一种新的方式来访问 Context

  • throw promise → use(promise):改进异步数据加载的处理方式

  • <Context.Provider> → <Context>:简化了上下文提供者的使用


但目前 React 官网没有对以上潜在更新提供详细的信息。


总结


React 的愿景很大,他们希望打破前端和后端的边界,在维持自身客户端能力优势的基础上,同时为社区的全栈框架提供基建。我非常认可他们的做法,因为打破了端的边界,才能帮助前端工程师打破职业天花板。


React 19 会是引入 hooks 之后又一次里程碑式版本,Andrew Clark 说新版本将在 3 月或 4 月发布,让我们拭目以待!


作者:BigYe程普
来源:juejin.cn/post/7339221543992426559
收起阅读 »

MyBatis-Plus快速入门指南:零基础学习也能轻松上手

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。那么,MyBatis-Plus到底是什么?又该...
继续阅读 »

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。

那么,MyBatis-Plus到底是什么?又该如何快速入门呢?让我们一起探索这个强大的工具。

一、MyBatis-Plus简介

1、简介

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Description

2、特性

无侵入: 只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑。

损耗小: 启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作,BaseMapper。

强大的 CRUD 操作: 内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求,简单的CRUD操作不用自己编写。

支持 Lambda 形式调用: 通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错。

支持主键自动生成: 支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题。

支持 ActiveRecord 模式: 支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作。

支持自定义全局通用操作: 支持全局通用方法注入( Write once, use anywhere )。

内置代码生成器: 采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用(自动生成代码)。

内置分页插件: 基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。

分页插件支持多种数据库: 支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库。

内置性能分析插件: 可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询。

内置全局拦截插件: 提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作。

3、框架结构

Description

二、快速入门

1.开发环境

2.创建数据库和表

1)创建表单

CREATE DATABASE `mp_study` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
use `mp_study`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2)添加数据

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

3. 创建SpringBoot工程

1)初始化工程

2)导入依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

4. 编写代码

1)配置application.yml

# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 1234

2)启动类

在Spring Boot启动类中添加@MapperScan注解,扫描mapper包

@MapperScan("cn.frozenpenguin.mapper")
@SpringBootApplication
public class MybatisPlusStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisPlusStudyApplication.class, args);
}
}

3)添加实体类

@Data//lombok注解
public class User {
private Long id;
private String name;
private Integer age;
private String email;
}

4)添加mapper
BaseMapper是MyBatis-Plus提供的模板mapper,其中包含了基本的CRUD方法,泛型为操作的实体类型

public interface UserMapper extends BaseMapper<User> {
}

5)测试

@Autowired
private UserMapper userMapper;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

结果
Description
注意:

IDEA在 userMapper 处报错,因为找不到注入的对象,因为类是动态创建的,但是程序可以正确执行。为了避免报错,可以在mapper接口上添加 @Repository注解。

6)添加日志

我们所有的sql现在是不可见的,我们希望知道它是怎么执行的,所以我们必须要看日志!

在application.yml中配置日志输出

# 配置日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations:

三、基本CRUD

1.插入

 @Test
void insert()
User user = new User(null, "lisi", 2, "aaa@qq.com");
int insert = userMapper.insert(user);
System.out.println("受影响行数"+insert);
//1511332162436071425
System.out.println(user.getId());
}

id设置为null,却插入了1511332162436071425,这是因为MyBatis-Plus在实现插入数据时,会默认基于雪花算法的策略生成id。

2.删除

1)通过id删除记录

@Test
void testDeleteById(){
//DELETE FROM user WHERE id=?
int result = userMapper.deleteById(1);
System.out.println("受影响行数:"+result);
}
  1. 通过id批量删除记录
@Test
void testDeleteBatchIds(){
//DELETE FROM user WHERE id IN ( ? , ? , ? )
int result = userMapper.deleteBatchIds(ids);
System.out.println("受影响行数:"+result);
}
  1. 通过map条件删除记录
@Test
void testDeleteByMap(){
//DELETE FROM user WHERE name = ? AND age = ?
Map<String,Object> map=new HashMap<>();
map.put("age",12);
map.put("name","lisi");
int result = userMapper.deleteByMap(map);
System.out.println("受影响行数:"+result);
}

3. 修改

@Test
void testUpdateById(){
//SELECT id,name,age,email FROM user WHERE id=?
User user = new User(10L, "hello", 12, null);
int result = userMapper.updateById(user);
//注意:updateById参数是一个对象
System.out.println("受影响行数:"+result);
}

4.自动填充

  • 创建时间、修改时间!这些个操作都是自动化完成的,我们不希望手动更新!

  • 阿里巴巴开发手册:所有的数据库表:gmt_create、gmr_modified、几乎所有的表都要配置上!而且需要自动化!

方式一:数据库级别(工作中不允许修改数据库)

1)在表中新增字段 create_time, update_time;

Description

2)再次测试插入方法,我们需要先把实体类同步!

3)再次更新查看结果即可。

Description

方式二:代码级别

  • 删除数据库的默认值,更新操作

  • 实体类的字段属性上需要加注解

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
  • 编写处理器处理注解
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐使用)
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}


@Override
public void updateFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐)
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
  • 测试插入

  • 测试更新、观察时间即可

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,

点击这里,立即开始你的学习之旅!

5.查询

  • 与查询基本一致;

  • 根据id查询用户信息;

  • 根据多个id查询多个用户信息;

  • 通过map条件查询用户信息;

  • 查询所有数据;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

通过观察BaseMapper中的方法,大多方法中都有Wrapper类型的形参,此为条件构造器,可针 对于SQL语句设置不同的条件,若没有条件,则可以为该形参赋值null,即查询(删除/修改)所有数据。

6.通用Service

说明:

  • 通用 Service CRUD 封装IService接口,进一步封装 CRUD;

  • 采用 get 查询单行;

  • remove 删除;

  • list 查询集合;

  • page 分页;

  • 前缀命名方式区分 Mapper 层避免混淆;

  • 泛型 T 为任意实体对象;

  • 建议如果存在自定义通用 Service 方法的可能,请创建自己的 IBaseService 继承 Mybatis-Plus 提供的基类;

  • 官网地址:https://baomidou.com/pages/49cc81/#service-crud-%E6%8E%A5%E5%8F%A3。

1)IService

MyBatis-Plus中有一个接口 IService和其实现类 ServiceImpl,封装了常见的业务层逻辑 详情查看源码IService和ServiceImpl。

2)创建Service接口和实现

/**
* UserService继承IService模板提供的基础功能
*/
public interface UserService extends IService<User> {
}
/**
* ServiceImpl实现了IService,提供了IService中基础功能的实现
* 若ServiceImpl无法满足业务需求,则可以使用自定的UserService定义方法,并在实现类中实现
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

3)测试查询记录数

@Test
void testGetCount(){
long count = userService.count();
System.out.println("总记录数:" + count);
}

4)测试批量插入

@Test
void testSaveBatch(){
// SQL长度有限制,海量数据插入单条SQL无法实行,
// 因此MP将批量插入放在了通用Service中实现,而不是通用Mapper
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User user = new User();
user.setName("lyl"+i);
user.setAge(20+i);
users.add(user);
}
//SQL:INSERT INTO t_user ( username, age ) VALUES ( ?, ? )
userService.saveBatch(users);
}

原理:先把user对象存到list(存在内存中),然后直接save集合list中的所有user。

MyBatis-Plus作为MyBatis的增强版,不仅继承了MyBatis的所有优点,还在此基础上做了大量的改进和扩展。它的出现,无疑为Java开发者提供了一个更为强大、便捷的数据操作工具。

技术的世界总是在不断进步,而我们作为开发者,也需要不断学习新的工具和技术。MyBatis-Plus正是这样一把钥匙,它能打开高效数据操作的大门。希望本文能帮助您快速入门MyBatis-Plus!

收起阅读 »

【HTML】交友软件上照片的遮罩是如何做的

web
笑谈 我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。 调研...
继续阅读 »

笑谈


我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。


调研


市场上这些交友软件比较多,就拿一个我朋友他经常玩的一个软件来研究,叫做《XX之恋》,重申一下,我这里没有任何打广告的嫌疑,毕竟是我朋友玩的,🐶。我们接下来看这软件中遮罩的图片。



注:我实在没有在网上找到该软件这些有遮罩的图片,所以只好从自己的主页上截取了下,如果有当事人认为这是自己的话,请速与我联系,我会及时删除的。




正如上面所见,该软件的遮罩效果还是非常不错的,为什么说非常不错呢?个人认为有两个亮点,🐶保命。



  1. 这个遮罩效果让我们知道对面是女生。

  2. 这个遮罩效果也仅仅只能让我们知道对面是女生。


言归正传,这种效果在我们悠久的前端历史上,有一种专业名词 --> 毛玻璃效果



碎碎念:我看了蛮多毛玻璃的技术文章,这个技术大家说都是为了让能人阅读的时候更赏心悦目,能用来遮小姐姐也算是很不错的创新了。🐶



实现


现在我们只需要两样东西,一个是小姐姐的图片,一个是前端的小知识。我都准备好啦。首先我们先介绍知识点。


backdrop-filter属性


我们看MDN的介绍文档


可以让你为一个元素后面区域添加图形效果(如模糊或颜色偏移)。因为它适用于元素背后的所有元素,为了看到效果,必须使元素或其背景至少部分透明。


backdrop-filter有如下常用属性(带了数值,方便理解)



  • 🌟blur(2px): 对元素背后的背景应用2像素的模糊效果。

  • brightness(60%): 将元素背景的亮度调整为原始亮度的60%。

  • contrast(40%): 将元素背景的对比度调整为原始对比度的40%。

  • ...............等


我们主要使用的便是blur属性,对背景图片模糊,达到类似的效果。


我采用的小姐姐图片如下




思路:使用两个Class。第一个Class的背景图片是上面的小姐姐,第二个Class完全覆盖第一个Class,设置blur10px即可。如下是代码。

效果图如下:





最后,希望大家多多点赞支持,兄弟们的点赞支持是我继续写文章的动力!



作者:鑫宝Code
来源:juejin.cn/post/7333986476030935050
收起阅读 »

软件License授权原理

软件License授权原理 你知道License是如何防止别人破解的吗?本文将介绍License的生成原理,理解了License的授权原理你不但可以防止别人破解你的License,你甚至可以研究别人的License找到它们的漏洞。喜欢本文的朋友建议收藏+关注,...
继续阅读 »

软件License授权原理


你知道License是如何防止别人破解的吗?本文将介绍License的生成原理,理解了License的授权原理你不但可以防止别人破解你的License,你甚至可以研究别人的License找到它们的漏洞。喜欢本文的朋友建议收藏+关注,方便以后复习查阅。


什么是License?


在我们向客户销售商业软件的时候,常常需要对所发布的软件实行一系列管控措施,诸如验证使用者身份、软件是否到期,以及保存版权信息和开发商详情等。考虑到诸多应用场景可能处于离线环境,无法依赖网络进行实时认证,所以还需要考虑单机认证时的防破解问题。总之,License许可证利用HTTPS网站的证书和签名技术,一方面证明当前使用者是申请License的本人,另一方面要防止恶意破解,并伪造篡改License达到白嫖的目的。


image.png


为什么使用License授权?


License的作用是什么呢?收费软件的License其目的肯定是防止用户白嫖啦,所以License还应该具有以下一些功能:



  • 授权使用


明确用户需要满足的使用条件,如单用户、多用户、企业内部使用、全球使用等,并且通常会限定可安装和激活的设备数量。



  • 限制功能


根据不同等级的License,软件可以提供不同等级的功能,例如基础版、专业版、企业版等,License可以解锁相应版本的功能。


image(1).png



  • 期限控制


规定软件的使用期限,可能是永久授权,也可能是订阅式授权,到期后用户需要续费才能继续使用。



  • 版权保护


重申软件的知识产权归属,禁止未经授权的复制、分发、反编译、篡改或逆向工程等侵犯版权的行为。



  • 法律保障


License作为法律合同,确立了软件提供商和用户之间的法律关系,明确了双方的权利和责任,如果发生违反协议的情况,软件提供商有权采取法律手段追究责任。


image(2).png



  • 技术支持和升级服务


部分License中会规定用户是否有权享有免费的技术支持、软件更新和维护服务,以及这些服务的有效期限。



  • 合规性要求


对于特殊行业或特定地区,License可能还涉及到满足特定法规、标准或认证的要求。


归纳起来,我们可以总结出License的作用是:



  • 控制软件使用者的使用权限

  • 申明软件所有者的版权

  • 规定软件的使用规范


最后两点主要是法律相关的,第一点才是本文的重点,即如何生成License,以及如何通过License对软件用户进行限制。


License分类


依据用途的不同,License可分为两大类别:商用License非商用License


非商用License主要服务于诸如展览展示活动、各类研发活动等多种非直接盈利性的应用场景;


商用License,则通常适用于那些开展商业运营活动的企业场所。


image(3).png


基于使用的期限,License可以划分为固定期限License和永久License两类。


固定期限License在激活后的指定时间内有效,过了预设的使用期限,用户必须更新许可期限并通过重新激活才能继续使用;


而永久License则是在激活后赋予用户无时间限制的使用权,一旦激活,无需担忧许可失效的问题,可以无限期地持续使用软件。


如何实现License授权?


要想生成一个安全性高的License,必须让其满足以下几个特征:



  • 保密性

  • 防篡改

  • 时效性

  • 可找回


保密性是指License里携带的data信息具有一定的隐蔽性,这样可以防止想要破解License的人寻找到生成License的规律,进而伪造自己的License。


防篡改是指防止License里携带的重要信息被篡改,例如License有效时间如果被篡改,那么License就起不到限制用户使用期限的作用了。


时效性是指License会记录软件可以使用的有效期,并在验证License的时候判断其是否过期。


可找回是指用户申请的License一旦丢失或者要续期,基于第一次申请License时创建的源文件,再一次生成新的License,新的License会携带用户当初申请时的信息。


由于License必须满足以上特性,所以在介绍License实现原理之前,我们先来学习一下非对称加密和签名&验签。


非对称加密


有非对称加密必然就会有对称加密,对称加密就是我们一般意义上的加密算法,这种算法在加密和解密时都使用同一个密钥,所以对称加密算法的密钥又叫做共享密钥。对称加密算法一般使用AES(Advanced Encryption Standard)加密算法。


image(4).png


非对称加密有两个密钥,一个公钥一个私钥。公钥是公开的,供多个人使用;私钥是非公开的,仅一个人或者少数群体使用。当非对称加密算法用作加解密时,公钥用来对明文加密,私钥用来给密文解密,这个顺序不能颠倒。你可以这样理解,密文是私密的东西,只有少数人才能解密,所以少数人手里的私钥用来解密,多数人手里的公钥不能解密只能加密。


image(5).png


为什么要区分公钥和私钥呢?直接使用一个共享密钥不行吗?可以,但是前提是你能够安全的将共享密钥传递给对方。共享密钥如何在线上安全的同步给对方是一个问题,毕竟在网络上传输信息很容易暴露。如果使用非对称密钥就可以将公钥同步给消息发送者,而消息接收者则保留私钥用来解密消息,这样即使公钥被中间人盗取,他也只能用来做加密操作而不能解密密文。


签名&验签


虽然非对称加密可以解决“密钥分配问题”,但是它不能防止伪造消息的问题。既然公钥可以公之于众,大家都知道你的消息要怎么加密,假如A想给B发送消息,那么中间人X可不可以将A发送的消息拦截,并将自己的消息加密以后发送给B呢?当然可以!


这就好比你买了一张周杰伦的演唱会的门票,我看到了之后自己伪造了一个一模一样的,如此一来我也可以去看周杰伦的演唱会。这时官方组织者发现了这个漏洞以后,规定周杰伦的演唱会门票需要带上官方印章才能进场,此时我就算把门票画的再惟妙惟肖,少了官方印章,我的这张假门票依然是张废纸。


如何解决这个问题呢?答案就是给你的消息“盖章”,即签名,签名就是认证你的身份。这里还是使用非对称密钥算法,只不过使用的顺序和加密消息时恰好相反。签名时是私钥用来加密,公钥用来解密。


image(6).png


你可以这样理解,给消息签名就好比给文件盖章,你会随随便便把你自己的印章交给别人来使用吗?当然不行!所以公钥不适合用来签名,私钥用作签名更加合理。需要注意的是签名所使用的密钥对由消息发送者生成并提供给消息接收者,这和给消息加密时正好相反,这样说来消息加密和消息签名这两个使用场景就需要生成两套密钥对。


1111_waifu2x_photo_noise1_scale.png


出于性能方面的考虑,大多数情况下给消息加密还是使用的对称加密算法,为了解决“密钥分配问题”,只会在第一次发送共享密钥的时候才会使用非对称加密,一旦消息接收者得到了共享密钥,通信双方就能够通过共享密钥进行通信了。


此外,使用非对称密钥对消息签名也可以防止消息被人篡改,由于性能原因一般不会对消息原文进行签名,而是先通过哈希算法形成消息摘要,再对消息摘要签名。消息接收者验签时会将消息的明文进行哈希,再将消息签名解密,两者比对如果一致则证明消息没有被篡改过。


License结构


前面铺垫了一些生成License所必备的基础知识,我们学习了生成的License如果需要防止被人破解,那就需要具有保密性、防篡改和防伪造等特点。接下来要考虑的是License需要携带什么信息就能让其既安全又能限制用户的使用权限。


License文件理论上来说至少需要以下一些信息:



  • 软件所有者信息

  • 申请授权时间

  • 授权截止时间

  • 软件使用者信息


下图是License文件流的结构图,主要字段有:



  • 魔数值

  • 分隔符

  • 申请时间

  • 到期时间

  • 公钥的长度 & 公钥

  • 携带信息的长度 & 携带信息


安全算法总结-导出(4).png



  • 魔数值:和Java Class文件头的魔数CAFEBABE类似,License文件头的魔数也是起到了快速识别的作用,也有格式验证的作用。

  • 分隔符:用来区分各个字段,将字段之间用分隔符隔开便于结构化管理。

  • 申请时间:用户申请License的日期。

  • 到期时间:License的有效截止日期。

  • 公钥的长度 & 公钥:公钥长度用来记录公钥是多少字节,依据公钥长度就可以读取相应长度的公钥数据了。

  • 携带信息的长度 & 携带信息:携带信息长度用来记录携带信息是多少字节,依据携带信息长度就可以读取相应长度的携带信息了。携带信息里通常会包含软件所有者、软件使用者、License唯一ID以及设备MAC地址等信息。


想好了License文件的结构,我们就可以开始生成License啦。


生成License


申请License的总体流程如下图所示。客户在软件服务商处申请License,软件服务商生成License之后会返回给客户License文件,自己保留一份License源文件,源文件用作以后找回License。客户拿到License文件,在安装、启动软件之后激活License。


license_apply.png


生成License主要做了这样几个事情:



  • 对需要携带的信息加密成密文

  • 对密文签名

  • 保存申请日期、有效截止日期和公钥

  • 生成源文件


安全算法总结-导出.png


私钥1加密的作用是对License的安全加固。因为License实际上可以通过Base64解码得到里面的数据,包括公钥信息,这样客户就能够通过公钥将携带的信息解析出来,倘若携带有敏感信息就会造成安全问题。所以这里对携带的信息做了先加密后签名的处理。


另外需要强调的是,申请日期和有效截止日期也需要签名但不需要加密。因为如果不签名的话,客户可以将日期解析出来之后篡改成自己想要的任何日期。


加载License


smart-license.png


客户申请到License之后,就可以去软件上面激活啦。激活License首先判断License是否合法,检查文件头魔数和分隔符是否正确,检查License是否过期等。然后就是提取License的授权信息进行验签比对。如果有必要,还可以检查授权信息里携带的MAC地址是否与安装设备的MAC地址匹配。如果一切正常就可以通过验证。


安全算法总结-导出(1).png


找回License


license_forget.png


防破解


首先需要明确的一点就是,没有万无一失的防破解方案,所谓魔高一尺道高一丈,漏洞堵的再严实依然能找到破解的方法,唯一的区别就是破解的成本高不高而已


例如,具备一定逆向工程经验的程序员都知道,应用程序不仅能够被调试,也能被修改。理论上讲,只要深入探究程序的代码,定位并替换其中嵌入的原始公钥信息,改为自己的公钥。随后,使用个人持有的私钥去创建一个新的授权文件,这样一来,就实现了对软件授权机制的破解。


更简单的方法是直接反编译验证逻辑的代码,当验证的时候直接返回true,即可通过验证。


即使不能做到百分之百的安全性,我们还是应该知道一些防破解的方法,增加用户破解的难度。防破解主要有以下几个方面的问题需要重点限制。



  • 如何解决java代码反编译之后,修改验证License的逻辑?



答:混淆代码,增加反编译的难度。




  • 如何防止客户修改服务器时间以避免License过期?



答:分为离线和在线两种情况。


在线情况下加载License信息时,可以将License里保存的过期时间和线上标准时间做比较


离线情况下,需要满足条件:申请时间 <= 系统时间 <= 截止时间


具体实现方案是,第一次加载License成功之后,将申请时间存到A处;


定时更新A处的时间,更新前比较当前系统时间,如果系统时间 < 申请时间,说明系统时间被篡改过。否则,更新A处时间为当前系统时间;


保存的时间是经过加密的,但是有个问题是如果用户备份了一开始的时间,过了一段时间之后用这个备份文件恢复,再修改系统时间就可以永不过期,如何解决?


可以将A处的时间信息保存到数据库里,数据库权限设置为只有开发人员可以修改,此外数据库安装的机器不能与软件安装的机器相同,否则用户可以将二者统一安装到某一个虚拟机里,快到期的时候再统一恢复到初始时间。


A处除了保存时间以外,还需要License的唯一id、使用License的机器mac地址,这些字段是为了保证License不被重复使用。




  • 如何防止客户在多台服务器上使用同一个License?



答:将服务器的ip或者mac地址与License做绑定关系。



image.png



  • 如何防止用户得到了源文件并获取了私钥,就可以自己伪造License?



答:避免将生成License的代码安装在用户的机器环境下,最好在自己的机器环境下生成License。因为生成License之后得到的源文件一般会保存在代码路径下,如果用户反编译生成License的代码,就能够得到源文件信息。



最后整理了一张泳道图,可以从整体观察一下不限制、防止篡改系统时间和防止多设备共享License等问题的解决方案。


安全算法总结-导出(3).png


作者:IT果果日记
来源:juejin.cn/post/7338723726837465107
收起阅读 »

如果失业了,我们又将何去何从?

经历 先说说自己的经历吧,小编在21年之前在南京一家国企外包工作过;主要做的是国网的项目,那时候工资不高但是福利待遇不错,什么季度奖、项目奖、年终奖没断过,可能日子太过安逸了吧,自己又想挑战一下高薪,于是就跳槽去了一家做法院业务的公司。跳槽的时候是20年,当时...
继续阅读 »

经历


先说说自己的经历吧,小编在21年之前在南京一家国企外包工作过;主要做的是国网的项目,那时候工资不高但是福利待遇不错,什么季度奖、项目奖、年终奖没断过,可能日子太过安逸了吧,自己又想挑战一下高薪,于是就跳槽去了一家做法院业务的公司。跳槽的时候是20年,当时疫情才刚开始,对经济的影响也还好,所以感觉当时找工作也没那么难。又过了1年,由于小编家人在18年的时候就给我在无锡贷款买了房,所以想早点回去发展,也过够了那种挤在出租屋里的感觉,所以我又辞了来了无锡去了一家做云计算的公司,也还算稳定,待了大概有2年多,从去年开始就开始走下坡路了,23年中旬开始大规模裁员,小编不幸中招。

从那家做云计算的公司走了之后我抑郁了半个月,因为半个 月内都找不到工作,当时真的很着急,因为还有房贷要、信用卡、车贷等等这些要还;从第三周开始我就下定决心要好好好背复习,不只是八股文,还有数据结构、算法 、设计模式等等,功夫不负有心人在第三周终于收获2个offer,此时悬着的心终于落了下来,阴差阳错去了一家做医疗的公司做运维监控平台。

后来不知怎么的裁员风波不断,从不少朋友那边收到消息很多公司都在裁员,很快这波风也刮到了我们公司;有一天我邮件收到了三个月试用期转正的邮件,当时还高兴了一下,但是第二天就通知我转正临时取消,没有任何理由 的取消,随后就有人事来通知我去签合同;我纳闷还要签啥合同啊,不过我大概也猜到点了,因为前些天就有小道消息说要延长试用期,我当时还以为是假的。到了人事那边一看是新的劳动合同,一看试用期变成了6个月,人事和我说应公司领导要求试用期需要延长,剩下3个月的公司还是按打八折发,但是转正后一并给我;我当时惊呆了,心想还能有这种操作,不过没办法看着大家都签了也只能签。这个事情开了头之后,后面又小道消息不断,听人说要降薪20%,很多同事听了都不愿意,于是都被一起约了谈了一次话,有些脾气比较爆的直接怼了领导,最后也就不欢而散。年前董事长又召集了我们研发部聊这个降薪的事情,开头先一堆铺垫,说什么今年怎么怎么难,外面都在裁员什么的,最后又说不降20%了,但是还是会扣5%,这个5%看公司运营情况来发放,大家听完虽然还是不情愿,但也没有再多说什么。


现状


这段时间公司一直没有活,领导也没安排,每天来了就往那一坐就没有方向,再也找不到以前工作的那种感觉了;有些人可能觉得没活干还给你发工资不是挺好嘛,但其实不然你仔细想想,没活干代表公司接不到单子,没有单子就代表没有钱,没钱怎么发工资,所以这种状态持续不了多久。不过这种状态迄今为止已经持续1个多月了,年前我也找过几家单位,面试我觉得面得挺好的,但都没有后续了,有的你问人家,人家却说再等等给你答复还有好多人没面呢,我心想这下完了,今年物联网行情太差了,以前随随便便手里拿好几个offer。越是没活干我就越是焦虑,最近这个班上的真的感觉像坐牢一样,不知道有没有和我一样经历的小伙伴,有兴趣的可以私信和我聊一下。


裁员


23年各大互联网公司裁员情况如下:



  • 知乎:裁员约300人

  • 去哪儿:裁员约400人

  • 搜狐:裁员约800人

  • 美团:裁员约900人

  • 途家:裁员约1400人

  • 京东:裁员约1500人

  • 网易:裁员约2000人

  • 58同城:裁员约3000人

  • 阿里:裁员约4000人

  • 百度:裁员约5000人

  • 滴滴:裁员约5000人


寻找方向


小编是一位Java程序员,现在只会Java写写增删改查根本找不到工作(除非学历很优秀),可以说绝大部分的程序员都能满足传统企业的需求;下面是我整理的可以试试去发展的方向,个人理解不喜勿喷,有兴趣的朋友可以一起去探讨。


研发方向



  • 大数据:也比较卷

  • 云计算:容器、容器编排(K8s)、云原生

  • AI大模型:今年的热门,但是不知道怎么去做


其他方向


image.png


如果不做程序员了我们还能做什么


当前的大环境下确实很艰难,说实话如果不做程序员了,我很难立马想到一个能挣到与之相当工资的工作。虽说360行,行行出状元,可哪一行不都是需要经过千锤百炼才能成功的,隔行如隔山,转行又岂是一朝一夕的事情。

现在的市场Java开发已经趋于饱和,和Boss打招呼基本都是已读不回的状态,甚至面试机会都没有,有的就算面得再好也没用,因为还有一堆人没面跟你竞争,有的期望薪资低,有的学历比你好,有的到岗时间比你早,对此我真的很无奈,快卷不动了。


跑滴滴


由于我经常跑扬州,走高速成本太高了,于是我就注册了个顺风车,想着能回一点成本,基本上去一趟拉一单有100多,来回基本上和成本抵消了。后来我就想着通过顺风车偶尔做做兼职能赚点钱,于是下了班就跑了几回,发现在市区跑订单金额很低,后来跑了几回也就放弃了;后来想注册专车司机,于是就找朋友了解了一下 ,发现注册了滴滴只会让你跑一段时间,然后就要办那个营运证,而且也不适合兼职做,目前还有工作,也许真的哪天失业了我会考虑全职做这个吧。从我朋友那边了解到,滴滴也很卷,每天起早贪黑的,给你派了单子只能被动接受,不像顺风车可以主动选择,不过真到没工作那一步也只能选择尝试一下。


开店


小编比较幸运,在老家小镇还有一个小店面,不过大家也可以租一个,只要做起来了都是一样的,关键是要能把生意搞起来。我其实早就萌生了这个想法,但是我现在才临近30岁,还想在外面再闯一下。


食品小超市


为什么想开这个呢,主要我发现了一个路子,哈哈!我老家有个很有名的食品超市,也是从小店一点一点做起来的,而且我还知道他是去无锡金桥食品批发市场批发的,国产的、进口的食品全都有。今年过年的时候去他们家买东西人都爆满,老板在收银台堆满了车厘子,2J、3J、4J的车厘子卖的好的不行,我在那边没一会就卖了一幢(车厘子堆起来的,堆了一幢),后来听别人说这个老板过个年净利润有四五十万。在我老家那边过个年能赚个四五十万万真的可以吓死人,于是乎开食品超市的想法在我心中萌芽。不过还好我们家那个店铺和他们家靠的不是很近,再开一家也不是不行,再不济也可以考虑和他加盟。

大家可以观察一下老家有没有那种食品超市的店,店里卖的都是比较中高档的吃的,周围人群比较密集的地方可以考虑开一个,只要找到货源能持续供货,这个店应该很容易就能开起来。


电脑店


我们家那个店面我也想过开个电脑店,卖卖电脑、装装系统、装装摄像头什么的,我感觉也不错,要说修电脑硬件我还是没那个本事的,不过可以带到城里面去修。


摆美食小摊


我想着等我年纪再大一点,可以搞个小美食摊,可以做做煎饼、鸡蛋饼、臭豆腐、烤香肠这种,不过不能在城里搞,只能去乡下镇上,城里面城管管得严,不是很好搞。


做短视频


这个想法我也考虑了很久,至今还没开始实践,因为我老是怕我做不好,作为程序员,视频剪辑这种我应该是一学就会。其实我主要就是觉得没有赶上风口,现在做短视频的搞直播的一大堆,没有什么吸引流量的创意真的很难博人眼球。后面我打算尝试尝试记录生活,生活琐事都拍一下,比如出去露营、钓鱼、旅行,在家做饭、健身什么的都可以拍一下,不过我听朋友说要专注拍一类才能拍好,也不知道是不是真的。


总结


给大家总结一下就是今年能不跳槽就尽量不要跳槽,绝对不能裸辞,在今年这个风口浪尖上各大企业都在人员优化、降本增效。欢迎大家多多留言,提一些建议,最后也祝大家龙年大运,在新的一年里找到自己满意的工作。


作者:MrDong先生
来源:juejin.cn/post/7338307026245845044
收起阅读 »

请立即停止编写 Dockerfiles 并使用 docker init

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者: Akhilesh Mishra 您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗? 我承认,我就是...
继续阅读 »

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者:

Akhilesh Mishra


您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗?



我承认,我就是其中之一。



我总是想知道我是否遵循了 Dockerfile、 docker-compose 文件的最佳编写实践,我害怕在不知不觉中引入了安全漏洞。


但是现在,我不必再担心这个问题了,感谢 Docker 的优秀开发人员,他们结合了生成式人工智能,创建了一个 CLI 实用工具 — docker init。


介绍 docker init


微信截图_20240224145630.png


几天前,Docker 推出了 docker init 的通用版本。我已经尝试过,发现它非常有用,迫不及待地想在日常生活中使用它。


什么是 docker init?


docker init 是一个命令行应用程序,可帮助初始化项目中的 Docker 资源。它根据项目的要求创建 Dockerfiles、docker-compose 文件和 .dockerignore 文件。


这简化了为项目配置 Docker 的过程,节省时间并降低复杂性。



最新版本的 docker init 支持 Go、Python、Node.js、Rust、ASP.NET、PHP 和 Java。目前它只能于 Docker Desktop 一起使用,也就是说大家目前在 Linux 系统中是无法使用 docker init 的。



如何使用 docker init?


使用 docker init 很简单,只需几个简单的步骤。首先,转到您要在其中设置 Docker 资源的项目目录。


举个例子,我来创建一个基本的 Flask 应用程序。


一、创建 app.py 以及 requirements.txt


touch app.py requirements.txt

将以下代码复制到相应文件中


# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_docker():
return '<h1> hello world </h1'

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

# requirements.txt
Flask

二、使用 docker init 初始化


docker init 将扫描您的项目并要求您确认并选择最适合您的应用程序的模板。选择模板后,docker init 会要求您提供一些特定于项目的信息,自动为您的项目生成必要的 Docker 资源。


现在让我们来执行 docker init。


docker init

出现如下结果,



接下来要做的就是选择应用程序平台,在我们的示例中,我们使用 python。它将建议您的项目的推荐值,例如 Python 版本、端口、入口点命令。



您可以选择默认值或提供所需的值,它将创建您的 docker 配置文件以及动态运行应用程序的说明。



让我们来看看这个自动生成的配置是什么样子。


三、生成 Dockerfile 文件


# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/

ARG PYTHON_VERSION=3.11.7
FROM python:${PYTHON_VERSION}-slim as base

# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1

# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them int0
# int0 this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt

# Switch to the non-privileged user to run the application.
USER appuser

# Copy the source code int0 the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 5000

# Run the application.
CMD gunicorn 'app:app' --bind=0.0.0.0:5000

看看它,它写了一个比我更好的 Dockerfile。



它遵循人们在所有 Linkedin 和 Medium 帖子中不断告诉我们的所有性能和安全最佳实践。



docker-compose.yml



它编写了 docker-compose 配置来运行应用程序。由于我们的应用程序不包含与数据库的任何连接,因此它注释掉了数据库容器可能需要的代码。


如果您想在 Flask 应用程序中使用数据库,请从 docker-compose 文件中取消注释 db 服务配置,创建一个包含机密的本地文件,然后运行该应用程序。它还为我们生成了 .dockerignore 文件。


为什么使用 docker init?


docker init 使 Docker 化变得轻而易举,特别是对于 Docker 新手来说。它消除了编写 Dockerfile 和其他配置文件的手动任务,从而节省时间并最大限度地减少错误。


它使用模板根据您的应用程序类型自定义 Docker 设置,同时遵循行业最佳实践。


总结一下


总而言之,docker init 完成了上面这一切。



  • 它可以编写比这里 90% 的孩子更好的 Docker 配置。

  • 像书呆子一样遵循最佳实践。

  • 当安全人员的工具生成包含数百个您从未想过存在的漏洞的报告时,可以节省时间、精力和来自安全人员的讽刺评论。


最后需要说明的是,就像任何其他基于人工智能的工具一样,这个工具也不完美。不要盲目相信它生成的配置。我建议您在使用配置之前再次检查下配置。



如果觉得这篇文章翻译不错的话,不妨点赞加关注,我会更新更多技术干货、项目教学、经验分享的文章。



作者:程序员wayn
来源:juejin.cn/post/7338717224435531826
收起阅读 »

面试官:实现一个吸附在键盘上的输入框

web
实现效果 话不多说,先上效果和 demo 地址: demo 地址:codesandbox.io/p/devbox/ke… 体验地址:7fsqr8-5173.csb.app 实现原理 要实现一个吸附在键盘上的 input,可以分为以下步骤: 监听键盘高度...
继续阅读 »

实现效果


话不多说,先上效果和 demo 地址:



demo 地址:codesandbox.io/p/devbox/ke…

体验地址:7fsqr8-5173.csb.app



666.gif


实现原理


要实现一个吸附在键盘上的 input,可以分为以下步骤:



  1. 监听键盘高度的变化

  2. 获取「键盘顶部距离视口顶部的高度」

  3. 设置 input 的位置


第一步:监听监听键盘键盘高度的变化


要监听键盘高度的变化,我们得先看看在键盘展开或收起的时候,分别会触发哪些浏览器事件:



  • iOS 和部分 Android 浏览器


    展开:键盘展示时会依次触发 visualViewport resize -> focusin -> visualViewport scroll,部分情况下手动调用 input.focus 不触发 focusin


    收起:键盘收起时会依次触发 visualViewport resize -> focusout -> visualViewport scroll


  • 其他 Android 浏览器


    展开:键盘展示的时候会触发一段连续的 window resize,约过 200 毫秒稳定


    收起:键盘收起的时候会触发一段连续的 window resize,约过 200 毫秒稳定,但是部分手机上有些异常的 case:键盘收起时 viewport 会先变小,然后变大,最后再变小



总结两者来看,我们要监听键盘高度的变化,可以添加以下监听事件:


if (window.visualViewport) {
 window.visualViewport?.addEventListener("resize", listener);
 window.visualViewport?.addEventListener("scroll", listener);
} else {
 window.addEventListener("resize", listener);
}

window.addEventListener("focusin", listener);
window.addEventListener("focusout", listener);

===========================


📚 题外话: 获取键盘展开和收起状态


===========================


在实际业务中,获取键盘展开和收起的状态,同样很常见,要完成状态的判断,我们可以设定以下规则:


判断键盘展开:当 visualViewport resize/window.reszie、visualViewport scroll、focusin 任意一个事件触发时,如果高度减少,并且屏幕减少的高度(键盘高度)大于 200px 时,判断键盘为展开状态(由于 focusin 部分情况下不触发,所以还需要监听其他事件辅助判断键盘是否为展开状态)


判断键盘收起:当 visualViewport resize/window.reszie、visualViewport scroll、focusout 任意一个事件触发时,如果高度增加,并且屏幕减少的高度(键盘高度)小于 200px,判断键盘为收起状态


// 获取当前视口高度
const height = window.visualViewport
? window.visualViewport.height
: window.innerHeight;

// 获取视口增量:视口高度 - 上次获取的视口高度
const diffHeight = height - lastWinHeight;

// 获取键盘高度:默认屏幕高度 - 当前视口高度
const keyboardHeight = DEFAULT_HEIGHT - height;

// 如果高度减少,且键盘高度大于 200,则视为键盘弹起
if (diffHeight < 0 && keyboardHeight > 200) {
   onKeyboardShow();
} else if (diff > 0) {
   onKeyboardHide();
}

同时,为了避免 “收起时 viewport 会先变小,然后变大,最后再变小” 这种情况,我们需要在展开收起状态发生变化的时候加一个200毫秒的防抖,避免键盘状态频繁改变执行“收起 -> 展开 -> 收起”的逻辑


let canChangeStatus = true;

function onKeyboardShow({ height, top }) {
   if (canChangeStatus) {
     canChangeStatus = false;
     setTimeout(() => {
callback();
        canChangeStatus = true;
    }, 200);
  }
}

第二步:获取键盘顶部距离视口顶部的高度


在 safari 浏览器或者部分安卓手机的浏览器中,在点击输入框的时候,可以看到页面会滚动到输入框所在位置(这是想让被软键盘遮挡的部分展示出来),这个时候,其实是触发了虚拟视口 visualViewport 的 scroll 事件,让页面整体往上顶,即使是 fixed 定位也不例外,因此要获取「键盘顶部距离视口顶部的高度」,我们需要进行如下计算:


键盘顶部距离视口顶部的高度 = 视口当前的高度 + 视口滚动上去高度


// 获取当前视口高度
const height = window.visualViewport ? window.visualViewport.height : window.innerHeight;
// 获取视口滚动高度
const viewportScrollTop = window.visualViewport?.pageTop || 0;
// 获取键盘顶部距离视口顶部的距离,这里是关键
const keyboardTop = height + viewportScrollTop;

第三步:设置 input 的位置


我们先设置 input 的 css 样式


input {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 50px;
transition: all .3s;
}

然后再动态调整 input 的 translateY,让 input 可以配合键盘移动,为了保证 input 能够露出,还需要用上一步计算好的「键盘距离页面顶部高度」再减去「元素高度」,从而获得「当前元素的位移」:


当前元素的位移 = 键盘距离页面顶部高度 - 元素高度


// input 的 position 为 absolute、top 为 0
keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

实现原理是不是很简单?不如来看看完整代码吧~


完整代码


import EventEmitter from "eventemitter3";

// 默认屏幕高度
const DEFAULT_HEIGHT = window.innerHeight;
const MIN_KEYBOARD_HEIGHT = 200;

// 键盘事件
export enum KeyboardEvent {
 Show = "Show",
 Hide = "Hide",
 PositionChange = "PositionChange",
}

interface KeyboardInfo {
 height: number;
 top: number;
}

class KeyboardObserver extends EventEmitter {
 inited = false;
 lastWinHeight = DEFAULT_HEIGHT;
 canChangeStatus = true;

 _unbind = () => {};

 // 键盘初始化
 init() {
   if (this.inited) {
     return;
  }
   
   const listener = () => this.adjustPos();

   if (window.visualViewport) {
     window.visualViewport?.addEventListener("resize", listener);
     window.visualViewport?.addEventListener("scroll", listener);
  } else {
     window.addEventListener("resize", listener);
  }

   window.addEventListener("focusin", listener);
   window.addEventListener("focusout", listener);

   this._unbind = () => {
     if (window.visualViewport) {
       window.visualViewport?.removeEventListener("resize", listener);
       window.visualViewport?.removeEventListener("scroll", listener);
    } else {
       window.removeEventListener("resize", listener);
    }

     window.removeEventListener("focusin", listener);
     window.removeEventListener("focusout", listener);
  };
   
   this.inited = true;
}

// 解绑事件
 unbind() {
   this._unbind();
this.inited = false;
}

 // 调整键盘位置
 adjustPos() {
   // 获取当前视口高度
   const height = window.visualViewport
     ? window.visualViewport.height
    : window.innerHeight;

   // 获取键盘高度
   const keyboardHeight = DEFAULT_HEIGHT - height;
   
   // 获取键盘顶部距离视口顶部的距离
   const top = height + (window.visualViewport?.pageTop || 0);

   this.emit(KeyboardEvent.PositionChange, { top });

   // 与上一次计算的屏幕高度的差值
   const diffHeight = height - this.lastWinHeight;

   this.lastWinHeight = height;

   // 如果高度减少,且减少高度大于 200,则视为键盘弹起
   if (diffHeight < 0 && keyboardHeight > MIN_KEYBOARD_HEIGHT) {
     this.onKeyboardShow({ height: keyboardHeight, top });
  } else if (diffHeight > 0) {
     this.onKeyboardHide({ height: keyboardHeight, top });
  }
}

 onKeyboardShow({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Show, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 onKeyboardHide({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Hide, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 setStatus() {
   const timer = setTimeout(() => {
     clearTimeout(timer);
     this.canChangeStatus = true;
  }, 300);
}
}

const keyboardObserver = new KeyboardObserver();

export default keyboardObserver;


使用:


keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

作者:DAHUIAAAAAA
来源:juejin.cn/post/7338335869709385780
收起阅读 »

最新 GitHub 骗局!千万别中招!

今天一早,焚香沐浴更衣,打开全球最大同性交友网站,准备好好摸鱼;突然方向通知多了一条: 正疑惑是触发是 GitHub 的什么隐藏关卡呢,点进去一看: 一个 21 年的创建 issue?但是有个新的评论: 这个评论的大意是: 喂!x毛! GitHub 瞎...
继续阅读 »

今天一早,焚香沐浴更衣,打开全球最大同性交友网站,准备好好摸鱼;突然方向通知多了一条:


image.png


正疑惑是触发是 GitHub 的什么隐藏关卡呢,点进去一看:


image.png


一个 21 年的创建 issue?但是有个新的评论:


image.png


这个评论的大意是:



喂!x毛!
GitHub 瞎了眼相中你了,有个很适合你的职位,年薪高达 18w 刀乐!
赶紧来申请啊,各种福利各种巴适!
但是有记得在 24 小时内点击这个链接来申请哦!过时不候!
后面芭啦芭啦@了一大堆人,其中我的用户名赫然在列!



他真的!我哭死!原来天上真的会掉馅阱 😢...


但是仔细一看,评论的这个人,是默认头像。不对劲!非常不对劲!遂点进其主页一看:


image.png


啥也没有...


这时候事情就很明显了,然后我就去 GitHub 社区找了一下相关的反馈,果不其然,两天前开始有人在反馈相关问题:


image.png



原讨论传送门:github.com/orgs/commun…



于是笔者在隐私模式下打开@我的那个评论附上的链接:


image.png


这个页面会请求你使用 GitHub 授权登录,并且要求你授权各种高级权限;而一旦你授权了,大概率会发生的第一件事,就是你的帐号会在各种 issue 中发布上面那条“GitHub 求职骗局”的评论,以导致更多的人受骗...



截至笔者写下这篇水文时,该钓鱼网站链接已经无法打开。



而在早上笔者在 github.com/orgs/commun… 中留下评论后,陆陆续续又有上百个全球各地的开发者进行了反馈。甚至有领先一步的好哥们已经直接出手向域名注册商、域名托管服务商进行了举报,并收到了反馈:


image.png


而这,仅仅是在笔者写下这篇水文前的 23 分钟(一切发生得太快...


不仅如此,在笔者截完本文第三张图后,提及我的那条评论已经删除了 🤪 (一切发生得实在太快...


不不仅如此,在笔者敲完上一句话后打算再次确认一下发出评论那个用户(第四张截图),发现他已经被封禁了...


image.png


好家伙,这发生得也太快了吧!赶上直播了???


估计这一波,有不少帐号也受到波及,最好确认一下自己的帐号是否有被影响(吓得笔者又刷新了一下页面确认自己有没有被封禁)。


这也让笔者想起最近 GitHub、NPM 等各种平台都在极力地推动用户启用双因素身份验证(2FA),以提高用户帐号的安全;这样看来,确实是一个明智之举。


最后还是提醒一下各位:


不清楚来源的链接不要点!不清楚来源的链接不要点!不清楚来源的链接不要点!


就这样。


作者:Nauxscript
来源:juejin.cn/post/7337666469903122472
收起阅读 »

看完zustand源码后,我的TypeScript水平突飞猛进。

web
前言 过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。 ts类型推断 个人认为ts最大的作用有两个,一个是类型约束,另外一个...
继续阅读 »

前言


过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。


ts类型推断


个人认为ts最大的作用有两个,一个是类型约束,另外一个是类型推断。



  • 类型约束也叫类型安全,在编译阶段就能发现语法错误,可以有效减少低级错误。

  • 类型推断,当你没有标明变量的类型时,编译器会根据一些简单的规则来推断你定义的变量的类型


这一篇主要和大家分享类型推断,类型推断主要有以下几种情况。


根据变量的值自动推导类型


image.png


image.png


函数返回值自动推断


image.png


函数中如果有条件分支,推导出来的返回值类型是所有分支的返回值类型的联合类型


image.png


ts的类型推导方式是懒推导,也就是说不会实际执行代码。


image.png


上图中如果实际执行了,c的类型是能确认为null的。


使用范型推导


image.png


可以看到按照上面写法,对象合并推导不出来,如果能推导出来u3应该等于 {name: string, age: number}


这时候我们可以借助范型来推导


image.png


可以给上面代码简写为这样,编辑器也能推导出来


image.png


实战


实现pick方法


从一个对象中,返回指定的属性名称。


image.png


上面代码中定义了两个范型T和U,T表示对象,U被限定为T的属性名(U extends keyof T),返回值的类型为{[K in U]: T[K]},in的作用就是遍历U这个数组。


image.png


可以看到数组元素被限制了只能是user对象里的key


image.png


image.png


也正确的推导出来了


实现useRequest


先看一个例子


import { useEffect, useState } from 'react';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);
const [error, setError] = useState(false);

useEffect(() => {
setLoading(true);
getUsers().then((res) => {
setUsers(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
})
}, []);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div key={u.name}>{u.name}</div>
))}
</div>

);
};

export default App;

上面这个例子实现了从后端请求用户列表,然后渲染出来。为了提高用户体验,在加载数据时,加了一个loading,当请求出错时,告诉用户请求失败。


代码比较简单我就不一一讲解了,有行代码需要注意一下。


 const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);


  • typeof getUsers 获取getUsers函数类型

  • ReturnType 获取某个函数的返回值

  • Awaited 如果函数返回值为Promise,这个可以获取到最终的值类型。


image.png


image.png


可以看到,正确的获取到了getUsers函数的返回值类型。


然而一个很简单的功能需要写那么多代码,肯定是不合理的,那么我们给简化一下。目前市面上已经有不少库来解决这个问题了,比如react-query或ahooks库里的useRequest,都可以解决这个问题,我这里分享的不是具体代码实现,而是怎么写ts。


封装useRequest


import { useEffect, useState } from 'react';

export function useRequest<T extends () => Promise<unknown>>(
fn: T,
): {
loading: boolean;
error: boolean;
data: Awaited<ReturnType<T>>;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [data, setData] = useState<any>();

useEffect(() => {
setLoading(true);
fn().then(res => {
setData(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
});
}, [fn])

return {
loading,
error,
data,
};
}

改造app.tsx文件,使用useRequest


import { useRequest } from './useRequest';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const { loading, data: users, error } = useRequest(getUsers);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div
key={u.name}
>

{u.name}
</div>
))}
</div>

);
};

export default App;

对比最开始的代码,是不是简单了很多。


useRequest.tsx代码也很简单,首先使用了范型限制fn只能是一个函数,返回值还必须是Promise。这个hooks返回值loading和error就不说了,主要是data,这个data要求和传进来的方法返回值一致,前面说过,可以使用Awaited<ReturnType<T>>获取函数的返回类型。


image.png


但是上面代码可能会导致bug,看下面代码,如果请求失败,users应该是空的,直接这样使用就会报错了。改造一下,当error为false的时候data为正常类型,error为true的时候data为null,这里可以使用联合类型。


image.png


image.png


image.png


image.png


加了一个判断后,下面就不会报错了。ts在某些时候,真的可以避免一些低级错误,我相信如果没有这个限制,肯定有人在写代码的时候不加判断直接用users。


如果请求接口的函数需要参数怎么办,下面来实现一下。


image.png


使用Parameters获取传入函数的参数类型


image.png


image.png


多个参数也是支持的


image.png


zustand


zustand是一个react状态管理库,使用起来比较简单没啥心智负担,所以我一直在用。


上面带着大家入门了ts的类型推断,下面给大家分享一下zustand的ts定义。我看完zustand源码后,发现这个库的ts定义比功能实现还复杂,这里我只给大家分享ts,具体实现掘金已经有很多大佬写过了,我就不分享了。


先从一个最简单的例子开始


import { create } from 'zustand';

interface State {
count: number;
}

interface Action {
inc: () => void;
}

export const useStore = create<State & Action>((set) => ({
count: 1,
inc: () => set((state) => ({count: state.count + 1})),
}));

image.png


create方法的定义


type Create = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
UseBoundStore<Mutate<StoreApi<T>, Mos>>
/**
* @deprecated Use `useStore` hook to bind store
*/

<S extends StoreApi<unknown>>(store: S): UseBoundStore<S>
}

可以看到create有三个重载方法,最后一个废弃不用了,上面例子使用的是第一个方法,第二个重载方法可以这样使用。


image.png


这样做的意义和中间件有关系,这个后面再说。


 <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>

我们先看第一个方法,定义了两个范型,T表示返回值类型,对应上面例子中create<State & Action>,Mos是给中间件用的,这个等会再说。


create方法的参数initializer定义


initializer: StateCreator<T, [], Mos>

参数initializer对应的类型是StateCreator


export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

StateCreator定义了4个范型,T还是表示返回值类型,其余三个暂时用不到。


((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

这段ts表明,initializer是一个函数,并且有三个参数,& { $$storeMutators?: Mos }表示交叉类型,也就是说这个函数可能会有$$storeMutators属性。


举个例子:


image.png


因为函数上没有$$name属性,所以报错了,下面给函数加上属性就可以了


image.png


setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>

type Get<T, K, F> = K extends keyof T ? T[K] : F

定义了一个Get类型,表示K如果在T对象的可以中,则返回K属性对应的值类型,如果不在返回F。


看个例子


image.png


因为T对象中没有count属性,所以返回never,never表示不存在的类型。


image.png


因为T对象中有name属性,所以返回name字段对应的类型string。


Mutate<StoreApi<T>, Mis>

Mutate这个类型很复杂,是为了解决中间件类型提示出现的,后面再说,没有使用中间件的情况下可以把这段代码简化为StoreApi<T>


export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
*/

destroy: () => void
}

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

到这里我们就看到前面例子中set的定义了,set方法有两个参数,第一个参数可以是前面范型定义的一个对象,可以是对象中的一些属性,也可以是一个函数。第二个属性表示是否覆盖整个对象。


这里的["_"]让我有点迷惑,不知道有啥作用,也可以写成下面这样。


type SetStateInternal<T> = (
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
) =>
void

set竟然可以直接设置值,看完源码后,我才知道可以这样用,一般我都是用函数,然后使用函数返回值更新值。


image.png


create方法的返回值类型定义


UseBoundStore<Mutate<StoreApi<T>, Mos>>

上面说了没有中间件的情况下,可以简化为:UseBoundStore<StoreApi<T>>


export type UseBoundStore<S extends WithReact<ReadonlyStoreApi<unknown>>> = {
(): ExtractState<S>
<U>(selector: (state: ExtractState<S>) => U): U
/**
* @deprecated Use `createWithEqualityFn` from 'zustand/traditional'
*/

<U>(
selector: (state: ExtractState<S>) => U,
equalityFn: (a: U, b: U) => boolean,
): U
} & S

type ExtractState<S> = S extends { getState: () => infer T } ? T : never

create返回值是一个函数,这个函数有三个重载方法,并且方法上还有一些属性,(& S)表示这些属性。


第一个重载方法表示没有参数时直接返回ExtractState<S>,ExtractState其实就是获取S对象中getState的返回值类型。


image.png


第二个重载方法有一个参数,可以返回自定义属性。


image.png


第三个重载方法废弃了,就不说了。


image.png


上图中useStore之所以有setState和getState等属性,就是上面& S的作用。


create第二个重载方法的作用


zustand支持使用中间件和编写中间件,看完官方持久化persist中间件的ts定义后,直接把我CPU干烧了,太复杂。


先看一下前面说过的,为啥create方法加了一个重载方法。


<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
Mutate<StoreApi<T>, Mos>

这个重载方法主要是给使用了中间件的情况下使用的,看一个例子。


image.png


image.png


上面例子中使用了官方提供的持久化中间件,如果使用第一种重载方法会报错,使用第二种就会报错,下面我们来分析一下为啥会这样。


先给上面代码简化一下


function a() {
console.log('hello');
}
type Fn = {
<T, U extends any[] = []>(name: U): T;
<T>(): <U extends any[] = []>(name: U) => T;
};

const b = a as Fn;

b(['hello'])

image.png


这时候我们调用第一个重载方法没有报错,加了范型后就报错了。


image.png


这是因为不使用范型的时候,编辑器会自动推导类型,如果传了一个范型,那么 U extends any[] = []会强制使用默认值[],所以传['hello']会报错。传[]就不会报错了。第二个重载方法的意义就是给两个范型拆开,这样设置了T不会应用U。


image.png


回到上面问题再看一下create方法的参数类型


image.png


因为传了一个范型约束,所以第二个参数使用默认值[]了


image.png


然而persist中间件返回值类型Mos不为[],所以报错了


image.png


针对这个问题,有两个解决方案


第一个方案是把范型去掉,把范型写在persist上。


image.png


第二个方案是用第二个重载方法


image.png


中间件返回值的类型定义


前面有个东西没说,create返回值里的Mutate<StoreApi<T>, Mos>是干嘛用的,先看下代码


export type Mutate<S, Ms> = number extends Ms['length' & keyof Ms]
? S
: Ms extends []
? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never

第一次看这个的时候,直接给我看懵了,这是啥,怎么还有递归,然后恶补了一下ts类型体操知识,顺便把github上类型体操题目刷了一下,然后再回来看这个类型体操就很简单了。


先写一个简单的例子让大家入门一下类型体操,合并数组中的对象类型。



// 写一个类型给a转换为{name: string, age: number}

type a = [{ name: string }, { age: number }];

// infer 可以理解为定义一个变量,
// infer F 表示取出数组中第一个元素,
// ...infer R表示把数组中剩余的元素放到R中,
// S & F 表示把S和F合并,
// C<R, S & F>递归剩余元素也合并S中
// 最后返回S

type C<T extends any[] = [], S = {}> = T extends [infer F, ...infer R] ? C<R, S & F> : S


image.png


理解了这个,那上面Mutate也就好理解了。


number extends Ms['length' & keyof Ms] ? S : : Ms extends [] ? S : ...这段表示如果Ms的类型为any[]则返回S,如果Ms为[]也返回S。


正常我们没有使用中间件的时候,Ms是[],所以直接返回S也就是StoreApi<State & Action>


当使用中间件的时候,我们先看下persist返回值类型。


image.png


persist中间件源码中的类型定义


image.png


根据create方法initializer参数定义Mos被自动推导成了[["zustand/persist", State & Action]],Mos对应Mutate里的Ms。


image.png


Ms extends [[infer Mi, infer Ma], ...infer Mrs]

对比上面的Ms类型,Mi为"zustand/persist",Ma为State & Action,Mrs为[]。


Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>

接下来开始递归了,StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier],把Mi替换成"zustand/persist",变成StoreMutators<S, Ma>["zustand/persist" & StoreMutatorIdentifier]


image.png


最开始这段代码让很迷惑,因为StoreMutators在项目里定义的是空对象,上面这种写法取不到任何东西。然后我去persist中间件源码里看了一下,原来在persist里给StoreMutators扩展了。


image.png


这几个类型定义可以简单理解为是给Mutate里S添加了persist属性。而persist属性有下面这些方法。


image.png


type Write<T, U> = Omit<T, keyof U> & U

Write表示合并两个类型,如果有重复的key,用后面的覆盖前面。


image.png


可以看到两个对象合并了key,并且name被覆盖成了number类型。


所以当使用persist中间件时,Mutate<StoreApi<T>, Mos>最终类型为StoreApi<T> & { persist: { ... } },所以我们能create返回的值里调用persist里的方法。


image.png


自定义中间件


模拟per中间件,自己也写一个,没有写具体实现,只写了类型定义。


import { StateCreator, StoreMutatorIdentifier } from 'zustand';

type Test = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
U = T
>(
initializer: StateCreator<T, [...Mps, ['test', unknown]], Mcs>
) =>
StateCreator<T, Mps, [['test', U], ...Mcs]>;

type Write<T, U> = Omit<T, keyof U> & U;

declare module 'zustand' {
interface StoreMutators<S, A> {
test: Write<S, {test: {log: () => void}}>;
}
}

function a() {
console.log(444);
}

export const test = a as unknown as Test;

image.png


在中间件中也可以重写setState方法


image.png


image.png


总结


到此终于结束了,最复杂的create方法讲完了,其他都是简单的,就不分享了。说实话ts类型定义比代码实现难理解多了,也有可能是我开始的水平不够,所以看起来比较费劲。为了看懂这些ts,我把ts体操类型刷了一遍,现在我感觉自己ts提升了很多。找个时间看一下zod的源码,学习一下它的ts定义。


我看一些ts教程的文章下面,很多人吐槽说TypeScript没有用,个人觉得公司里的业务代码或者个人小项目确实可以不用,但是如果你要开发一个开源框架或组件库,我觉得ts或jsdoc还是有必要的,类型推断和准确的代码提示可以方便用户使用。


作者:前端小付
来源:juejin.cn/post/7339364757386264612
收起阅读 »

很多人 30 岁了,对人情世故的认知水平还停留在上学

工作和事业 当领导夸你“工作完成的不错”时。 一般的回答:谢谢领导夸奖 高情商回答: 在今后的工作中,我会继续加油,认真负责的跟着您干,如果没做好的地方,希望您指正。 工作认真负责是我的本分,主要是您指挥的好,不然我也不会成长得这么快 都是因为您给了我充分的...
继续阅读 »

工作和事业


当领导夸你“工作完成的不错”时。


一般的回答:谢谢领导夸奖


高情商回答:



  1. 在今后的工作中,我会继续加油,认真负责的跟着您干,如果没做好的地方,希望您指正。

  2. 工作认真负责是我的本分,主要是您指挥的好,不然我也不会成长得这么快

  3. 都是因为您给了我充分的信任和支持,所以我才能这么顺利地推进。

  4. 最开始我信心不足,您对我的信任给了我做事的信心和动力。能做成这件事,全靠您的帮助,非常感谢您。以后我会加倍努力。


当领导给你安排 完成不了的工作时


一般的回答:领导我干不了。


高情商回答:



  1. 好的,领导,不过我缺乏经验,有些事情还需要您的指导的帮助

  2. 好的领导,您先给我一个小时,我先整体评估一下难度,初步预估一下事情的周期。

  3. 领导,我现在手头有三个项目,你看如果我做现在这个项目的话,怕是会影响其他项目的进度和质量,同事 XXX 在某些方面比较专业,您看是否可以让他负责。


炫耀


对利益相关的人


要展示你的实力和智力


对你利益不相关的人


就展示你的礼貌就好


工资一万,对家里说 7000,对外人说 4000。程序员不要炫富,你那点工资差远了。


规则和事实


当事实对你有利,就强调事实


当规则对你有利,就强调规则


当事实和规则都对你不利


就敲桌子把事情搅混


求人帮忙



  • 求人办事,关系再好,也要让对方得到利益。 铁哥们也不例外,至少请一顿饭,当面感谢。

  • 找人办事,别一上来就说等办完事,再给什么好处,办事之前就要给到。

  • 不要轻易得罪别人,虽然他不能帮你成事,但是可能坏你的事。尤其是领导身边重要的心腹。


想要强大,必须出丑。出丑越多,脸皮越厚,成长越快


生活


管住嘴



  • 少跟妈妈说难过的事,她帮不上忙,也会睡不着觉

  • 别人一对你好,你就推心置腹的毛病一定要改

  • 交浅言深,是人际交往的大忌讳

  • 亲朋好友的孩子再不对,也不要去教育,因为教育别人家的孩子就是在打别人的脸


帮忙


别人不开口请你帮忙,尽量不要主动帮忙!


别人求你帮忙,你先探探对方的口风,看他的态度和想法,看看对方是在寻求你的意见,也许对方只是想寻求你认可他的想法。


见人说人话


遇到女人一律说对方瘦了


遇到穷人,一律说钱不重要,快乐就好


遇到美女夸有内涵,身材好、气质好


遇到带孩子,夸小孩聪明伶俐,夸小孩带的好,


遇到帅哥,夸有才华,有风度。


遇到病人夸气色好,很快就会痊愈


遇到企业家,夸有情怀


遇到小职员,夸有格局


遇到富人,夸他有眼光,有品位


饭局



  • 饭桌上一直玩手机的聚会,就这一次没有下次,没有意义的社交无需留念

  • 去别人家吃饭,饭后不要帮忙刷碗

  • 除非是铁哥们,否则不要临时通知别人去聚餐,别人会认为自己是凑数的!

  • 聚餐时,一定不要夹盘子里的最后一口菜

  • 坐同事的车,只坐副驾驶,不坐在后排。

  • 车子收拾的很干净的人,大多数不好客


作者:五阳
来源:juejin.cn/post/7338723726838218771
收起阅读 »

停止使用 localStorage !

web
medium 优秀文章翻译,也增加自己的一些使用体验。 非标题党!本文标题很明确的想表达对 localStorage 的不推荐。 localStorage 的弊病 2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆...
继续阅读 »

medium 优秀文章翻译,也增加自己的一些使用体验。




非标题党!本文标题很明确的想表达对 localStorage 的不推荐。


localStorage 的弊病


2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆解下定义中的关键点。



  • 字符串集合:它只能存储字符串。如果你想要存储或者检索其他格式数据,你必须进行序列化和反序列化。假如你忘记了这一点,你将会遇到各种各样的网络Bug。例如当你存储 true 和 false 时,你还要注意处理 null、undefined、空字符串等潜在返回值。

  • 非结构化数据:JavaScript 结构化克隆算法用于复制复杂的 JavaScript 对象的算法。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。postMessage, WebWorkers, IndexedDB, Caches API, BroadcastChannel, Channel Messaging API, MessagePortHistory API 都是采用的结构化数据! 采用该结构化数据就是为了解决序列化和反序列化 JSON 带来的问题。很遗憾的是 localStorage 并没有更新该特性,并且未来也没有推进的计划。

  • 安全妥协:你永远不会在任何持久化存储中保存敏感数据,但是开发者依旧会在 localStorage 中保存 Session IDs,JWTs、API keys 等敏感信息。这是个常见的安全隐患,你可以在 window.localStorage 中随意查阅。




  • 性能:localStorage 的性能相对于之前已经有了很好的优化,但是对于超量事务的并发应用程序来说,其性能瓶颈一样要重点考虑。

  • 大小限制:localStorage 有 5MB 的限制,而且可能被浏览器删除。对于现代应用来说,5MB 是很小的容量了,几乎很难存储任何媒体数据。它并不是一成不变的,在一些场景下,浏览器也会主动删除部分持久化存储中的数据,这是个通病,这也是何为常规日志上报会有数据丢失的原因之一。因此有甚至需要主动去管理这部分的数据的生命周期,尽管没人告诉你要做这个。还有个点就是存储剩余容量是无法查询的,因此你无法确定操作是否会以为容量达到上限而无法完整写入。

  • WebWorker 无法访问:localStorage 并不是面向未来的API,也不是适用于并放进程中。

  • 非原子化:localStorage 不保证并行操作中的原子性,也没有任何锁能够保证正在写入的数据不会被覆盖。

  • 无数据隔离:localStorage 仅仅是个字符串的对象,应用下所有数据都被混淆在一起,无法进行数据隔离。

  • 无事务:常规数据库都会支持事务操作,也没办法进行分组。所有操作都是同步的、非独立的、无锁定的。

  • 同步阻塞操作:localStorage 不是异步的,它会阻塞主进程。频繁的读取甚至会影响动画的流畅性,在移动端设备最为明显


WebSQL 何去何从?



WebSQL 目标是为 Web 提供一个简单的 SQL 数据库接口,但是浏览器支持程度确实不好。


你可能好奇它为啥会被抛弃?



  • 单一浏览器厂商实现:WebSQL 主要是 Chrome 和 Safari 实现的,由于 Mozilla 和 Microsoft 不支持,业内开发者几乎不采用它。

  • 非 W3C 标准:这个是至关重要的,W3C 在 2010 年将它从标准中移除了。

  • 与 IndexedDB 的竞争:IndexedDB 主要获得更多的关注,且被设计成标准的跨浏览器解决方案了。

  • 安全问题:一些开发人员和安全专家对WebSQL的安全性表示担忧。他们在很多方面都持怀疑态度,包括缺乏权限控制和SOL风格的漏洞。


最终 IndexedDB 成为浏览器存储的标准,被评价为强壮的、跨浏览器友好。但是大多数经验丰富的开发者都视其为瘟疫,那这种推荐又有什么意义呢?


Cookies 又如何呢?


cookie是1994年由网景公司的网络浏览器程序员卢·蒙图利(Lou Montulli)创建的。


本篇文章的标题实际应该是“停止使用 localStorage 和 Cookie”,但是又不全对,我们应该使用安全的 cookies。



  • 4KB体积限制

  • 默认会被请求传输:非跨域 HTTP 请求会携带 cookie 数据,假如数据不需要被每个请求传输,就会带来带宽开销,导致网络加载速度变慢。

  • 安全隐患:cookie更容易受到XSS的攻击。由于cookie会自动包含在对域的每个请求中,因此它们可能成为恶意脚本的目标。

  • 过期:cookie被设计为在给定日期过期。


IndexedDB 呢?



  • 更好的性能:IndexedDB 操作是异步的,不和阻塞主进程。API 被设计为了事件驱动的。

  • 充足的存储配额:与localStorage的5MB上限相比,IndexedDB提供了更大的存储配额(取决于浏览器、操作系统和可用存储。

  • 可靠且结构化数据:Indexed 减少了强制类型转换,并且采用结构化克隆算法,保证数据的完整。



但是你大概并不想直接使用 IndexedDB。


IndexedDB 大概是避免过多依赖的例外。将 IndexedDB 视为后台数据库,你需要的是 ORM 或者 数据库处理程序来进行查询的管理。由于 IndexedDB 糟糕的 API 设计,你更想要一个 IndexedDB 库。



  • 基于 Promise

  • 更好使用

  • 减少样板代码

  • 关注于更关键的部分


本文比较推荐 dexie.js 和 idb 两个针对 indexedDB 的封装库,其中 idb 的体积是最小的,仅仅 1.19 KB,并不会给程序带来负担。


总结


本文的口号虽然是“停止使用 localStorage”,但是在这个时代实际是难以实现的,但是我们确实应该朝着这个目标出发。


未来开发者应该从 Promise()、async/await 和结构化数据中或者更加清晰且有意义的知识,而不应该关注为何数字“0”在条件语句中会成为“true”,而不应该愤怒与客户获得 null 的返回值。


由于 IndexedDB 的性能优势,你存储各种类型的数据,甚至可以使用游标来遍历所有对象。基于这种技术,你甚至可以构建客户端的搜索引擎,而不会像 localStorage 那样影响动画渲染。



IndexedDB is commonly described as “low-level” . There’s absolutely nothing low-level about IndexedDB, it’s just an API with an old-style and unfriendly syntax. But that doesn't negate it’s underlying capabilities, hence common library usage.



你并不需要直接使用 API,一个体积很小的封装库可以帮助你规避这些。


作者:三省法师
来源:juejin.cn/post/7338422591518457871
收起阅读 »

不可不知的Redis秘籍:事务命令全攻略!

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升...
继续阅读 »

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。

今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升我们的数据操作效率和安全性。

一、Redis事务的概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Description

总结来说: redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

redis事务的执行阶段

  • 开始事务(multi)。
  • 命令入队。
  • 执行事务(exec)

Description

二、Redis事务优缺点

对于Redis事务的概念我们已经有了基本的了解,下面我们再来看看它都有哪些优缺点。

优点:

  • 一次性按顺序执行多个Redis命令,不受其他客户端命令请求影响;

  • 事务中的命令要么都执行(命令间执行失败互相不影响),要么都不执行(比如中间有命令语法错误);

缺点:

  • 事务执行时,不能保证原子性;

  • 命令入队每次都需要和服务器进行交互,增加带宽;

注意:

  • 当事务中命令语法使用错误时,最终会导致事务执行不成功,即事务内所有命令都不执行;

  • 当事务中命令知识逻辑错误,就比如给字符串做加减乘除操作时,只能在执行过程中发现错误,这种事务执行中失败的命令不影响其他命令的执行。

三、Redis事务相关命令

Redis事务可以通过一系列命令来执行多个操作,并确保这些操作可以原子性地执行。以下是Redis事务的相关命令及其作用:

MULTI: 开启一个事务。在调用此命令后,Redis 会将后续的命令逐个放入队列中,直到接收到 EXEC 命令为止。

EXEC: 执行事务中的所有操作命令。一旦调用 EXEC 命令,Redis 会原子性地执行队列中的所有命令。

DISCARD: 取消事务,放弃执行事务块中的所有命令。如果不想继续执行事务中的操作,可以使用 DISCARD 命令来清除当前事务队列。

WATCH: 监视一个或多个键,如果在事务执行之前这些键被其他命令所改动,那么事务将会被打断。

UNWATCH: 取消所有由 WATCH 命令监视的键。如果不想继续监视某些键,可以使用 UNWATCH 命令来取消监视。

需要注意的是,在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中,这保证了事务的隔离性。同时,Redis 事务提供了批量操作缓存的功能,即在发送 EXEC 命令前,所有操作都会被放入队列缓存。

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,点击这里,立即开始你的学习之旅!

四、Redis事务的使用

使用Redis事务的步骤如下:

  • 使用MULTI命令开启一个事务。

  • 在事务中执行需要的命令,如SET、GET等。

  • 使用EXEC命令提交事务,将事务中的命令一次性发送给Redis服务器执行。

  • 如果需要取消事务,可以使用DISCARD命令。

Description

下面通过一些示例来讲解一下这些命令的使用方法:

1、正常执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa AA
QUEUED
192.168.xxx.21:6379> set bb BB
QUEUED
192.168.xxx.21:6379> set cc CC
QUEUED
192.168.xxx.21:6379> set dd DD
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
3) OK
4) OK
192.168.xxx.21:6379> get aa
"AA"

首先,通过执行multi命令开始一个事务块。然后,依次执行了四个set命令,将键"aa"、“bb”、“cc"和"dd"分别设置为对应的值"AA”、“BB”、“CC"和"DD”。

每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。执行结果为每个命令的返回值,即"OK"。最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。

2、取消事务

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> set ee EE
QUEUED
192.168.xxx.21:6379> discard
OK
192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> get ee
(nil)
192.168.xxx.21:6379>

示例代码中,首先,通过执行multi命令开始一个事务块。然后,依次执行了两个set命令,将键"aa"设置为值"11",将键"ee"设置为值"EE"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行discard命令来取消事务,放弃执行事务块内的所有命令。执行结果为"OK"。

最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。而执行get ee命令获取键"ee"的值时,由于之前已经取消了事务,所以返回结果为"(nil)",表示该键不存在。

3、事务队列中存在命令错误

如果在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 22
QUEUED
192.168.xxx.21:6379> set bb 33
QUEUED
192.168.xxx.21:6379> setq cc 44
(error) ERR unknown command 'setq'
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
192.168.xxx.21:6379> get ff
(nil)
192.168.xxx.21:6379> get bb
"BB"
192.168.xxx.21:6379>

首先,通过执行multi命令开始一个事务块。然后,依次执行了三个set命令,将键"aa"设置为值"22",将键"bb"设置为值"33",将键"cc"设置为值"44"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

然而,在执行第三个set命令时,出现了错误。因为Redis中并没有名为"setq"的命令,所以返回结果为"(error) ERR unknown command ‘setq’"。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。由于之前已经出现了错误,导致事务被中断,所以执行结果为"(error) EXECABORT Transaction discarded because of previous errors."。

最后,通过执行get ff命令获取键"ff"的值时,由于事务被中断,所以返回结果为"(nil)“,表示该键不存在。而执行get bb命令获取键"bb"的值时,由于事务被中断,所以返回结果为"BB”。

4、事务队列中存在语法错误

如果在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> incr aa
QUEUED
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> set bb 22
QUEUED
192.168.xxx.21:6379> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
192.168.xxx.21:6379> get bb
"22"
192.168.xxx.21:6379> get ff
"FF"
192.168.xxx.21:6379>

错误原因:字符串不能累加1

5、watch监控

watch 命令可以监控一个或多个键,一旦有其中一个键被修改(被删除),后面的事务就不会执行了。监控一直持续到 EXEC 命令(事务中的命令是在exec之后才执行的,所以在multi命令后可以修改watch监控的键值)

假设我们通过watch命令在事务执行之前监控了多个Keys,倘若在watch之后有任何Key的值发生了变化,exec命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。

(1)、执行watch,不执行multi、exec

192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> watch aa
OK
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(2)、执行 watch 命令,通知执行 MULTI、exec

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379>

(3)、exec 执行之后,会自动执行 UNWatch 命令,撤销监听操作

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(4) 、unwatch撤销监听

192.168.xxx.21:6379> get bb
"BBB"
192.168.xxx.21:6379> watch bb
OK
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> unwatch
QUEUED
192.168.xxx.21:6379> set bb 222
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
192.168.xxx.21:6379> get bb
"222"
192.168.xxx.21:6379>

以上就是Redis事务的概念及相关命令的使用,Redis事务是一个非常强大的工具,它可以帮助我们在处理数据的时候保持数据的一致性和完整性。通过使用Redis事务,可以让我们的数据操作更高效、更安全。

希望这篇文章能够帮助你更好地理解和使用Redis事务!

收起阅读 »

在高德地图上使用threejs,tweenjs,引入外部模型,实现动画效果

web
init展示地图展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH ...
继续阅读 »

init

展示地图

展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客

踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH 原因:申请的key和使用的服务不匹配,展示地图使用JS-API的key,地理信息解析是web服务

  1. npm包安装
npm i @amap/amap-jsapi-loader --save
  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader';
  1. 初始化
var AMap, map
window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
initMap()
})
.catch((e) => {
console.error(e); //加载错误提示
});
function initMap () {
map = new AMap.Map("map", {
viewMode: '2D', //默认使用 2D 模式
zoom: 11, //地图级别
center: [116.397428, 39.90923], //地图中心点,背景天安门为例
});
}

此时,一个平平无奇的高德地图跃然纸上

Pasted image 20240221095238.png

结合THREE

自定义图层-GLCustomLayer 结合 THREE-自有数据图层-示例中心-JS API 2.0 示例 | 高德地图API (amap.com)

环境搭建:

  1. 高德地图环境搭建看上一章
  2. three环境搭建 :一定要下载对应版本的three,在官网示例中可以查看其引入的three版本
npm i three@0.142 # 24/2/1日数据

入门小案例-引入外部模型

照搬官网案例就行

我这里做了些许改动

  1. 引入外部模型猴头
  2. 创建mesh,参考官网案例
  3. 添加移动功能,将猴头移动入mesh中

效果如图

Pasted image 20240222161502.png 代码如下:

Pasted image 20240222161509.png

  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader'
import * as THREE from 'three';
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import {reactive } from 'vue'
  1. 地图准备
// step map 
var AMap, map

window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
createMap() // 创建地图
createThree() // 创建three
})
.catch((e) => {
console.error(e); //加载错误提示
});
function createMap () {
map = new AMap.Map("map", {
center: [116.54, 39.79],
zooms: [2, 20],
zoom: 14,
viewMode: '3D',
pitch: 50,
});
}

  1. three 准备 核心内容是创建GL图层,里面的 render 基本没什么变化
// step three init
var camera, renderer, scene
var model, monkey, mesh
// 数据转换工具
var customCoords
// 测试用数据
var data
function createThree () {
customCoords = map.customCoords;
data = customCoords.lngLatsToCoords([
[116.52, 39.79],
[116.54, 39.79],
[116.56, 39.79],
])
// 创建 GL 图层
var gllayer = new AMap.GLCustomLayer({
// 图层的层级
zIndex: 10,
// 初始化的操作,创建图层过程中执行一次。
init: (gl) => {
initThree(gl)
},
render: () => {
// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
// 重新设置图层的渲染中心点,将模型等物体的渲染中心点重置
// 否则和 LOCA 可视化等多个图层能力使用的时候会出现物体位置偏移的问题
customCoords.setCenter([116.52, 39.79]);
var { near, far, fov, up, lookAt, position } =
customCoords.getCameraParams();

// 这里的顺序不能颠倒,否则可能会出现绘制卡顿的效果。
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(...position);
camera.up.set(...up);
camera.lookAt(...lookAt);
camera.updateProjectionMatrix();

renderer.render(scene, camera);

// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
},
});
map.add(gllayer)
window.addEventListener('resize', onWindowResize);
}
function onWindowResize () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}

然后我们来看initThree

function initThree (gl) {
// 这里我们的地图模式是 3D,所以创建一个透视相机,相机的参数初始化可以随意设置,因为在 render 函数中,每一帧都需要同步相机参数,因此这里变得不那么重要。
// 如果你需要 2D 地图(viewMode: '2D'),那么你需要创建一个正交相机
camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
100,
1 << 30
);

renderer = new THREE.WebGLRenderer({
context: gl, // 地图的 gl 上下文
// alpha: true,
// antialias: true,
// canvas: gl.canvas,
});

// 自动清空画布这里必须设置为 false,否则地图底图将无法显示
renderer.autoClear = false;
scene = new THREE.Scene();

// 环境光照和平行光
var aLight = new THREE.AmbientLight(0xffffff, 3);
var dLight = new THREE.DirectionalLight(0xffffff, 10);
dLight.position.set(1000, -100, 900);
scene.add(dLight);
scene.add(aLight);
// 加载模型、mesh
addModel()
addMesh()
}

以上内容也基本不变,但最后加载模型、mesh按照你需要加载的物体变化。

加载外部模型

function addModel () {
const glftLoader = new GLTFLoader()
glftLoader.load("/public/models/monkeyAndCube.glb", function (gltf) {
model = gltf.scene
model.traverse((child) => {
child.scale.set(500, 500, 500); // 放大模型
child.rotation.x = 0.5 * Math.PI;
child.position.z = 0.8;
console.log(child.name)
if (child.name === "monkey") { monkey = child }
})
monkey.position.set(data[0][0], data[0][1], 500); // 设置位置
scene.add(monkey)
})
}

加载mesh

function addMesh () {
// 这里可以使用 three 的各种材质
var mat = new THREE.MeshLambertMaterial({
side: THREE.DoubleSide,
color: 0x1e2f97,
transparent: true,
opacity: .4,
depthWrite: false
})
var geo = new THREE.BoxBufferGeometry(1200, 1200, 1200);
const d = data[2];
mesh = new THREE.Mesh(geo, mat);
mesh.position.set(d[0], d[1], 500);
scene.add(mesh);
animate()
}
// 动画
function animate () {
mesh.rotateZ((1 / 180) * Math.PI);
map.render();
requestAnimationFrame(animate);
}

移动猴头! 记得自己给这个函数加个按钮

function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
monkey.position.set(data[2][0], data[2][1], 500);
} else {
monkey.position.set(data[0][0], data[0][1], 500);
}
}

[!TIP] 这里面地图和three好像是一起渲染的。如果你只加载了猴头,没加载mesh,此时是没有动画效果的,所以移动猴头的话,这个效果有延迟,缩放平移一下地图就好了。但是如果添加了动画,由于一直调用 map.render() 函数,因此不会出现此问题

大功告成!

结合tween.js

是不是觉得猴头的移动还不够顺滑,加个tween的动画试试

  1. 引入 npm install @tweenjs/tween.js
import * as TWEEN from '@tweenjs/tween.js'
  1. 更改 moveMonkey函数
function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
// monkey.position.set(data[2][0], data[2][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[2][0], y: data[2][1], z: 500 }, 2000)
.start()
} else {
// monkey.position.set(data[0][0], data[0][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[0][0], y: data[0][1], z: 500 }, 2000)
.start()
}
}
  1. 在 render 函数中加入 ( 这里指Three 函数中的 创建GL图层的 render 函数)
 TWEEN.update()

大功告成!Tween的其他功能也可以使用


作者:写bug的小杜
来源:juejin.cn/post/7338240698703314985

收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

记一种不错的缓存设计思路

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。 场景 假设有个以下格式的接口: GET /api?keys={key1,key2,key3,...}&types={1,2,3,...} 其中 keys 是业务...
继续阅读 »

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。


场景


假设有个以下格式的接口:


GET /api?keys={key1,key2,key3,...}&types={1,2,3,...}


其中 keys 是业务主键列表,types 是想要取到的信息的类型。


请求该接口需要返回业务主键列表对应的业务对象列表,对象里需要包含指定类型的信息。


业务主键可能的取值较多,千万量级,type 取值范围为 1-10,可以任意组合,每种 type 对应到数据库是 1-N 张表,示意:


redis-cache-design.drawio.png


现在设想这个接口遇到了性能瓶颈,打算添加 Redis 缓存来改善响应速度,应该如何设计?


设计思路


方案一:


最简单粗暴的方法是直接使用请求的所有参数作为缓存 key,请求的返回内容为 value。


方案二:


如果稍做一下思考,可能就会想到文首我提到的觉得不错的思路了:



  1. 使用 业务主键:表名 作为缓存 key,表名里对应的该业务主键的记录作为 value;

  2. 查询时,先根据查询参数 keys,以及 types 对应的表,得到所有 key1:tb_1_1key1:tb_1_2 这样的组合,使用 Redis 的 mget 命令,批量取到所有缓存中存在的信息,剩下没有命中的,批量到数据库里查询到结果,并放入缓存;

  3. 在某个表的数据有更新时,只需刷新 涉及业务主键:该表名 的缓存,或令其失效即可。


小结


在以上两种方案之间做评估和选择,考虑几个方面:



  • 缓存命中率;

  • 缓存数量、占用空间大小;

  • 刷新缓存是否方便;


稍作思考和计算,就会发现此场景下方案二的优势。


另外,就是需要根据实际业务场景,如业务对象复杂度、读写次数比等,来评估合适的缓存数据的粒度和层次,是对应到某一级组合后的业务对象(缓存值对应存储 + 部分逻辑),还是最基本的数据库表/字段(存储的归存储,逻辑的归逻辑)。


作者:mzlogin
来源:juejin.cn/post/7271597656118394899
收起阅读 »

HTTP请求头中的Authorization

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制: 基本认证(Basic Authentication): Authorization: Basi...
继续阅读 »

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制:



  1. 基本认证(Basic Authentication):


    Authorization: Basic base64(用户名:密码)

    这是最常见的一种,涉及将用户名和密码以base64格式编码并与请求一起发送。需要注意,Basic 后面有空格, 未使用HTTPS时基本认证不够安全
    实际使用例子,比如:


    curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

    curl -u “username:password” 就相当于在请求的请求头中添加keyAuthorizationvalueadmin:P@88w0rd,这是一种认证方式。
    对账号密码进行base64编码之后


    echo -n "admin:P@88w0rd" | base64

    得到:YWRtaW46UEA4OHcwcmQ=
    ,上方的curl也可以写成:


    curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept:  application/json" http://localhost:8090/api/v1alpha1/users


  2. Bearer令牌(Bearer Token):


    Authorization: Bearer <令牌>

    这通常与OAuth 2.0一起使用。<令牌>通常是通过单独的认证过程获取的长寿命访问令牌。


  3. 摘要认证(Digest Authentication):


    Authorization: Digest username="用户名", realm="领域", nonce="随机数", uri="URI", response="响应", opaque="opaque", qop=auth, nc=00000001, cnonce="cnonce"

    摘要认证比基本认证更安全,涉及挑战-响应机制来验证客户端。挑战-响应机制(Challenge-Response Mechanism,在这种机制中,服务器通过向客户端发送一个随机的挑战(challenge),并期望客户端使用其凭据(通常是密码)生成一个对应的响应(response)来证明其身份,服务端收到响应后验证身份)


  4. API密钥(API Key):


    Authorization: ApiKey <API密钥>

    API密钥通常用于API请求中的身份验证。密钥包含在Authorization头中。


  5. Bearer令牌(JWT):


    Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn...

    JSON Web Tokens(JWT)通常在现代身份验证系统中使用。令牌包含在Bearer方案中。


  6. 自定义方案(Custom Schemes):
    一些应用程序或服务可能定义了自己的自定义认证方案。例如:


    Authorization: CustomScheme 自定义数据



以上的使用的scheme,如BasicBearer,Digest,ApiKey是约定俗成的,大家都这样使用,具体认证类型取决于服务器的要求和实现的协议,针对自己的业务也可以自定义scheme。也可以参考正在与之交互的服务或API的文档,以确定Authorization头的正确格式。


作者:星夜晚晚
来源:juejin.cn/post/7329573746464718857
收起阅读 »

简单的 Web 端实时日志实现

web
背景 cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。 方案如何选择? 我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTT...
继续阅读 »

Feb-20-2024 21-40-36.gif


背景


cron service 在执行定时任务时,需要能够实时查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。


方案如何选择?


我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTTP/SSE 。WebSocket 在很多情况下是实至名归的万金油选择。但这这里他太重了,对服务端和客户端具有侵入性需要花费额外的时间来集成到项目中。


那么 SSE 呢,为什么不是他?
虽然 SSE 即轻量又实时,但 SSE 无法设置请求头。这导致无法使用缓存和通过请求头设置的 Token。而且作为长链接他还一直占用并发额度。



  • WebSocket:❌

    • 优势:

      • 实时性高

      • 不会占用 HTTP 并发额度



    • 劣势:

      • 复杂度较高,需要在客户端和服务器端都进行特殊的处理

      • 消耗更多的服务器资源。





  • SSE(Server-Sent Events):❌

    • 优势:

      • 基于HTTP协议,不需要在服务端和客户端做额外的处理

      • 实时性高



    • 劣势:

      • 无法设置请求头

      • 占用 HTTP 并发额度





  • HTTP:✅

    • 优势:

      • 简单易用,不需要在服务端和客户端做额外的处理。

      • 支持的功能丰富,如缓存,压缩,认证等功能。



    • 劣势:

      • 实时性差,取决于轮询时间间隔。

      • 每次HTTP请求都需要建立新的连接(仅针对 HTTP/0.x 而言)并可能阻塞同源的其他请求而导致性能问题。12

        • HTTP/1.x 支持持久连接以避免每次请求都重新建立 TCP 连接,但数据通信是串行的。

        • HTTP/2.x 支持持久连接且支持并行的数据通信。









以上列出的优缺点是仅对于本文所讨论的场景(即Web 端的实时日志)而言,这三种数据交互技术在其他场景中的优缺点不在本文讨论范围内。



实现


HTTP 轮询已经是老熟人了,不再做介绍。本文的着重于实时日志的实现及优化。


sequenceDiagram

participant 浏览器

participant 服务器

Note right of 浏览器: 首先,获取日志文件最后 X bytes 的内容

浏览器->>服务器: 最新的日志文件有多大?

服务器->>浏览器: 日志文件大小: Y bytes

浏览器->>服务器: 从 Y - X bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y1 bytes, 日志内容: XXXX



loop 持续轮询获取最新的日志

浏览器->>服务器: 从 Y1 bytes 处返回内容

服务器->>浏览器: 日志文件大小: Y2 bytes, 日志内容: XXXX

end

上方是基本工作原理的流程图。
实现的关键点在于



  • 前端如何知道日志文件当前的大小

  • 服务端如何从指定位置获取日志文件内容


这两点都可以通过 HTTP Header 来解决。Content-Range HTTP 响应头会包含完整文件的大小,而 Range HTTP 请求头可以指示服务器返回指定位置的文件内容。


因此在服务端不需要额外的逻辑,仅通过 Web 端代码就可以实现实时日志功能。


代码实现



篇幅限制,代码不会处理异常情况



首先,根据上述流程图。我们需要获取日志文件的大小。


const res = await fetch(URL, {  
method: "GET",
headers: { Range: "bytes=0-0" }
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 日志文件大小
const total = Number.parseInt(match[3], 10);


Range: "bytes=0-0" 指定仅获取第一个字节的内容,以免较大的日志文件导致响应时间过长。



我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=0-0 如果服务器能够正确处理 Range 请求头,响应中将包含一个 Content-Range 列其中将包含日志文件的完整大小,可以通过正则解析拿到。


现在我们已经拿到了日志文件的大小并存储在名为 total 的变量中。然后根据 total 获取到最后 10 KB 的日志内容。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${total - 1000 * 10}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
const start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

现在我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=${total - 1000 * 10}- 以获取最后 10 KB 的日志内容。并且通过正则解析拿到了下一次请求的起始位置。


现在我们已经拿到了日志文件的大小和最后 10 KB 的日志内容。接下来就是持续轮询去获取最新的日志。轮询代码区别仅在于将 Range 标头设置为 bytes=${start}- 以便获取最新的日志。


const res = await fetch(url, {  
method: "GET",
headers: {
Range: `bytes=${start}-`
}
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

以上,基本的功能已经实现了。日志内容保存在名为 content 的变量中。


优化


HTTP 轮询常因其高延迟为人诟病,我们可以通过指数退避的方式来尽可能的降低延时且不会显著的增加服务器负担。


指数退避是一种常见的网络重试策略,它会在每次重试时将等待时间乘以一个固定的倍数。这样做的好处是,当网络出现问题时,重试的时间间隔会逐渐增加,直到达到最大值。


一个简单的实现如下:


/**
* 使用指数退避策略获取日志.
*/

export class ExponentialBackoff {
private readonly base: number;
private readonly max: number;
private readonly factor: number;
private retries: number;

/**
* @param base 基础延迟时间 默认 1000ms (1秒)
* @param max 最大延迟时间 默认 60000ms (1分钟)
* @param factor 延迟时间增长因子 默认 2
*/

constructor(base: number = 1000, max: number = 60000, factor: number = 2) {
this.base = base;
this.max = max;
this.factor = factor;
this.retries = 0;
}

/**
* 获取下一次重试的延迟时间.
*/

next() {
const delay = Math.min(this.base * Math.pow(this.factor, this.retries), this.max);
this.retries++;
return delay;
}

/**
* 重置重试次数.
*/

reset() {
this.retries = 0;
}
}

值得一提的是带有 Range 标头的请求成功时会返回 206 Partial Content 状态码。而在请求的范围超出文件大小时会返回 416 Range Not Satisfiable 状态码。我们可以通过这两个状态码来判断请求是否成功。


成功时调用 reset 方法重置重试次数,失败时调用 next 方法获取下一次重试的延迟时间。


总结


即使再优秀的方案也不是银弹,真正合适的方案需要考虑的不仅仅是技术本身,还有业务场景,团队技术栈,团队技术水平等等。在选择技术方案时,我们需要权衡各种因素,而不是盲目的选择最流行的技术。


当我们责备现有的技术方案为何如此糟糕时,或许在那个时间点上,这个方案是最合适的。


Pasted image 20240220213326.png


感谢以下网友的帮助和建议:齐洛格德


Footnotes




作者:siaikin
来源:juejin.cn/post/7337519776796295177
收起阅读 »

马斯克接手Twitter一年后的成果-工作量化的重要性

大家好,我是云舒编程。 马斯克接手Twitter的一年后,在10.27其官方团队发布了一条推文展示这一年的工程成果。 有点国内那味了,论工作量化的重要性。 这一年里,我们在工程技术上取得了许多出色的成就,除了大家在应用中看到的明显变化之外,在幕后我们还做了...
继续阅读 »

大家好,我是云舒编程。


马斯克接手Twitter的一年后,在10.27其官方团队发布了一条推文展示这一年的工程成果。



有点国内那味了,论工作量化的重要性。



这一年里,我们在工程技术上取得了许多出色的成就,除了大家在应用中看到的明显变化之外,在幕后我们还做了一系列重要的优化和改进。



  • 将「为你推荐」、「关注」、「搜索」、「个人主页」、「列表」、「社区」和「探索」等功能的技术栈整合到了一个统一的产品框架中。

  • 彻底重建了「为你推荐」的服务和排名系统,代码行数从700K减少到70k,减少了90%;同时计算资源减少了50%,处理相同请求的能力提升了80%。

  • 统一了「为你推荐」和视频的个性化、排名模型,显著提高了视频推荐质量。

  • 重构了API中间层的架构,删除了超过10万行代码和数千个未使用的内部废弃接口,同时删除了一些没人用的客户端服务。

  • 将获取帖子元数据的时间减半,全局API超时错误减少90%。

  • 对外部机器人、爬虫的屏蔽,相比2022年,增长了37%。平均每天,阻止了超过100万次机器人注册,并将私信中的无用信息减少了95%。

  • 关闭了位于萨克拉门托的数据中心,重新调配了5200台机架和148000台服务器,每年为公司节约了超1亿美元,总的来说,节约了48兆瓦的电量,60000磅的网络机架。

  • 优化了对云服务厂商的使用,开始在本地进行更多的工作,这一转变使得每月的云服务成本降低60%,同时我们还将所有的媒体和大文件从云端迁出,减少了60%的云端存储空间,除此之外,还成功将云数据处理成本减少了75%。

  • 构建本地GPU超级计算集群,并设计、交付了43.2Tbps 的高性能网络架构。

  • 提升网络主干的容量和冗余性,每年节省1390万美元。

  • 开展了自动化峰值流量故障转移测试,以持续验证平台的可扩展性和可用性。


作者:云舒编程
来源:juejin.cn/post/7295397683397066762
收起阅读 »

【日常总结】iframe内嵌网站初始路由404解决

web
背景 Vue 项目被嵌入到 <iframe> 中,通过src加载页面,结果打开后直接访问404,但是如果不是通过iframe打开的话是不会出现这个问题的。<iframe src="/dist/index.html"></ifram...
继续阅读 »

背景


Vue 项目被嵌入到 <iframe> 中,通过src加载页面,结果打开后直接访问404,但是如果不是通过iframe打开的话是不会出现这个问题的。

<iframe src="/dist/index.html"></iframe>



问题现象


在首次进入时,Vue Router 没有正确地导航到首页路由,而是到了404页面。




原因


vuerouter内部match当前路径的逻辑在这种情况下有问题,所以取到的是错误的信息,找不到这个path,就跳转到了404。前端路由是要依赖当前路径来做处理的,而路径是依赖location字段,但这个时候location跟我们预期的不一样,就跳转错误。




为什么会这样?


是因为iframe 的加载是异步的,router路径跳转之前iframe还没初始化好,导致router内部获取的当前路径不对。




所以思路有2个:



  1. 路径redirect有问题,那就不依赖它,在url中增加参数设置正确的首次跳转路径

  2. 在 iframe 加载完成时,设置正确的路由信息从而导航过去。所以就从iframe加载完后重新设置导航这个关键点下手。


梳理到这里,我意识到上面两种方案是iframe的通信方式:



  • url传递信息

  • postMessage传递消息


解决方案


方案一:iframe的src的URL 参数中增加初始路由信息



  1. 在 iframe 的 src 属性中通过url参数添加首页的路由信息,
<iframe src="/dist/index.html?route=/dashboard"></iframe>


  1. 再在 Vue 应用程序初始化时,如果读取到了router则跳转过去。
import Vue from 'vue'
import App from './App.vue'
import router from './router'

// 方案1:根据添加的url参数判断
const routeParam = new URLSearchParams(window.location.search).get('route');
if (routeParam) {
router.push(routeParam);
}

new Vue({
router,
render: h => h(App),
}).$mount('#app')

我使用的这种方案。


方案二:使用 postMessage 实现父页面和iframe的通信



  1. 父页面监听iframe加载完后通过postMessage发送消息:
const iframe = document.getElementById('myIframe');
if (iframe) {
iframe.onload = function() {
iframe.contentWindow.postMessage({ route: '/dashboard' }, '*');
};
}


  1. iframe内页面main.ts中监听消息,设置路由
// 方案2:postMessage来实现iframe的通信
window.addEventListener('message', handleMessage, false);

function handleMessage(event: any) {
const { data } = event;
if (data.route) {
console.log('message', event, data.route);
router.push(data.route); // 导航到接收到的路由
}
}

这个方案,也可以fix这个bug,但是会先闪到404,再到首页,原因应该是iframe通信是要放到onmounted钩子里的,因为依赖dom加载完,所以会有个时间差。等到iframe onload时候之前已经redirect到404了,然后又重新route了下,效果不是很好。


方案三:利用路由钩子


利用 Vue Router 的导航守卫,在路由导航前检查 URL 参数中是否存在初始路由信息,如果存在则进行相应的导航处

// 方案3: 路由拦截
router.beforeEach((to, from, next) => {
if (to.matched.length === 0) {
// 如果未匹配到任何路由,说明当前页面可能是初始加载
next('/dashboard/base'); // 或者重定向到默认页面
} else {
next(); // 继续正常导航
}
});

结果


先把今天遇到的问题简单总结下:




  • 最后采用了方案1。




  • 方案2 调试出来了,但是效果会有先闪过404页面,后续又到首页的情况,效果不是很好。




  • 3没调出来,暂时不调了。

































标题优点缺点问题
url加参数简单,直接,只需要在内嵌的项目写代码url结构不好看最终使用
postmenssage不需要修改url需要内嵌iframe的页面和iframe的项目中添加额外代码会有一闪而过的效果,体验不好
beforeEach不需要修改url路由钩子每个都处理了逻辑有问题,没调试出来

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

你不知道的JSON.stringify神操

web
一、前言 在我们面试过程中,面试官经常会提及到深浅拷贝的问题。想必大多数小伙伴会说到JSON.parse(JSON.stringify(obj))。正好今天我就和大家好好唠一唠这个JSON.stringify。 二、概念 JSON.stringify对于我...
继续阅读 »

一、前言



在我们面试过程中,面试官经常会提及到深浅拷贝的问题。想必大多数小伙伴会说到JSON.parse(JSON.stringify(obj))。正好今天我就和大家好好唠一唠这个JSON.stringify



二、概念


JSON.stringify对于我们不陌生,一般用来处理序列化(深拷贝)。就是把我们的对象转换JSON字符串,此方法确实很方便在我们的工作中,但是,这个方法也会有一些弊端,只是我们不怎么遇到。


let obj = {
name: 'iyongbao'
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao"}

三、弊端


1. 对函数不友好


如果我们的对象属性是一个函数,那么在序列化的时候该属性丢失


let obj = {
name: 'iyongbao',
foo: function () {
console.log(`${ this.name }是一个小菜鸟!`)
}
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao"}

2. 对undefined不友好


如果对象的属性值是undefined,转换后会丢失


let obj = {
name: undefined
}

console.log(JSON.stringify(obj)); // {}

3. 对正则表达式不友好


如果对象的属性是一个正则表达式,转换后就会变成一个空的Object


let obj = {
name: 'iyongbao',
zoo: /^i/ig,
foo: function () {
console.log(`${ this.name }是一个小菜鸟!`)
}
}

console.log(JSON.stringify(obj)); // {"name":"iyongbao","zoo":{}}

4. 数组对象


如果是一个数组对象,以上的情况也会发生。


let arr = [
{
name: undefined
}
]

console.log(JSON.stringify(arr)); // [{}]

四、JSON.stringify拓展



说完了JSON.stringify不足,下面我们来说一下你可能没有接触过的其他特性,希望看完会对你有所帮助。



1. 接收一个数组(过滤)


其实JSON.stringify第二参数,可能我们不经常用到。我们可以传入一个数组,值就是对应我们对象key,我称之为过滤。


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, ['name']);

console.log(res); // {"name":"iyongbao"}

2. 接收一个函数


第二个参数也可以是一个函数,也是类似过滤效果


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, (key, value) => {
if (key === 'age') return undefined;
return value;
});

console.log(res); // {"name":"iyongbao","hobby":["JavaScript","Vue"]}

3. 缩进


第三个参数可以接收一个数字,表示缩进多少字符


let obj = {
name: 'iyongbao',
age: 25,
hobby: ['JavaScript', 'Vue']
}

let res = JSON.stringify(obj, null, 2);

console.log(res);

擷取.PNG


4. 自身toJSON方法


对象可以有一个自身的toJSON属性,是一个返回值方法,用来输出我们自定义的数据样式。


let obj = {
name: 'iyongbao',
age: 25,
toJSON: function () {
return {
message: `${ this.name }的年龄为${ this.age }`
}
}
}

let res = JSON.stringify(obj);

console.log(res); // {"message":"iyongbao的年龄为25"}

五、总结


好了,今天就和大家分享到这吧。一般如果真涉及到深拷贝,我还是首选自己封装一个方法或者是使用第三方插件库来做深拷贝,这样最保险,避免不必要的麻烦。


作者:勇宝趣学前端
来源:juejin.cn/post/7337989768636973083
收起阅读 »

双token和无感刷新token(简单写法,一文说明白,不墨迹)

为什么有这篇小作文?最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子项目构成后端部分:使用golang的gin框架起的服务前端部分:vue+elementu...
继续阅读 »

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811
收起阅读 »

掌握Redis核心:常用数据类型的高效运用秘籍!

在数据驱动的时代,高效地存储和处理数据成为了开发者们的重要任务。Redis,作为一个开源的高性能键值对(key-value)数据库,以其独特的数据结构和丰富的功能,成为了众多项目的首选。今天,我们就来揭开Redis的神秘面纱,看看它是如何通过不同的数据类型,为...
继续阅读 »

在数据驱动的时代,高效地存储和处理数据成为了开发者们的重要任务。Redis,作为一个开源的高性能键值对(key-value)数据库,以其独特的数据结构和丰富的功能,成为了众多项目的首选。

今天,我们就来揭开Redis的神秘面纱,看看它是如何通过不同的数据类型,为我们提供高效、灵活的数据存储和处理能力的。

一、字符串(String):数据的基石

String类型简介

字符串是Redis最基本的数据类型,它可以存储文本、数字或者二进制数据。

使用字符串类型,你可以执行原子性的操作,如追加(APPEND)、设置(SET)和获取(GET)。例如,你可以将用户信息作为字符串存储,并通过键快速检索。

  • 一个key对应一个value。

  • String类型是二进制安全的。只要内容可以使用字符串表示就可以存储到string中。比如jpg图片或者序列化的对象。

  • 一个Redis中字符串value最多可以是512M。

常用命令

set key value: 添加键值对。

Description

get key: 查询key对应的键值。

Description

注意:如果设置了两次相同的key,后设置的就会把之前的key覆盖掉。

append key value: 将给定的value追加到原值的末尾。

Description

strlen key: 获得值的长度。

Description

setnx key value: 只有在key 不存在时 ,才能设置 key 的值。

Description

incr key: 将 key 中储存的数字值增1(只能对数字值操作,如果为空,新增值为1)。

Description

decr key: 将 key 中储存的数字值减1(只能对数字值操作,如果为空,新增值为-1)。

Description

incrby / decrby key 步长: 通过自定义步长方式增减 key 中储存的数字值。

Description

mset key1 value1 key2 value2 …: 同时设置一个或多个键值对。

Description

mget key1 key2 key3 …: 同时获取一个或多个value。

Description

msetnx key1 value1 key2 value2 …: 所有给定 key 都不存在时,同时设置一个或多个 key-value 对。

注意:此操作有原子性,只要有一个不符合条件的key。其他的也都不能设置成功。如下图:
Description

getrange key 起始位置、结束位置: 获得值的范围,类似java中的substring。

Description

setrange key 起始位置 value: 用 value覆写key所储存的字符串值,从起始位置开始(索引从0开始)。

Description

setex key 过期时间 value: 可以在设置键值的同时,设置过期时间,单位秒(前面的expire是给已有的键值设置过期时间,注意区别)。

Description

getset key value: 以新换旧,设置了新值同时获得旧值。

Description

应用场景

存储用户信息: 将用户的姓名、年龄等信息作为字符串存储在Redis中,通过键值对的方式快速检索和更新。

计数器: 使用INCR命令实现访问量、点赞数等计数功能。

二、哈希(Hash):组织数据的框架

Hash类型简介

哈希类型允许你存储字段-值对的集合。这种结构非常适合于存储对象,如用户的个人信息。通过HSET和HGET命令,你可以设置和获取哈希中的字段和值。

哈希类型的优势在于它可以对字段进行原子性操作,而不需要读取整个对象。

常用命令

hset key field value: 给key集合中的 field 键赋值value。
Description

hget key1 field: 从key1集合field取出 value。

Description

hmset key1 field1 value1 field2 value2… : 批量设置hash的值(一次性设置多个数据值)。

Description

hexists key1 field: 查看哈希表 key 中,给定域 field 是否存在。

Description

hkeys key: 列出该hash集合的所有field。

Description

hvals key: 列出该hash集合的所有value。

Description

hincrby key field increment: 为哈希表 key 中的域 field 的值加上增量。

Description

hsetnx key field value: 将哈希表 key 中的域 field 的值设置为 value ,当且仅当域 field 不存在。

Description

应用场景

存储用户对象: 将用户对象的多个属性(如姓名、年龄、性别等)存储在一个哈希结构中,通过HGETALL命令获取整个对象,或使用HGET/HSET针对某个属性进行操作。

存储配置信息: 将应用程序的配置信息以字段-值对的形式存储在哈希中,方便集中管理和修改。

三、列表(List):有序数据的队列

List类型简介

列表类型提供了一种顺序存储数据的方式,它类似于Python中的列表或Java中的LinkedList。

你可以使用LPUSH和RPUSH命令在列表的头部或尾部添加元素。列表还支持范围查询和列表内元素的移除操作,非常适合于实现消息队列等场景。

  • List中单键多值,即一个key对应多个value,其中的多个value值使用List进行存储。

  • Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。

  • 它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。

常用命令

**lpush / rpush key value1 value2 value3 … :**从左边/右边插入一个或多个值(l代表left,r代表right)。

**lrange key start stop:**按照索引下标获得元素(从左到右)。

其中lrange k1 0 -1表示取出k1中全部value值(0表示左边第一个,-1代表右边第一个)。

Description

lpop / rpop key: 从左边/右边吐出一个值。值在键在,值光键亡。
pop表示把值拿出来。

Description
图片从左边取出k1的一个value值。如下图:

Description

从右边取出k2的一个value值。如下图:

Description

value值全部取完的时候,key就没有了。如下图:

Description

rpoplpush key1 key2: 从key1列表右边吐出一个值,插到key2列表左边。

Description

lindex key index: 按照索引下标获得元素(从左到右)。

Description

llen key: 获得列表长度。

Description

linsert key before value newvalue : 在value的前面插入newvalue插入值。

Description

lrem key n value: 从开始删除n个value(从左到右)图片。

Description

lset key index value: 将列表key下标为index的值替换成value。

Description

应用场景

消息队列: 使用LPUSH/RPUSH命令将待处理的消息添加到列表头部/尾部,使用LPOP/RPOP从列表中取出并处理消息。

关注列表: 存储用户关注的其他用户列表,使用LINSERT命令在列表中插入新关注的对象。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

点这里即可查看!

四、集合(Set):去重数据的集合

Set类型简介

集合类型用于存储无序且唯一的数据集合。当你需要存储不允许重复的元素时,集合是一个很好的选择。

SADD命令用于向集合中添加元素,而SMEMBERS可以获取集合中的所有元素。集合还支持交集、并集和差集等高级操作,非常适合于处理标签、好友关系等场景。

Redis的Set是string类型的无序,不可重复集合。Set底层其实是一个value为null的hash表,所以添加,删除,查找的复杂度都是0(1)。

常用命令

sadd key value1 value2 …: 将一个或多个 member 元素加入到key对应的集合中,已经存在的 member 元素将被忽略

smembers key: 取出key对应的集合中的所有值。

Description

sismember key value: 判断集合是否为含有该value值。1表示有,0表示没有。

Description

scard key: 返回key对应集合中的元素个数。

Description

srem key value1 value2…: 删除key对应的集合中的某些元素。

Description

spop key: 随机从key对应的集合中吐出一个值。

Description

srandmember key n: 随机从key对应的集合中取出n个值。不会从集合中删除 (rand即为random)。

Description

smove sourceKey destinationKey value: 把集合中一个值从一个集合移动到另一个集合。

Description

sinter key1 key2: 返回两个集合的交集元素

sunion key1 key2: 返回两个集合的并集元素。

sdiff key1 key2: 返回两个集合的差集元素(key1中的,不包含key2中的)。

Description

应用场景

好友关系: 将用户的好友列表存储在一个集合中,使用SADD命令添加好友,使用SISMEMBER判断某个用户是否为好友。

标签系统: 将文章或商品的标签存储在集合中,使用SUNION/SINTER等命令进行标签的交集、并集操作。

五、有序集合(Sorted Set)

Sorted Set类型简介

有序集合是Redis中的一个高级数据类型,它结合了集合的唯一性和列表的排序功能。

每个元素都关联一个分数(score),根据分数对元素进行排序。ZADD命令用于添加元素和分数,ZRANGE则可以获取排序后的元素列表。

注:zset是sorted set的缩写

常用命令

zadd key score1 value1 score2 value2…: 将一个或多个 member 元素及其 score 值加入到有序集 key 当中。

Description

zrange key start stop (withscores): 返回有序集 key 中,下标在start和stop之间的元素。(带withscores,可以让分数一起和值返回到结果集)。

zrange rank 0 -1 (withscores): 表示取出全部元素,从小到大排列。如下图:

Description

zrangebyscore key min max (withscores): 返回有序集 key 中所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列。

Description

zrevrangebyscore key max min (withscores): 同上,改为从大到小排列(其中rev表示reverse)。

Description

zincrby key increment value: 为元素的score加上增量。

Description

zrem key value: 删除该集合下,指定值的元素。

Description

zcount key min max: 统计该集合,分数区间内的元素个数。

Description

zrank key value: 返回该值在集合中的排名。(排在第一位的是0)

Description

应用场景

排行榜系统: 可以使用有序集合来存储用户的得分,并根据得分进行排序。

带权重的集合: 有序集合可以用来实现带权重的集合,即每个元素都有一个对应的权重值。

时间线排序: 有序集合可以用于实现时间线排序,即将事件或消息按照时间顺序进行排序。

六、总结

本篇文章我们探索了Redis的五种常用数据类型及常用命令和使用场景。每一种数据类型都有其独特的优势和适用情况,掌握它们将使你在数据处理的道路上更加得心应手。

无论是构建缓存系统,还是实现复杂的数据结构,Redis都能提供强有力的支持。希望这篇文章能帮助你更好地理解和运用Redis,让你的项目在数据的世界中脱颖而出。

收起阅读 »

听说你会架构设计?来,弄一个公交&地铁乘车系统

1. 引言 1.1 上班通勤的日常 “叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。 突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。 这个时候,通勤的老难题又摆...
继续阅读 »

1. 引言


1.1 上班通勤的日常


“叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。



突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。


这个时候,通勤的老难题又摆在了你面前:要不要吃完这口面包、刷牙和洗脸,还是先冲出门赶车?


好不容易做出了一个艰难的决定——放下面包,快步冲出门。你拿出手机,点开了熟悉的地铁乘车 App 或公交地铁乘车码小程序。


然后,一张二维码在屏幕上亮了起来,这可是你每天通勤的“敲门砖”。





你快步走到地铁站,将手机二维码扫描在闸机上,"嗖"的一声,闸机打开,你轻松通过,不再需要排队买票,不再被早高峰的拥挤闹心。


你走进地铁车厢,挤到了一个角落,拿出手机,开始计划一天的工作。


1.2 公交&地铁乘车系统


正如上文所说,人们只需要一台手机,一个二维码就可以完成上班通勤的所有事项。


那这个便捷的公交或地铁乘车系统是如何设计的呢?它背后的技术和架构是怎样支撑着你我每天的通勤生活呢?


今天让我们一起揭开这个现代都市打工人通勤小能手的面纱,深入探讨乘车系统的设计与实现


在这个文章中,小❤将带你走进乘车系统的世界,一探究竟,看看它是如何在短短几年内从科幻电影中走出来,成为我们日常生活不可或缺的一部分。


2. 需求设计


2.1 功能需求




  • 用户注册和登录: 用户可以通过手机应用或小程序注册账号,并使用账号登录系统。

  • 路线查询: 用户可以查询地铁的线路和站点信息,包括发车时间、车票价格等。

  • 获取乘车二维码: 系统根据用户的信息生成乘车二维码。

  • 获取地铁实时位置: 用户可以查询地铁的实时位置,并查看地铁离当前站台还有多久到达。

  • 乘车扫描和自动支付: 用户在入站和出站时通过扫描二维码来完成乘车,系统根据乘车里程自动计算费用并进行支付。

  • 交易记录查询: 用户可以查询自己的交易历史记录,包括乘车时间、金额、线路等信息。


2.2 乘车系统的非功能需求


乘车系统的用户量非常大,据《中国主要城市通勤检测报告-2023》数据显示,一线城市每天乘公交&地铁上班的的人数普遍超过千万,平均通勤时间在 45-60 分钟,并集中在早高峰和晚高峰时段。


所以,设计一个热点数据分布非均匀、人群分布非均匀的乘车系统时,需要考虑如下几点:



  • 用户分布不均匀,一线城市的乘车系统用户,超出普通城市几个数量级。

  • 时间分布不均匀,乘车系统的设计初衷是方便上下班通勤,所以早晚高峰的用户数会高出其它时间段几个数量级。

  • 高并发: 考虑到公交车/地铁系统可能同时有大量的用户在高峰时段使用,系统需要具备高并发处理能力。

  • 高性能: 为了提供快速的查询和支付服务,系统需要具备高性能,响应时间应尽可能短。

  • 可扩展性: 随着用户数量的增加,系统应该容易扩展,以满足未来的需求。

  • 可用性: 系统需要保证24/7的可用性,随时提供服务。

  • 安全和隐私保护: 系统需要确保用户数据的安全和隐私,包括支付信息和个人信息的保护。


3. 概要设计


3.1 核心组件




  • 前端应用: 开发手机 App 和小程序,提供用户注册、登录、查询等功能。

  • 后端服务: 设计后端服务,包括用户管理、路线查询、二维码管理、订单处理、支付系统等。

  • 数据库: 使用关系型数据库 MySQL 集群存储用户信息、路线信息、交易记录等数据。

  • 推送系统: 将乘车后的支付结果,通过在线和离线两种方式推送给用户手机上。

  • 负载均衡和消息队列: 考虑使用负载均衡和消息队列技术来提高系统性能。


3.2 乘车流程


1)用户手机与后台系统的交互


交互时序图如下:



1. 用户注册和登录: 用户首先需要在手机应用上注册并登录系统,提供个人信息,包括用户名、手机号码、支付方式等。


2. 查询乘车信息: 用户可以使用手机应用查询公交车/地铁的路线和票价信息,用户可以根据自己的出行需求选择合适的线路。


3. 生成乘车二维码: 用户登录后,系统会生成一个用于乘车的二维码,这个二维码可以在用户手机上随时查看。这个二维码是城市公交系统的通用乘车二维码,同时该码关联到用户的账户和付款方式,用户可以随时使用它乘坐任何一辆公交车或地铁。


2)用户手机与公交车的交互


交互 UML 状态图如下:




  1. 用户进站扫码: 当用户进入地铁站时,他们将手机上的乘车码扫描在进站设备上。这个设备将扫描到的乘车码发送给后台系统。

  2. 进站数据处理: 后台系统接收到进站信息后,会验证乘车码的有效性,检查用户是否有进站记录,并记录下进站的时间和地点。

  3. 用户出站扫码: 用户在乘车结束后,将手机上的乘车码扫描在出站设备上。

  4. 出站数据处理: 后台系统接收到出站信息后,会验证乘车码的有效性,检查用户是否有对应的进站记录,并记录下出站的时间和地点。


3)后台系统的处理



  1. 乘车费用计算: 基于用户的进站和出站地点以及乘车规则,后台系统计算乘车费用。这个费用可以根据不同的城市和运营商有所不同。

  2. 费用记录和扣款: 系统记录下乘车费用,并从用户的付款方式(例如,支付宝或微信钱包)中扣除费用。

  3. 乘车记录存储: 所有的乘车记录,包括进站、出站、费用等信息,被存储在乘车记录表中,以便用户查看和服务提供商进行结算。

  4. 通知用户: 如果有需要,系统可以向用户发送通知,告知他们的乘车费用已被扣除。

  5. 数据库交互: 在整个过程中,系统需要与数据库交互来存储和检索用户信息、乘车记录、费用信息等数据。


3. 详细设计


3.1 数据库设计



  • 用户信息表(User) ,包括用户ID、手机号、密码、支付方式、创建时间等。

  • 二维码表 (QRCode) ,包括二维码ID、用户ID、城市ID、生成时间、有效期及二维码数据等。

  • 车辆&地铁车次表 (Vehicle) ,包括车辆ID、车牌或地铁列车号、车型(公交、地铁)、扫描设备序列号等。

  • 乘车记录表 (TripRecord) ,包括记录ID、用户ID、车辆ID、上下车时间、起止站点等。

  • 支付记录表 (PaymentRecord) ,包括支付ID、乘车记录ID、交易时间、交易金额、支付方式、支付状态等。


以上是一些在公交车&地铁乘车系统中需要设计的数据库表及其字段的基本信息,后续可根据具体需求和系统规模,还可以进一步优化表结构和字段设计,以满足性能和扩展性要求。


详细设计除了要设计出表结构以外,我们还针对两个核心问题进行讨论:



  • 最短路线查询



  • 乘车二维码管理


3.2 最短路线查询


根据交通部门给的公交&地铁路线,我们可以绘制如下站点图:



假设图中的站点有 A-F,涉及到的交通工具有地铁 1 号线和 2 路公交,用户的起点和终点分别为 A、F 点。我们可以使用 Dijkstra 算法来求两点之间的最短路径,具体步骤为:


步骤已遍历集合未遍历集合
1选入A,此时最短路径 A->A = 0,再以 A 为中间点,开始寻找下一个邻近节点{B、C、D、E、F},其中与 A 相邻的节点有 B 和 C,AB=6,AC=3。接下来,选取较短的路径节点 C 开始遍历
2选取C,A->C=3,此时已遍历集合为{A、C},以 A 和 C 为中间点,开始寻找下一个邻近节点{B、D、E、F},其中与 A、C 相邻的节点有 B 和 D,AB=6,ACD=3+4=7。接下来,选取较短的路径节点 B 开始遍历
3选取B,A->B=6,此时已遍历集合为{A、C、B},A 相邻的节点已经遍历结束,开始寻找和 B、C 相近的节点{D、E、F},其中与 B、C 相邻的节点有 D,节点 D 在之前已经有了一个距离记录(7),现在新的可选路径是 ABD=6+5=11。显然第一个路径更短,于是将 D 的最近距离 7 加入到集合中
4选取D,A->D=7,此时已遍历集合为{A、C、B、D},寻找 D 相邻的节点{E、F},其中 DE=2,DF=3,选取最近路径的节点 E 加入集合
5选取 E,A->E=7+2=9,此时已遍历集合为{A、C、B、D、E},继续寻找 D 和 E 相近的节点{F},其中 DF=3,DEF=2+5=7,于是F的最近距离为7+3=10.
6选取F,A->F=10,此时遍历集合为{A、C、B、D、E、F}所有节点已遍历结束,从 A 点出发,它们的最近距离分别为{A=0,C=3,B=6,D=7,E=9,F=10}

在用户查询路线之前,交通部门会把公交 & 地铁的站点经纬度信息输入到路线管理系统,并根据二维的空间经纬度编码存储对应的站点信息。


我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成 4 个部分。



根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识用户或站点的位置信息。再通过 Redis 的 GeoHash 算法,来获取用户出发点附近的所有站点信息。


GeoHash 算法的原理是将一个位置的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有站点


一旦获得了起始地点的经纬度,系统就可以根据附近的站点信息,调用路线管理系统来查找最佳的公交或地铁路线。


一旦用户选择了一条路线,导航引擎启动并提供实时导航指引。导航引擎可能会使用地图数据和 GPS 定位来指导用户前往起止站点。


3.3 乘车二维码管理


乘车码是通过 QR 码(Quick Response Code)技术生成的,它比传统的 Bar Code 条形码能存更多的信息,也能表示更多的数据类型,如图所示:



二维码的生成非常简单,拿 Go 语言来举例,只需引入一个三方库:


import "github.com/skip2/go-qrcode"

func main() {
    qr,err:=qrcode.New("https://mp.weixin.qq.com",qrcode.Medium)
if err != nil {
    log.Fatal(err)
else {
    qr.BackgroundColor = color.RGBA{50,205,50,255//定义背景色
    qr.ForegroundColor = color.White //定义前景色
    qr.WriteFile(256,"./wechatgzh_qrcode.png"//转成图片保存
    }
}

以下是该功能用户和系统之间的交互、二维码信息存储、以及高并发请求处理的详细说明:



  1. 用户与系统交互: 用户首先在手机 App 上登录,系统会验证用户的身份和付款方式。一旦验证成功,系统根据用户的身份信息和付款方式,动态生成一个 QR 码,这个 QR 码包含了用户的标识信息和相关的乘车参数。

  2. 二维码信息存储: 生成的二维码信息需要在后台进行存储和关联。通常,这些信息会存储在一个专门的数据库表中,该表包含以下字段:



    • 二维码ID:主键ID,唯一标识一个二维码。

    • 用户ID:与乘车码关联的用户唯一标识。

    • 二维码数据:QR码的内容,包括用户信息和乘车参数。

    • 生成时间:二维码生成的时间戳,用于后续的验证和管理。

    • 有效期限:二维码的有效期,通常会设置一个时间限制,以保证安全性。



  3. 高并发请求处理: 在高并发情况下,大量的用户会同时生成和扫描二维码,因此需要一些策略来处理这些请求:



    • 负载均衡: 后台系统可以采用负载均衡技术,将请求分散到多个服务器上,以分担服务器的负载。

    • 缓存优化: 二维码的生成是相对耗时的操作,可以采用 Redis 来缓存已生成的二维码,避免重复生成。

    • 限制频率: 为了防止滥用,可以限制每个用户生成二维码的频率,例如,每分钟只允许生成 5  次,这可以通过限流的方式来实现。




总之,通过 QR 码技术生成乘车码,后台系统需要具备高并发处理的能力,包括负载均衡、缓存和频率限制等策略,以确保用户能够快速获得有效的乘车二维码。


同时,二维码信息需要被安全地存储和管理,比如:加密存储以保护用户的隐私和付款信息。



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



4. 乘车系统的发展


4.1 其它设计


除此之外,公交车或地铁的定位和到站时间计算可能还涉及定位设备、GPS 系统、NoSQL 数据库、用户 TCP 连接管理系统等核心组件,并通过实时数据采集、位置处理、到站时间计算和信息推送等流程来为用户提供准确的乘车信息。


同时,自动支付也是为了方便用户的重要功能,可以通过与第三方支付平台的集成来实现。


4.2 未来发展


公交车/地铁乘车系统的未来发展可以包括以下方向:



  • 智能化乘车: 引入智能设备,如人脸自动识别乘客、人脸扣款等。

  • 大数据分析: 利用大数据技术分析乘车数据,提供更好的服务。


在设计和发展过程中,也要不断考虑用户体验、性能和安全,确保系统能够满足不断增长的需求。


由于篇幅有限,文章就到此结束了。


希望读者们能对公交&地铁乘车系统的设计有更深入的了解,并和小❤一起期待未来更多的交通创新解决方案叭~


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

年,是团圆

大概是我刚上小学时候,村子里流行起来出门打工,每个家庭里面的壮年男人都会去湖北挖桩,组团去。他们过完年出发,先走路下山,坐车再坐船。 出门一段时间后,他们会寄钱回家,那时寄钱的方式我不清楚也还从没有问过,只记得家里女人们听到对门肉嗓子大喊“xxx,来拿汇票”时...
继续阅读 »

大概是我刚上小学时候,村子里流行起来出门打工,每个家庭里面的壮年男人都会去湖北挖桩,组团去。他们过完年出发,先走路下山,坐车再坐船。


出门一段时间后,他们会寄钱回家,那时寄钱的方式我不清楚也还从没有问过,只记得家里女人们听到对门肉嗓子大喊“xxx,来拿汇票”时,她们是很开心的。


快过年,男人们从外面回家,背着或挑着大件的行李,行李中是破旧的棉穗,小孩的新衣与不常见的水果糖。


挖桩需要两个人配合,一人在井里往下挖,一人在上面接桶转移土石,隔一段时间换人。有想法的男人认为他可以一直待在井里,女人提桶倒土是完全没有问题的,如果与自己的女人组团,工钱不用与别人分,挣钱的速度便会快上一倍。


于是,到我上初中时候,出门打工的规模更加扩大,家中的男人女人一起出门,小孩交给父母带着。


男人女人外出打工的时间是一样的,过完年出门,快过年时回家。


男人女人年龄渐长,他们的小孩长成大人,成人都外出打工,过完年出门,快过年时回家。


男人女人与小孩的目的地并不相同。于是过年,成为一家团圆的日子。


家家张灯结彩,人人喜笑颜开。


初一的早餐,是可以很简单的,汤圆或是包面;也可以是复杂的,蒸米饭,炸鱼、牛肉干、猪耳朵、凉拌鸡脚等许多凉菜,昨天的猪脚炖鸡汤,再配几个新鲜炒菜,满满一桌。复杂与简单的差异,来自于当天行程的安排,马上要走亲戚就简单些,家中要来客人便复杂点。


午饭,必然是丰盛的,一锅必不可少的鲜炖的猪脚炖鸡汤(是的,猪脚与鸡年三十只炖四分之一,留着客人来后能炖新的猪脚汤),一盆小孩儿最是欢迎的洋芋片,一锅人人喜爱的猪头汤清炖萝卜,两盘肥腻多汁男人们热衷的辣椒炒三线儿,一盘香肠、两碗扣肉,再来一盘炸豆腐……凉菜有猪肝、猪耳朵、鸡脚、炸鱼、牛肉干、萝卜干儿、折耳根……炒一个青菜,或是再加一碗青菜猪血汤。


男人们大都是会喝点酒的,他们互相劝着,你敬我一口酒,我回你两句话:“这酒好甜啊!”女人们偶尔也喝些酒,但更多是和小孩一起喝饮料。主妇是最后上桌吃饭的,大家吃饭时,她为桌上换热汤,为客人盛热饭。


吃过午饭,晒晒太阳烤烤火,嗑嗑瓜子吹吹牛。客人准备回家,临出发告诫主人:“你们明天一定要来啊,都要来。”


初二初三初四初五初六,是初一的重复。


隔得近,午饭的重复便是晚饭。吃过晚饭,今日不用再做饭,男人女人们围着炉子说过去说现在,说去年说当下,那“哈哈”大笑声,是时常能传到地坝的。


地坝里,孩子们的游戏一个接一个的切换。老鹰抓小鸡、三个字(一个人追众人跑,快被抓到时喊出随便的三个字,被抓者定住等待救援,抓人者切换目标;待所有人都被定住,便问“桃花开了没?”)、跳绳、打沙包……四岁五岁的,大多数游戏还玩不太明白,但他们的参与感该是最强的,因为哥哥姐姐叔叔阿姨都护着他们。


地坝里的叽叽喳喳,盖过屋子里哈哈哈哈。


年,是团圆!


作者:我要改名叫嘟嘟
来源:juejin.cn/post/7337957655190847522
收起阅读 »

年收入 100 万,不敢生孩子

今日热帖,有网友发帖称:互联网大头兵夫妇,两个人都 30+ 了,老公 xhs 后端年包 80+,我私企年薪 20 左右,老家三线城市有房。本来今年要孩子了,但我这边最近又在搞优化感觉很不稳定,老公这边还挺好,他们组的 Id 和业务线比我稳一些,不知道能不能算是...
继续阅读 »

今日热帖,有网友发帖称:互联网大头兵夫妇,两个人都 30+ 了,老公 xhs 后端年包 80+,我私企年薪 20 左右,老家三线城市有房。

本来今年要孩子了,但我这边最近又在搞优化感觉很不稳定,老公这边还挺好,他们组的 Id 和业务线比我稳一些,不知道能不能算是风险对冲。

主要焦虑的点还是怕有了孩子花销太大,不知道要不要回老家发展,还是继续在大城市坚守。

2023 年中国的出生人口是 902 万,比上一年少了 208 万,已经跌破一千万了。看到这个网友的担忧,有这个出生率也就不奇怪了。

这可是年收入超过 100 万的家庭啊,这个收入都不敢生,那还有人敢生吗?

不过话说回来,现在养娃也确实费钱。回想我小时候,哪穿过什么尿不湿,一个开裆裤就养大了。

现在呢,孩子一下生就得要个月嫂、尿不湿、吸奶棒、磨牙棒、奶粉、果泥、肉泥;长大了就开始各种兴趣班,钢琴、游泳、轮滑、英语、乐高,哪一个都得学。

怪不得都说孩子就是专业碎钞机,分分钟都在烧钱。

花钱是一方面,更重要的是会牵扯家长的精力。我听好多朋友说过,有了孩子之后,就很少睡过一个整觉。

再有一个就是教育,咱们都属于穷什么都不能穷教育,在教育上,无论多少钱都能花出去。

去年年底,好多小朋友生病,结果就是一边在医院打点滴,一边做作业,太卷了。

大人在公司卷,小孩在学校卷,卷在起跑线上。

再有一个原因就是缺乏安全感。

现在经济形势不好,公司随时都有可能裁员,一旦被裁员,就断了收入,就算有一定的积蓄,也会很焦虑。

大环境我们无法改变,只能从自身出发,调整好心态,积极乐观,从容面对。

1、明确自我定位,了解优劣势,规划未来职业道路

这也是了解自己的一个过程,了解自己的优势和劣势,目标是什么。只有目标明确了,才能走的更加坚定。

2、持续学习

这一点无需多言,活到老学到老,不管是哪一个行业,做什么类型的工作,持续学习都是必须的。

3、保持身体健康

身体是革命的本钱,特别是程序员,本来就加班很多,而且每天久坐,很多人身体都不是很好。一定要保持一定时间的运动,积极锻炼,有一个好的身体才能更好的工作。

熬过这个经济周期,就又是一条好汉。

之前看过一个说法,大概意思是说如果你现在不吃苦,不在大城市扎根,那将来就得你孩子来吃苦。

我之前感觉这句话太对了,所以一定得努力,留在大城市。但现在不这么想了,凭啥就得我吃苦?宁愿孩子吃苦,也不要自己吃苦。

过年回老家,和老家的同学和朋友聊天。我说我每天基本都是十点多下班,甚至十一点或者十二点,他们都觉得不可思议。

毕竟他们都是五点多就下班了,六点多吃完饭,就是老婆孩子热炕头了。

这可能就是世界的参差,我们总在一个环境下生活,就认为生活就是这样的,工作就是应该加班的,但实际上还有很多其他的生活方式。

就看我们有没有发现,有没有勇气去尝试。

我不想把自己陷入到大城市好,还是小城市好的争论中去,毕竟我原来是铁了心的想要在大城市,但现在已经动摇了。

还是那句话,没有哪一个选择是标准的,也没有哪一个选择是一层不变的。

但有一个原则是可以坚持的,那就是选择让自己舒服的。


作者:程序员贝塔
来源:mp.weixin.qq.com/s/aQF-FEwdLkvIPFMi3ZtKWQ

收起阅读 »

年后面试,最好不要有这几种心态

大家好,我是老三,大家新年好,我在朋友圈看到有朋友已经在大张旗鼓地“内卷”,为年后的面试做准备。 成功的面试常常是源于实力+运气,失败的面试可能会有各种各样的原因,知识点的盲区、和面试官不对眼、经验不匹配……很多东西我们是没法控制的,只能尽量做好自己能做的——...
继续阅读 »

大家好,我是老三,大家新年好,我在朋友圈看到有朋友已经在大张旗鼓地“内卷”,为年后的面试做准备。


成功的面试常常是源于实力+运气,失败的面试可能会有各种各样的原因,知识点的盲区、和面试官不对眼、经验不匹配……很多东西我们是没法控制的,只能尽量做好自己能做的——八股更熟一点、算法多刷一刷,硬技能相关的暂且不多聊,我们今天聊一聊,心态相关的,哪些心态,是求职面试中最好不要有的。


我得找个特别完美的工作


“钱多事少干的爽”,几乎是所有打工人的终极梦想,但是回归现实,真有这样的工作,凭什么流转到普通人身上呢?


有人可能会说,某某在XX公司,你看干的轻松,年终还发的贼多,真爽啊。这其实可以算幸存者偏差,我们有时候只能看到经过某种筛选而产生的结果,比如今年微信视频发了十几个月的年终,一度在各个公众号刷屏——我们可能觉得微信给的钱真多啊!但其实想想,哪怕同属WXG,除了微信视频,其它的部门也有这么“钱多”吗?


“钱多”、“事少”、“干的爽”,三者里面能满足一个就很不错了,钱多一般事也多,事少一般钱也少,”干的爽“更是一个非常主观的因素,自己不去干一干真不知道,干了也可以调整。


老三经历了几次跳槽,找工作的心理预期没那么高,为什么呢?我过去的工作经历基本上从低位到高位跳的一个过程,但是有的同学比较优秀,可能一出道就是在高位,刚工作就是大厂SSP。


这些有实力的同学,也许也会面临一个幸福的烦恼,就像谈恋爱,如果一个女生的初恋特别优秀,她后面可能会单身很久,因为她发现很难找到比初恋更优秀的男朋友。“曾经沧海难为水,除却巫山不是云”。


经济学上有一个“锚定效应”:



人们需要对某个事件做定量估测时,会将某些特定数值作为起始值,起始值像锚一样制约着估测值。在做决策的时候,会不自觉地给予最初获得的信息过多的重视。



有这么好的“初恋”,一般他们都不会轻易“分手”,但是假如遇到了一些波动,主观或者客观的原因,导致“分手”,那么在找下一份工作的时候,可能需要正视“锚定”的因素。


我们找工作的时候,也会去“锚定”,和现在的工作去比,和其它人的工作去比,但其实,相比评估价值,可能找准自己的需求更加重要,钱多发展职级大厂背书业务匹配稳定工作生活平衡 ……可以像排工作任务一样,排一下需求的优先级,哪些是必须的,哪些是可以妥协的。


找工作也和找对象类似,我有一个哥们,特别喜欢古力娜扎,可能很多人的女神也是迪丽热巴、古力娜扎、马尔扎哈……但是应该没有普通人会把自己找对象的目标定在这些女神头上。这就涉及到“吸引力”和“可得性”的平衡,很多时候,一份好的工作,对我们的“吸引力”是巨大的,但是我们也不得不承认它的“可得性”是极低的。


外包,是互联网软件行业一个比较普遍,而且越来越普遍的用工方式,有的朋友在外包——“你为什么要做外包呢?”,我觉得一般人也不会问这个问题,这就像之前有个主持人:“为什么不吃肉呢?因为肉不好吃吗?”生活所迫嘛,我们很多时候,只是在可选的选择里选一个相对最优的。


尤其是在现在整体行情不景气,如果真的没有办法,比如空窗了很久,妥协也是可以接受的,华为进不去OD也能凑合。


在经济学上,资源的配置常常达不到最优,找工作也是一样,能在一定可达性的基础上,通过努力获取次优的选择,已经很不错了。


“幻想不是理想”,这个世界上少有完美的工作,也少有完美的人生,我们很多时候,都要接受这些不完美,做一些适当的取舍。


留下还是跑路,好纠结


45度的人生最难受。


我去年经历了很纠结的时刻,当时的工作满足不了我的需求,但是我又不敢跑路,因为市场的寒气实在让人望而却步,就在这种纠结中,我错过了最好的面试备战和简历投递的时机,当时的第一目标快手,等我十月份觉得可以面一面的时候,HC已经锁了一个月了。


最怕的就是这种纠结的心态,工作投入不进去,面试也准备不好。还是那句话,找准现在的问题和需求。


第一,我现在的工作是不是已经非常让我难以接受了?有哪些点让我难以接受?是客观的原因还是主观的原因?我有没有尝试最大的努力去解决?我有没有寻求别人的帮助?


第二,现在这份工作的问题解决不了,我能通过跳槽解决吗?是不是换一个公司,换一个环境,就能解决或者缓解我的问题?什么样的公司能解决或者缓解我的问题?这样的公司对我的可得性大概有多少?


想清楚这两点,就能确认自己离开的意向,留下就好好干,努力去解决问题;要走就做好规划,挤出时间准备。


第三,工作和面试,不是绝对冲突的。只要有想离开的想法,再忙每天也能抽点时间出来准备的,把刷抖音,打游戏的时间拿一部分出来准备面试,做好规划,每天时间充足就拉短周期,每天时间少就拉长周期。


其实很多的时候的纠结,只是给自己找放纵的借口,“其实,我不是特别想走”,“外面现在行情很差,没什么机会”,这样一来,就可以心安理得地玩游戏、刷抖音,至于面试准备,后面再说吧。


我也经历了这样当鸵鸟的时期,直到一些关系比较好的同事因为各种原因离开,再加上一些裁员的传言,才彻底打醒了我。


打破纠结的最好办法就是行动,最怕的就是找个借口,让自己停在了原地。


这个面试没啥,随便面面


面试有自信是一件好事,比如老三,可能会觉得自己的八股比较熟;有的朋友,可能会觉得,自己的算法比较溜;有的觉得自己的项目比较亮眼。


自信,不是轻敌。我有个朋友吃了这样的亏,面飞猪的时候,随便约了个时间,当时那个时间是在旅行中,感觉的时候觉得很简单,结果挂了,事后一问,面试官说根本听不清;后面他面快手,也吃了同样的亏,因为没怎么准备,一面挂在了自己最擅长的算法上。


同样,面试,难和简单,也是一个很主观的感觉,别人感觉简单的,自己未必简单,还是那个朋友,他面飞猪,相对简单,我面飞猪,写了两道题,几乎问到了所有知识点的八股,足足面了九十多分钟。


认真对待每一场面试,现在这个环境,每一个机会都是非常难得的,很多面试机会,错过了就不再有。每一场面试,都尽量拿比较好的状态去面。


有的人说,我不咋想去,只是练手,练手也要认真一点,就像模拟考,也得好好考。而且现在不想去,可能只是了解不多,后面如果了解多了,未必不是完全不想去。


完全准备好了再面


面试,永远没有完全准备好的时候。很多时候,觉得准备个七八成的时候,就可以去面了。


第一,很多岗位的窗口期是很短的,在现在这种僧多粥少的局面下,一个岗位可能很短的时间就关闭了,如果不及时地去面,可能就会失去这个机会。


第二,面试永远会有答不上来的问题,面试本身就是个非对称的场景,以面试官之长,攻己之短,面试,也有一些运气的成分在里面。


第三,准备六个月,未必效果比三个月好,人的投入,是有边际递减的效应的,短时间高效率的准备,也许比拉长战线更有效果。


机会来了,估计着差不多就冲,我有个同事,很短的时间准备,但是因为年底的机会很好,也认真地准备了,一年多的经验,拿下了美团和京东的Offer。


我和面试官是对立的


面试是一场交流,一头是面试官,一头是候选人,有这么一个段子,面试是”三分天注定,七分靠打拼,九十分看面试官心情“,我和这样一个能一票否决我的人,我应该怎么打交道呢?


对立情绪是不可取的,在考试这个逻辑的下面,其实我们和面试官还有合作的逻辑。我以前作为面试官,面了几十个人,面到后面,我的感受是什么呢?赶紧招一个合适的人选吧,别让我面试了。


在这种关系里,面试官也是有需求的,面试官希望尽快招到合适的人,让自己早点摆脱面试对自己的干扰。面试官也是有投入的,毕竟面试的时间,基本都不会算进工作排期。


既然双方都有需求和投入,当然候选人的需求和投入会更多,那么这段关系就不是完全从上到下俯视的,我觉得最好的面对面试官的心态,就是萍水相逢的路人,尊重但不谄媚、存异但是和谐、热情但有边界,如果有缘份的话,我们可能会成为真正的朋友,如果没有缘分那就相忘江湖。


很多人说,程序员都不太擅长交流,但至少请保持礼貌,“您好”,“谢谢”,“不好意思”,“感谢你的时间”,我觉得这些礼貌用语,应该小孩子都会吧。


面试,不仅有硬实力的碰撞,也有人情世故的交融。


面试官让我不爽


我在之前,某个博主的文章里,看到他在某个公司的三面挂了,因为感觉和面试官不太对味。根据我的经验,其实三面很少挂人,这种,就是和面试官的”碰撞“激烈了一些。


我们应该明白一个道理:在面试的流程中,我们是没有挑选的权利的,只有拿到Offer后,我们才是真正有挑选的权利。


这个世界我们不认同的东西多了,要么改变它,要么适应它,这句话不太好听,既然选择了打工,那大概率是只能适应的。


面试里,面试官说的东西,我们可能不认同,面试官的态度甚至可能不友好,我觉得我们能做的,就是保持礼貌:


“对对,你是对的。”


我去年面西二旗某厂,三面面试官给我的感觉是无知且蛮横,她的很多问题都让我无言以对,但是我还是全程保持了微笑,努力保持了风度,没想到最后面试过了。


如果你真的看面试官不爽,在Offer后拒绝他们,不比在面试阶段,闹得不欢而散,最后只能心里默默骂着傻叉要痛快?


面的不好,放弃吧


面试永远有我们不会的问题,这是正常的。可能在某个问题卡壳之后,心态就慢慢崩掉,破罐子破摔。这时候,一定要给自己一些积极的心理暗示,面试直接发生在面试官和候选人之间,间接发生在候选人和候选人之间。


“我这种八股文职业选手都答不好,其他人更不行了。”


而且,答的不好,未必就是没戏,很多时候,面试官可能通过一些开放性的问题或者压力面的方式,考察候选人的广度深度,思维方式。


所以,哪怕偶有一个点答不好,也一定要绷住心态,我之前作为面试官,面试实习生,一个候选人,坦白说,她面的不好,同事的实习生,他觉得可以,那就通过吧,结果告知她面试通过后,那个候选人心态崩了,放弃了后面的面试。


最差不过挂了,最坏的结果都能接受,那就朝最好的方向尝试努力。


失败了好几次,没信心了


我们经常在网上,刷到一些“Offer收割机”“吊打面试官”,但是经过我本人的实践,反正我是做不到,比如美团,我面了四个岗位才拿到Offer。


面试失败,是一件很正常的事情,“面了不一定能过,过了不一定能Offer”,我们可能因为各种原因挂掉。但是,千万不能因为失败几次,就灰心丧气,面一次总结一次,查缺补漏,面的多了,自然会面了,就能面过了。


而且面试也有运气的成分,此路不通,未必别路也不通。


很多人恐惧面试,其实我也怕面试,每次面试前,都很忐忑,但是想一想,最差不过挂了,越淡定的面试发挥地越好,越紧张的面试发挥地越差。


在挫折里不断进步,保持心态,才有机会拿到好的结果。






工作是为了更好地生活,生活是为了快乐和幸福,每个人的快乐和幸福都是自己定义的,祝大家既能找到好的工作,也能享受生活的快乐和幸福。






参考:


[1].《长的好看,能当饭吃吗:提升认知的33个经济学常识》


[2].《经济学原理》






作者:三分恶
来源:juejin.cn/post/7334623638116057099
收起阅读 »

前端又又出新框架,这次没有打包了

web
最近,前端开发领域又迎来了一个新框架——ofa.js。它的独特之处在于,不依赖于现有的 nodes/npm/webpack 前端开发工作流程。与jQuery类似,只需引用一个脚本,您就能像使用React/Vue/Angular一样轻松地开发大型应用。 极易上...
继续阅读 »

最近,前端开发领域又迎来了一个新框架——ofa.js。它的独特之处在于,不依赖于现有的 nodes/npm/webpack 前端开发工作流程。与jQuery类似,只需引用一个脚本,您就能像使用React/Vue/Angular一样轻松地开发大型应用。


punch-logo.png


极易上手


如果您要开发简单的项目,想要用一个漂亮的按钮组件,例如 Ant Design 中的 Button组件,你需要学习Node.js、NPM和React等知识,才能开始使用该按钮组件。对于非前端开发者或初学者来说,这将是一个漫长的过程。


如果使用基于ofa.js 开发的组件,就不需要这么复杂了;你只需要了解HTML的基础知识(即使不看ofa.js的文档),也可以轻松使用基于ofa.js开发的组件。以下是使用官方的 punch-logo 代码示例:


<!-- 引入ofa.js到您的项目 -->
<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>

<!-- 加载预先开发的punch-logo组件 -->
<l-m src="https://ofajs.github.io/ofa-v4-docs/docs/publics/comps/punch-logo.html"></l-m>

<!-- 使用punch-logo组件 -->
<punch-logo style="margin: 50px 0 0 100px">
<img
src="https://ofajs.github.io/ofa-v4-docs/docs/publics/logo.svg"
logo
height="90"
/>

<h2>不加班了</h2>
<p slot="fly">下班给我</p>
<p slot="fly">迟点下班</p>
<p slot="fly">周末加班</p>
</punch-logo>

punch-demo.gif


你可以最直接拷贝上面的代码,放到一个空白的html文件内运行试试;这使得ofa.js非常容易与传统的Web开发技术栈相融合。


一步封装组件


封装组件同样非常简单,只需一个HTML文件即可实现。以下是一个官方封装的开关(switch)组件示例:


<!-- my-switch.html -->
<template component>
<style>
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
background-color: #ccc;
transition: background-color 0.4s;
border-radius: 34px;
cursor: pointer;
}

.slider {
position: absolute;
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: transform 0.4s;
border-radius: 50%;
}

.switch.checked {
background-color: #2196f3;
}

.switch.checked .slider {
transform: translateX(26px);
}
</style>
<div class="switch" class:checked="checked" on:click="checked = !checked">
<span class="slider"></span>
</div>
<script>
export default {
tag: "my-switch",
data: {
checked: true,
},
};
</script>
</template>

在使用时,只需使用 l-m 组件引用它:


<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>
<l-m src="./my-switch.html"></l-m>
<my-switch></my-switch>

switch.gif


示例可以在官方网站下方查看。由于无需打包流程,只需将文件上传到静态服务器即可发布,还可以进行跨域引用,这极大降低了组件共享的成本。


多种模板语法糖


ofa.js与Vue和Angular一样提供了许多模板语法糖,主要包括:



  • 文本渲染

  • 属性绑定/双向绑定

  • 事件绑定

  • 条件渲染

  • 列表渲染

  • ...


具体案例可在官网向下滚动至“提供多样便捷的模板语法”处查看。


天生的状态同步高手


与其他框架不同,ofa.js 使用无感状态同步。这意味着数据不需要通过函数操作,只需设置数据对象即可实现状态同步。以下是一个共享黑夜模式的按钮示例:


// is-dark.js
const isDark = $.stanz({
value: false,
});

export default isDark;

<!-- my-button.html -->
<template component>
<style>
:host {
display: block;
}

.container {
display: inline-block;
padding: 0.5em 1em;
color: white;
border-radius: 6px;
background-color: blue;
cursor: pointer;
user-select: none;
}
.container.dark {
background-color: red;
}
</style>
<div class="container" class:dark="isDark.value">
<slot></slot>
</div>
<script>
import isDark from "./is-dark.js";
export default {
data: {
isDark: {},
},
attached() {
// 共享dark对象数据
this.isDark = isDark;
},
detached() {
// 清除内存记录
this.isDark = {};
},
};
</script>
</template>

sync-state.gif


您可以跳转到 状态同步案例 以查看效果。


最简单的表单操作


表单只需调用formData方法,就能生成自动同步数据的对象:


<form id="myForm">
<input type="text" name="username" value="John Doe" />
<div>
sex:
<label>
man
<input type="radio" name="sex" value="man" />
</label>
<label>
woman
<input type radio="radio" name="sex" value="woman" />
</label>
</div>
<textarea name="message">Hello World!</textarea>
</form>
<br />
<div id="logger"></div>
<script>
const data = $("#myForm").formData();

$("#logger").text = data;
data.watch(() => {
$("#logger").text = data;
});
</script>

form1.gif


您还可以轻松地反向设置表单数据:


<form id="myForm">
<input type="text" name="username" value="John Doe" />
<div>
sex:
<label>
man
<input type="radio" name="sex" value="man" />
</label>
<label>
woman
<input type="radio" name="sex" value="woman" />
</label>
</div>
<textarea name="message">Hello World!</textarea>
</form>
<br />
<div id="logger"></div>
<script>
const data = $("#myForm").formData();

setTimeout(() => {
// 反向设置数据
data.username = "Yao";
data.sex = "man";
data.message = "ofa.js is good!";
}, 1000);
</script>

form2.gif


制作自定义表单组件也没有其他框架那么复杂,只需为组件定义valuename属性即可。


具体效果可跳转至formData API查看。


开发应用


您还可以使用ofa.js开发Web应用,然后直接引用已开发的应用到您的网页上:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>应用演示</title>
<script src="https://cdn.jsdelivr.net/gh/kirakiray/ofa.js/dist/ofa.min.js"></script>
</head>
<body>
<o-app src="https://xxxxx.com/app-config.mjs"> </o-app>
</body>
</html>

具体效果可跳转至使用o-app组件查看。


SCSR


官方提供了一种类似服务端渲染的解决方案,它不仅保证了用户体验,还使页面在静态状态下可被搜索引擎爬取。官网采用了SCSR的方案。



SCSR的全称是Static Client-Side Rendering,又称为静态客户端渲染。它是CSR(Client-Side Rendering)的一种变种,在保留了CSR用户体验的基础上,还能够让页面在静态状态下被搜索引擎爬取。



您可以点击SCSR方案以查看详细信息。


代码简洁


当前版本4.3.29的 ofa.min.js 文件仅有52KB,经过gzip压缩后仅有18KB。


其他


最近升级到了v4版本,目前可用的第三方库还比较有限,但以后将逐渐增加。作者正在准备开发基于ofa.js的UI库。


作者是一位中国开发者,快去给 ofa.js 加星吧!


作者:皮卡怪兽
来源:juejin.cn/post/7295576148364460071
收起阅读 »

全世界都想让程序员专心写业务代码

最近两年我不知道你是否和我拥有同样的感受,编程容易了许多,同时乐趣也少了许多 一 最近有两个契机让我写这篇文章,一是过去一年我陆续把我所有 side project 后端从 Azure App Service 迁移到 Digital Oce...
继续阅读 »

最近两年我不知道你是否和我拥有同样的感受,编程容易了许多,同时乐趣也少了许多



最近有两个契机让我写这篇文章,一是过去一年我陆续把我所有 side project 后端从 Azure App Service 迁移到 Digital Ocean(以下简称 DC) 的 Droplet(Virtual Machine),再迁移到 DC 的 K8S,目的是剥离应用内托管型(managed)资源;前端顺便也从 Azure Static Web Apps 迁移到 Vercel;二是最近读完《Competing in the Age of AI》,其中关于于 operation model 的论述与近期迁移应用的感受不谋而合


书中把公司价值划分为两部分,首先是商业模型(business model),即为消费者带来了何种价值;其次是运营模型(operation model),即如何将价值传递到消费者手中。运营的难点不在交付本身,而在于如何大规模(scale)、大范围(scope)交付,并且持续对交付方法改进和创新(learning)


同样,如果代码的价值在于上线(通俗说派上用场),无论是对自己还是客户还是用户,那么交付对我们来说同样重要,以上所说的大规模、大范围、以及保证持续改进的交付难点也同样成立。


先上线,剩下的以后再说



你们有没有想过 Netlify 和 Vercel 究竟是做什么生意的?如果你的答案依然是 JAM stack,只能说明你已经 out 了。不妨去看看 Netlify 官网的 slogan,甚至不用是 slogan,看看网站首页的 title 写的是什么:



Scale & Ship Faster with a Composable Web Architecture



Netlify 所做的正如上述所标榜的,帮助代码更快的交付上线。换而言之,它们售卖的的是一站式 web 应用的部署解决方案,除 host 和流水线外,还提供免费的用户行为分析、页面性能监控、日志等工具,以及 Redis/SQL/Blob 数据库。


Azure 不是也提供类似的服务吗?问题的关键在于不同平台间的开发者体验,Vercel 能够真正做到上述服务一键集成,你会明显感觉到 Vercel 在加速你的交付,让你更快得到对于产品的反馈。而在 Azure 下开发更像是赤裸裸的买卖,Azure 团队按照他们的理解提供一个最低限度可以使用产品,至于多好用,多大程度能和其它 Azure 服务契合则不在他们的考虑范围内。如下图所示在 Vercel 的 dashboard 上,你可以纵览所有从部署状态到线上监测一系列信息。试想一下如果在 Azure 上集成所有对应的功能并呈现类似的 dashboard 需要付出多少外的精力


vercel-screenshot.png



再简单聊聊 DevSpace。这里的 DevSpace,也可以是 tilt,可以是 skaffoldtelepresence


GitOps 无疑是伟大的,但这种伟大里依然充满了“一板一眼”——我的一次上线需要经过 push 代码、创建 release、推送镜像到GHCR、更新 yaml、通过 ArgoCD 部署。我理解它们是 necessay evil。


相比之下 DevSpace 看上去是反流程的,它可以帮助你在本地直接创建镜像并执行部署,还允许热更新容器内文件。即便是创建镜像,我发现它利用也非是既有的 pipeline,而是借助 buildkit 在我的集群上创建一个 local registry 并将镜像储存在此。


别搞糊涂了,生产环境和开发环境具有完全相反的气质,我们希望生产环境稳定、可追溯,这是 GitOps 的优势所在,但开发过程具有破坏性、需要不停试错,DevSpace 着重解决的则是开发体验问题,对于代码修改它可以带来高效的反馈。


compiling-joke.png


上图已经流传了很多年,图中的 compiling 可以替换成 uploading, deploying,running test 等等。过去十年它是事实,现在应该只是一个笑话



在 Vercel 和 DevSapce上我们看到了一类区别于传统的开发方式,这里没有 pipeline,没有 IaC,没有手动的统计埋点,无需申请第三方资源,从 GitHub 上导入 repo 的那一刻起,它自动为你匹配部署脚本,寻找项目入口。开发者所需要做的,就是专心编写他所需要的业务代码


模板化的项目是带来这些便利的主要原因,继续往上追溯,主流前端框架自带 CLI 工具的流行功不可没,从大到创建一个新项目小到执行编译,命令行一键即可完成。无论是 React,Vue 还是 Angular,你都不会在官方的教程里找到专门的章节教你为项目编写 webpack 或者 vite 的配置。也许是因为习惯了早年间凡事都需要自己动手的工作方式,我花了很长时间才想通并接受这件事:传统从0到1搭建项目的能力已不再重要。


越来越多的环节像一个个黑盒出现在开发流程中,在此之前如果我对 pipeline 感兴趣,我可以阅读 .github 文件夹中的代码看 action 是如何定义的,而现在我连机会都没有了。连同代码被一同干掉的还有程序员的编程乐趣。站在交付的角度上来说这种乐趣并没有什么道理:为什么我要花上几天的时间只为了研究如何写一段 yaml 把代码送上线运行,更何况这段代码带来不了任何产品收益。


最近另一个类似的体验是,Next.js 编写起来太无趣了,不是说它不够好,而是问题在于它太好到你只能这么写。说起来有些贱,我很怀念那个一种功能有十几个写法,然后这十几个写法还有鄙视链的“坏”时光


当我们再度审视代码的生命周期,你会发现所有人努力的目标,是想让程序员尽可能少的关心核心业务代码以外的事情。一切一切都在想方设法优化运营模型。很明显个性化配置和重复劳动在规模化前是不受欢迎的



也许换个思路会好受一些:当你加入一家公司之后,发现同等体量的应用利用市面上的工具可以做到随时上线;而你们的产品却只能选择在每个周六凌晨部署,并需要若干人值班守候,这听上去让人有些失望是不是。


cover.jpg


作者:李熠
来源:juejin.cn/post/7336538738750013440
收起阅读 »

我收到的最好的职业建议

Nicholas C. Zakas 是全世界最著名的 JavaScript 程序员之一。 今天突然看到大神在13年写的一篇长文,回顾自己的职业生涯,提到七个对他来说最重要的建议 个人觉得每一条都很受益;所以决定翻写出来;做一个学习记录。 一、Don’t be...
继续阅读 »

image.png
Nicholas C. Zakas 是全世界最著名的 JavaScript 程序员之一。


今天突然看到大神在13年写的一篇长文,回顾自己的职业生涯,提到七个对他来说最重要的建议


个人觉得每一条都很受益;所以决定翻写出来;做一个学习记录。




一、Don’t be a short-order cook 不要做一个点菜厨师



你要别人点什么;你就做什么。积极表达自己的见解



我的第一份工作持续了8个月,因为公司倒闭了。当我和我的经理谈论我下一步该怎么做时,他给了我这样的建议:


尼古拉斯,你的价值不仅仅在于你的代码。无论你的下一个工作是什么,一定要确保你不是一个按需制作的厨师。不要接受那种你被告知要构建什么以及如何构建的工作。你需要在一个既欣赏你对产品的见解,又欣赏你构建能力的地方工作。


这是我在整个职业生涯中一直牢记的事情。仅仅做一个实现者是不够的--你需要参与到实现的过程中。优秀的工程师不只是服从命令,他们会给产品负责人给予反馈,并与他们合作,使产品更好。幸运的是,我明智地选择了我的工作,从来没有在人们不尊重或不重视我的见解的情况下结束。


个人反思:在选择一份工作的时候,一定要有自主权;这个自主权可以是自己争取的;通过表达意见以及个人反馈来进行争取。侧面也说明需要自己对相关系统有更深的理解。


二、Self-promote 自我推销



推销自己



进入雅虎后;有一天,我在雅虎的团队经理把我拉到一边,给予我一些建议。他一直在看我的代码和产出内容,告诉我,觉得我有点缺失:


你做得很好。我是说真的很棒。我喜欢你的代码,它很少有bug。但是; 问题是别人看不到这一点。为了让你的工作得到认可,你必须让别人知道。你需要做一些自我推销来引起注意。


我花了一点时间来消化他说的话,但我终于弄明白了。如果你做了很好的工作,但没有人知道你做了很好的工作,那么它并没有真正帮助你。你的经理可以支持你,但不能为你辩护。组织内部的人需要了解你的价值,最好的方法就是告诉人们你做了什么。


个人反思:做一个角落里静静编码的工程师,并不可取。你的主管会支持你,但是他没法替你宣传。公司的其他人需要明白你的价值,最好的办法就是告诉别人你做了什么。一封简单的Email:"嗨,我完成了XXX,欢迎将你的想法告诉我",就很管用。


三、It’s about people 关注人员,学会团队相处



学会带领团队



在我职业生涯的早期,我非常注重头衔。我一直想知道我必须做些什么才能得到晋升。在雅虎主页上与我的新经理进行第一次一对一的交谈时,我问我需要什么才能获得晋升。他的话至今仍在我耳边回响:


当你的技术能力过关以后,就要考验你与他人相处的能力了。


从那时起,我不确定我是否对软件工程专业有了更好的了解。他是完全正确的。那时,没有人质疑我的技术能力。众所周知,我是一个写出优秀、高质量代码的人,很少有错误。我缺乏的是领导能力


从那时起,我看到无数工程师在他们的职业生涯中被困在一个层面上。聪明的人,优秀的代码,但无法与他人有效合作,使他们留在原地。每当有人觉得自己被困在软件工程生涯中时,我都会讲述这个建议,而且它总是在金钱上是正确的。


个人反思:在团队工作中,除了完成自己的工作后;更多需要学会与团队成员的相处,这决定了你是否能够走向管理;当然;领导能力也是自己需要认真培养和学习的。


四、 of this matters 这些都不重要



生活才是最重要的



我在雅虎度过了一段令人沮丧的时期。也许沮丧不是正确的词,更像是生气。我愤怒地爆发,经常与人争吵。事情出了问题,我不喜欢这样。在一个特别艰难的一天,我问我的一位导师,当很多事情出错时,他是如何保持冷静的。他的回答是:


这很简单。你看,这些都不重要。所以一些蹩脚的代码被签入了,所以网站宕机了。那又怎样?工作不可能是你的全部生活。这些不是真正的问题,而是工作问题。真正重要的是工作之外发生的事情。我回家,我的妻子在等我。这真是太好了


我从马萨诸塞州搬到加利福尼亚,很难交到朋友。工作是我的生命,它让我保持理智,所以当它不顺利时,这意味着我的生活不顺利。这次谈话让我意识到我的生活中必须有其他事情发生,我可以回去忘记我在工作中遇到的麻烦。


他是对的,一旦我改变了思维方式,将工作中烦人的事情重新归类为“工作事情”,我就能够更清楚地思考。我能够在工作中冷静下来,与人进行更愉快的互动。


个人反思:把工作和生活分开,工作不是你的全部;学会分清楚这个是工作问题;不要把工作问题带出来的情绪带入到生活中。


五、Authority, your way 自主,自己决策



找到自己道路



当我第一次被提升为雅虎的首席工程师时,我和我的主管坐下来,以更好地了解这个角色的含义。我知道我必须更像一个领导者,但我很难自主决策。我寻求帮助。他是这样说的:


以前都是我们告诉你做什么,从现在开始,你必须自己回答这个问题了,我期待你来告诉我,什么事情需要做。


很多工程师都没有完成这个转变,如果能够做到,可能就说明你成熟了,学会了取舍。你不可能把时间花在所有事情上面,必须找到一个重点。


个人反思:你需要慢慢的学会自主决策,成为专家。找到自己的风格;并显现出来。


六、Act like you’re in charge 把自己当成主人


我每天要开很多会,有些会议我根本无话可说。我对一个朋友说,我不知道自己为什么要参加这个会,也没有什么可以贡献,他说:


"不要再去开这样的会了。你参加一个会,那是因为你参与了某件事。如果不确定自己为什么要在场,就停下来问。如果这件事不需要你,就离开。不要从头到尾都静静地参加一个会,要把自己当成负责人,大家会相信你的。"


从那时起,我从没有一声不发地参加会议。我确保只参加那些需要我参加的会议。


七、找到水平更高的人


最后,让我从自己的经历出发,给我的读者一个建议。


"找到那些比你水平更高、更聪明的人,尽量和他们在一起,吃饭或者喝咖啡,向他们讨教,了解他们拥有的知识。你的职业,甚至你的生活,都会因此变得更好。"


作者:GleanWu
来源:juejin.cn/post/7337227407365423131
收起阅读 »

在上海做程序员这么多年,退休后我的工资是多少?

大家好,我是拭心。 最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。 之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。 吸取...
继续阅读 »

image.png


大家好,我是拭心。


最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。


之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。


吸取教训,作为在上海工作多年的打工人,为了老有所依,我花了些时间学习了养老金相基本知识,并且估算了一下退休后每月能拿到的钱,这篇文章来聊聊。


文章主要内容:



  1. 如何能在上海领退休工资

  2. 我退休后大概能领多少钱

  3. 退休工资的组成


如何能在上海领退休工资


image.png


上海作为全国 GDP 第一的城市,居民的收入也是很可观的。平均工资在 2023 年达到了 12183,平均退休工资在全国也位列前茅:


image.png


从上图可以看到,上海的平均退休工资居然有五千多!不用早起不用挤地铁,躺在家里每月就能领五千多,花不完,根本花不完啊!


那么问题来了,我们如何能领到上海的退休工资?


主要有 2 个条件:



  1. 达到退休年龄:男性满 60 周岁,女性满 50 周岁(灵活就业人员需要满 55 周岁)

  2. 退休前累计缴费社保 >= 180 个月(也就是 15 年),其中在上海至少缴满 120 个月(10 年)


第二点很关键:在上海需要缴满社保 10 年,上海 + 其他地方累计需要缴满 15 年。 比如小张在上海工作并缴纳社保满 10 年,然后去青岛缴纳 5 年,最后可以在上海领退休工资;但如果在青岛缴纳了 10 年,在上海缴纳了 5 年,就无法在上海领退休工资了。


需要强调的是,这里说的是「累计缴满」,即使中间有断开也没关系。


还有一个细节是,发工资不等于缴纳社保,个别不正规公司会漏缴社保,这需要我们打工人自己多关注。 身边有朋友遇到过:刚毕业加入的公司规模很小,人力资源不靠谱,干了半年多只缴纳了两个月社保。


怎么看公司有没有给自己缴社保呢?我们可以从随申办上查询:


image.png


OK,这就是在上海领退休工资的条件。


退休后大概能领多少钱


掐指一算,社保缴满 15 年的任务我已经完成了一半,但还有二十九年才能领钱,心里苦啊😭。


虽然拿不到,但我对能领多少钱还是非常好奇的,究竟比平均退休工资高还是被平均?🤔


经过一番搜索,我终于发现了退休工资的计算方法。


国家社会保险公共服务平台网站中,有一个「企业职工养老保险待遇测算」的功能:


image.png



国家社会保险公共服务平台 -> 养老保险 -> 企业职工养老保险待遇测算


链接:si.12333.gov.cn/157569.jhtm…



我们只需要输入年龄、预计退休年龄、当前缴纳年限、目前及之前平均工资、养老保险个人账户余额及未来大概工资即可测算退休工资。


如果不知道你的「养老保险个人账户余额」有多少,可以从随申办 app 搜索「养老金」查询余额:


image.png


在填完所有需要的信息后,我的预算结果是这样的:


image.png


我的天,个十百千万,居然有两万五?这还花的完??


几秒后冷静下来,我才发现算错了。。。


两万五应该是这样算的:按照当前收入,再连续缴纳 29 年。 😂


image.png


对于我们程序员,保持收入 29 年基本是不可能的,我还是重新调整参数再看看吧。


我现在社保缴纳了七年半,如果缴够 15 年社保,退休后我能领多少钱呢?


image.png


答案是一万四!看着还不错哈,每天能有 480 元左右,就是不知道 30 年后的物价怎么样了😂。


如果再悲观一点,社保缴纳到 35 岁(然后最低标准缴够 15 年),退休后大概能领多少呢?


image.png


答案是一万元!


看了下人民币的贬值率,30 年后的一万元不知道有没有今天三千块钱的购买力😷。


OK,这就是我退休后大概能领到的工资范围。


退休工资的组成


从上面的预算结果中我们可以看到,养老金由三部分组成:基础养老金、个人账户养老金和过渡性养老金,它们都是什么意思呢?


1.基础养老金 = 退休时平均工资 ×(1+平均缴费指数)÷ 2 × 累计缴费年限 × 1%。


退休时平均工资指的是退休时所在地区上年度的社会平均工资。也就是说经济发达地区的基础养老保险金,要高于欠发达地区。


平均缴费指数指的参保人选择的缴纳比例(一般在0.6-3之间)。每个月社保的缴费比例越高,相应的基础养老金越高。


例如,小张退休时,上年度的社会平均工资是 12000。虎虎的缴费指数平均值是 1,累计缴存了15年,他的基础养老金约为:12000*(1+1)/2 * 15 * 1% = 1800。


2.个人账户养老金 = 养老保险个人账户累计金额 ÷ 养老金计发月数。


我们缴纳社保时,一部分会进入个人社保账户,一部分会进入国家统筹账户。个人账户的部分,直接影响退休养老金的计算。


计发月数和我们退休的年龄有直接的关系,退休的越晚,计发月数越少;退休的时间越早,计发月数越多。一般来说,按照 60岁 退休,计发月数是 139 个月。



退休金的计发月数只是用来计算退休金,而不是说只能领这么久的退休金。



例如,小张社保的个人缴纳比例为 8%,社保的计算基数是 9339,他选择在 60 岁退休,那他的个人账户养老金约为:9339 * 8% * 12 * 15 / 139 = 967.49。


3.过渡性养老金 = 退休时平均工资 × 建立个人账户前的缴费年限 × 1.3% × 平均缴费指数


过渡性养老金,是指在养老保险制度发生变化(比如缴费标准提高、计算方法改变、退休年龄调整)的时候,给予受影响群体的资金补充。


这个奖金的计算规则说法不一,一种比较广泛的计算方法是:退休时所在地区的平均工资 x 缴费指数 x 缴费年限 x 过渡系数,其中过渡系数大概在 1% 到 1.4% 之间。


OK,这就是养老金三部分组成的含义。


总结


好了,这篇文章到这里就结束了,主要讲了:



  1. 如何能上海领退休工资:缴纳 10~15 年社保,到达退休年龄

  2. 我退休后大概能领多少钱:30 年后的一万左右

  3. 退休工资的三部分组成:基础养老金、个人账户养老金和过渡性养老金


通过写这篇文章,我对养老金的认识更多了一些,希望国家繁荣昌盛,让我退休的时候能多领点钱!


作者:张拭心
来源:juejin.cn/post/7327480122407141388
收起阅读 »

22点下班的地铁上闲看发现一个现象...求解

打工人写代码写到晚上十点😭 匆匆忙忙赶上地铁,累的手机也不想玩,因为眼睛累,也不想坐因为坐了一天了,闲的蛋疼,也不知道怎么的,脑子里就出了这么个想法😓 我发现了一个奇怪的现象,无论是大学生,还是普通的上班族,或者城市写字楼里的白领,现在很多人,包括年轻人,脚上...
继续阅读 »

打工人写代码写到晚上十点😭
匆匆忙忙赶上地铁,累的手机也不想玩,因为眼睛累,也不想坐因为坐了一天了,闲的蛋疼,也不知道怎么的,脑子里就出了这么个想法😓


我发现了一个奇怪的现象,无论是大学生,还是普通的上班族,或者城市写字楼里的白领,现在很多人,包括年轻人,脚上穿的大部分都是运动鞋,很少有人穿皮鞋了,年轻人好像只有在结婚的当天,才会去穿皮鞋。


我记得,以前穿皮鞋才叫时尚,出门的时候,一个阳光的年轻人,为了给人一个好的形象,都会把皮鞋擦的增明透亮,上面再配上西装、西裤,走起路来,咔咔地响,那才叫一个酷,女孩子穿皮鞋的更多,上身穿什么的都有,脚上穿的基本上都是高跟皮鞋,走起路来,左扭一下,右扭一下,在大街上会很吸人眼球的。


不知从何时起,皮鞋突然失宠了,人们都喜欢穿运动鞋了,商场里售卖的鞋子,大部分也都是运动鞋,基本上看不到皮鞋的影子了。


前几天,我就去了一趟卖鞋的商场,里面销售的鞋子,品牌很多,但几乎全是运动鞋,包括特步、鸿星尔克、李宁、安踏等等,还有一些叫不出名字洋品牌。


我自己也已经有十多年没有穿皮鞋的习惯了,穿的也都是运动鞋,这是为什么呢


作者:threerocks
来源:juejin.cn/post/7337867671096885286
收起阅读 »

前端要懂的Docker部分

前言 最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到... Docker开始 简介 Docker是一个应用打包,分发,...
继续阅读 »

前言


最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到...


Docker开始


简介


Docker是一个应用打包,分发,部署的工具,它就相当于一个容器,将你想要的一些依赖、第三方库、环境啥的和代码进行一块打包为镜像,并上传到镜像仓库里面,这样别人可以直接从镜像仓库里面直接拉去这个代码来进行运行,可以适应不同的电脑环境,打造了一个完全封闭的环境。



  • 打包:将软件运行所需要的依赖、第三方库、软件打包到一起,变成一个安装包

  • 分发:将你打包好的安装包上传到一个镜像 仓库,其他人可以很方便的获取安装

  • 部署:拿着安装包就可以一个命令运行起来你的应用,自动模拟出一摸一样的运行环境


为什么使用Docker



Docker 的出现主要是为了解决以下问题:“在我的机器上运行正常,但为什么到你的机器上就运行不正常了?”。



平常开发的时候,项目在本地跑的好好的,但是其他人想要在他的电脑上去跑你的应用程序,但是却跑不起来,他得配置数据库,Web 服务器,插件啥的,非常不方便。Docker的出现解决了这些问题。


基本术语


镜像:理解为软件安装包,可以方便的进行传播和安装,它包含了运行应用程序所需的所有元素,包括代码、运行时环境、库、环境变量和配置文件。



  • 镜像可以通过Dockerfile来进行创建

  • Dockerfile就相当于一个脚本,编写一些命令,他会识别这些命令并且执行


容器:软件安装后的状态,每个软件运行的环境都是独立的、隔离的,称之为容器。


仓库:仓库是存放 Docker 镜像的地方。仓库允许你分享你的镜像,你可以将你的镜像推送(push)到仓库,也可以从仓库拉取(pull)其他人分享的镜像。



  • Docker 提供了一个公共的仓库 Docker Hub,你可以在上面上传你的镜像,或者寻找你需要的镜像。


常见命令



  • docker run: 用于从 Docker 镜像启动一个容器。例如,docker run -p 8080:80 -d my-app 将从名为 "my-app" 的 Docker 镜像启动一个新的容器,并将容器的 8080 端口映射到主机的 80 端口。

  • docker build: 用于从 Dockerfile 构建 Docker 镜像。例如,docker build -t my-app . 将使用当前目录中的 Dockerfile 构建一个名为 "my-app" 的 Docker 镜像。

  • docker pull: 用于从 Docker Hub 或其他 Docker 注册服务器下载 Docker 镜像。例如,docker pull nginx 将从 Docker Hub 下载官方的 Nginx 镜像。

  • docker push: 用于将 Docker 镜像推送到 Docker Hub 或其他 Docker 注册服务器。例如,docker push my-app 将名为 "my-app" 的 Docker 镜像推送到你的 Docker Hub 账户。

  • docker ps: 用于列出正在运行的 Docker 容器。添加 -a 选项(docker ps -a)可以列出所有容器,包括已停止的。

  • docker stop: 用于停止正在运行的 Docker 容器。例如,docker stop my-container 将停止名为 "my-container" 的 Docker 容器。

  • docker rm: 用于删除 Docker 容器。例如,docker rm my-container 将删除名为 "my-container" 的 Docker 容器。

  • docker rmi: 用于删除 Docker 镜像。例如,docker rmi my-app 将删除名为 "my-app" 的 Docker 镜像。

  • docker logs: 用于查看 Docker 容器的日志。例如,docker logs my-container 将显示名为 "my-container" 的 Docker 容器的日志。

  • docker exec: 用于在正在运行的 Docker 容器中执行命令。例如,docker exec -it my-container bash 将在名为 "my-container" 的 Docker 容器中启动一个 bash shell。
    docker常见命令



ps:不会使用就:docker load --help



编写Dockerfile


FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'

FROM:指定基础镜像


RUN:一般指安装的过程


COPY:拷贝本地文件到镜像的指定目录


ENV:环境变量


EXPOSE:指定容器运行时监听到的端口,是给镜像的使用者看的


ENTRYPOINT:镜像中应用的启动命令,容器运行时调用


编写完Dockerfile生成镜像:


docker build -t test:v1 .


运行镜像:


docker run -p 8080:8080 --name test-hello test:v1


意思:跑在8080端口将test:v1命名为text-hello


之后你可以发布到上面说的仓库里面




  • 命令行登录账号: docker login -u username

  • 新建一个tag,名字必须跟你注册账号一样 docker tag test:v1 username/test:v1

  • 推上去 docker push username/test:v1

  • 部署试下 docker run -dp 8080:8080 username/test:v1


实践一下Docker部署前端项目


核心思想:


将前端打包的dist放到nginx里面,然后添加一个nginx.conf文件。


docker的话就是多了一个Dockerfile文件来构建镜像而已。


nginx.conf文件的编写


#nginx.conf文件编写
#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
#监听的端口
listen 8748;
#请填写绑定证书的域名或者IP
server_name 121.199.29.3;

gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}


Dockerfile文件的编写


# Dockerfile文件

FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'


然后将这些压缩成zip(或者本地打包为镜像,然后上传到镜像仓库里面,在服务器里面拉取镜像就可以了),然后传到服务器中,进行解压,构建镜像并且运行镜像来进行前端部署。


部署node项目


Dockerfile的编写


FROM node:11
MAINTAINER Merikle

#
复制代码
ADD . /mongo-server

#
设置容器启动后的默认运行目录
WORKDIR /mongo-server

#
运行命令,安装依赖
# RUN 命令可以有多个,但是可以用 && 连接多个命令来减少层级。
# 例如 RUN npm install && cd /app && mkdir logs
RUN npm install --registry=https://registry.npm.taobao.org

#
CMD 指令只能一个,是容器启动后执行的命令,算是程序的入口。
# 如果还需要运行其他命令可以用 && 连接,也可以写成一个shell脚本去执行。
# 例如 CMD cd /app && ./start.sh
CMD node app.js

然后构建镜像


发布镜像到hub上面


在服务器上面进行拉取镜像并且运行镜像。


部署mongodb


这个还没有部署,之后再说啦。


作者:Charlotten
来源:juejin.cn/post/7332290436345905187
收起阅读 »

千分位分隔?一个vue指令搞定

web
说在前面 🎈对数字进行千分位分隔应该是大部分同学都做过的功能了吧,常规的做法通常是编写一个工具函数来对数据进行转换,那么我们可不可以通过vue指令来实现这一功能呢? 效果展示 实现原理 非输入框 非输入框我们只需要对其展示进行处理,我们可以判断绑定元素...
继续阅读 »

说在前面



🎈对数字进行千分位分隔应该是大部分同学都做过的功能了吧,常规的做法通常是编写一个工具函数来对数据进行转换,那么我们可不可以通过vue指令来实现这一功能呢?



效果展示




实现原理


非输入框


非输入框我们只需要对其展示进行处理,我们可以判断绑定元素的innerHTML是否不为空,不为空的话则直接对其innerHTML内容进行格式化。


export default {
bind: function (el, binding) {
const separator = binding.value || ",";
if (el.innerHTML) {
el.innerHTML = addThousandSeparator(el.innerText, separator);
}
},
};

输入框



对于输入框,我们希望其有以下功能:



1、输入的时候去掉分隔符


这里我们只需要监听元素的聚焦(focus)事件即可,取到元素的值,将其分隔符去掉后重新赋值。


el.addEventListener("focus", (event) => {
const value = event.target.value;
event.target.value = deleteThousandSeparator(value, separator);
});

2、输入完成后添加分隔符


这里我们只需要监听元素的失焦(blur)事件即可,取到元素的值,对其进行添加分隔符处理后重新赋值。


el.addEventListener("blur", (event) => {
const value = event.target.value;
event.target.value = addThousandSeparator(value, separator);
});

千分位分隔函数


function addThousandSeparator(num, separator = ",") {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
}


  • num.toString(): 将输入的数字 num 转换为字符串,以便后续处理。

  • .replace(/\B(?=(\d{3})+(?!\d))/g, separator): 这里使用了正则表达式进行替换操作。具体解释如下:



    • \B: 表示非单词边界,用于匹配不在单词边界处的位置。

    • (?=(\d{3})+(?!\d)): 使用正向预查来匹配每三位数字的位置,但不匹配末尾不足三位的数字。

    • (\d{3})+: 匹配连续的三位数字。

    • separator: 作为参数传入的分隔符,默认为 ,。

    • g: 表示全局匹配,即匹配所有满足条件的位置。




这样,通过正则表达式的替换功能,在数字字符串中的每三位数字之间插入指定的千位分隔符,从而实现千位分隔符的添加。


去掉千分位分隔


function deleteThousandSeparator(numberString, separator = ",") {
return numberString.replace(new RegExp(separator, "g"), "");
}

直接将字符串中的分隔符全部替换为空即可。


完整代码


function addThousandSeparator(num, separator = ",") {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
}
function deleteThousandSeparator(numberString, separator = ",") {
return numberString.replace(new RegExp(separator, "g"), "");
}
export default {
bind: function (el, binding) {
const separator = binding.value || ",";
if (el.innerHTML) {
el.innerHTML = addThousandSeparator(el.innerText, separator);
}
el.addEventListener("focus", (event) => {
const value = event.target.value;
event.target.value = deleteThousandSeparator(value, separator);
});
el.addEventListener("blur", (event) => {
const value = event.target.value;
event.target.value = addThousandSeparator(value, separator);
});
},
};


组件库


组件文档


目前该组件也已经收录到我的组件库,组件文档地址如下:
jyeontu.xyz/jvuewheel/#…


组件内容


组件库中还有许多好玩有趣的组件,如:



  • 悬浮按钮

  • 评论组件

  • 词云

  • 瀑布流照片容器

  • 视频动态封面

  • 3D轮播图

  • web桌宠

  • 贡献度面板

  • 拖拽上传

  • 自动补全输入框

  • 图片滑块验证


等等……


组件库源码


组件库已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt…


作者:JYeontu
来源:juejin.cn/post/7337125978802683956
收起阅读 »

SQL中为什么不要使用1=1

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。 编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢...
继续阅读 »

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。


编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢?


为什么会使用 1=1?


在动态构建SQL查询时,开发者可能会不确定最终需要哪些条件。这时候,他们就会使用“1=1”作为一个始终为真的条件,让接下来的所有条件都可以方便地用“AND”连接起来,就像是搭积木的时候先放一个基座,其他的积木块都可以在这个基座上叠加。


就像下边这样:


SELECT * FROM table WHERE 1=1
<if test="username != null">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>

这样就不用在增加每个条件之前先判断是否需要添加“AND”。


1=1 带来的问题


性能问题


我们先来了解一下数据库查询优化器的工作原理。查询优化器就像是一个聪明的图书管理员,它知道如何最快地找到你需要的书籍。当你告诉它所需书籍的特征时,它会根据这些信息选择最快的检索路径。比如你要查询作者是“谭浩强”的书籍,它就选择先通过作者索引找到书籍索引,再通过书籍索引找到对应的书籍,而不是费力的把所有的书籍遍历一遍。


但是,如果我们告诉它一些无关紧要的信息,比如“我要一本书,它是一本书”,这并不会帮助管理员更快地找到书,反而可能会让他觉得困惑。一个带有“1=1”的查询可能会让数据库去检查每一条记录是否满足这个始终为真的条件,这就像是图书管理员不得不检查每一本书来确认它们都是书一样,显然是一种浪费。


不过这实际上可能也不会产生问题,因为现代数据库的查询优化器已经非常智能,它们通常能够识别出像 1=1 这样的恒真条件,并在执行查询计划时优化掉它们。在许多情况下,即使查询中包含了1=1,数据库的性能也不会受到太大影响,优化器会在实际执行查询时将其忽略。


代码质量


不过,我们仍然需要避免在查询中包含 1=1,有以下几点考虑:



  1. 代码清晰性:即使数据库可以优化掉这样的条件,但对于阅读SQL代码的人来说,1=1可能会造成困惑。代码的可读性和清晰性非常重要,特别是在团队协作的环境中。

  2. 习惯养成:即使在当前的数据库系统中1=1不会带来性能问题,习惯了写不必要的代码可能会在其他情况下引入实际的性能问题。比如,更复杂的无用条件可能不会那么容易被优化掉。

  3. 优化器的限制:虽然现代优化器很强大,但它们并不是万能的。在某些复杂的查询场景中,即使是简单的 1=1 也可能对优化器的决策造成不必要的影响,比如索引的使用。

  4. 跨数据库兼容性:不同的数据库管理系统(DBMS)可能有不同的优化器能力。一个系统可能轻松优化掉1=1,而另一个系统则可能不那么高效。编写不依赖于特定优化器行为的SQL语句是一个好习惯。


编写尽可能高效、清晰和准确的SQL语句,不仅有助于保持代码的质量,也让代码具有更好的可维护性和可扩展性。


替代 1=1 的更佳做法


现在开发者普遍使用ORM框架来操作数据库了,还在完全手写拼SQL的同学可能需要反思下了,这里给两个不同ORM框架下替代1=1的方法。


假设我们有一个用户信息表 user,并希望根据传入的参数动态地过滤用户。


首先是Mybatis


<!-- MyBatis映射文件片段 -->
<select id="selectUsersByConditions" parameterType="map" resultType="com.example.User">
SELECT * FROM user
<where>
<!-- 使用if标签动态添加条件 -->
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>
<!-- 更多条件... -->
</where>
</select>

在 MyBatis 中,避免使用 WHERE 1=1 的典型方法是利用动态SQL标签(如 )来构建条件查询。 标签会自动处理首条条件前的 AND 或 OR。当没有满足条件的 或其他条件标签时, 标签内部的所有内容将被忽略,从而不会生成多余的 AND 或 WHERE 子句。


再看看 Entity Framework 的方法:


var query = context.User.AsQueryable();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(b => b.UserName.Contains(username));
}
if (age>0)
{
query = query.Where(b => b.Age = age);
}
var users = query.ToList();

这是一种函数式编程的写法,最终生成SQL时,框架会决定是否在条件前增加AND,而不需要人为的增加 1=1。


总结


“1=1”在SQL语句中可能看起来无害,但实际上它是一种不良的编程习惯,可能会导致性能下降。就像在做饭时不会无缘无故地多加调料一样,我们在编写SQL语句时也应该避免添加无意义的条件。


每一行代码都应该有它存在的理由,不要让你的数据库像一个困惑的图书管理员,浪费时间在不必要的事情上。


作者:萤火架构
来源:juejin.cn/post/7337513754970095667
收起阅读 »

uniapp根据不同的环境配置不同的运行基础路径

web
前言当我们使用uniapp开发同一个项目发布不同的环境二级路径不同时,这时候我们就要根据环境来添加运行的基础路径product:xxx-product-api.xxx.com:9002/productConf…text:xxx-text-api.xxx.com...
继续阅读 »

前言

当我们使用uniapp开发同一个项目发布不同的环境二级路径不同时,这时候我们就要根据环境来添加运行的基础路径

product:xxx-product-api.xxx.com:9002/productConf…

text:xxx-text-api.xxx.com:9002/textConfig/

但是当我们使用HBuilderX开发时你会发现manifest.json手动配置Web配置时只能配置一个像这种情况

image.png

碰到这种情况你会怎么处理?

你是不是会在每次打包发布之前变更该环境对应基础路径?

这样也是一种方法,不过其过程太繁琐,废话不多说,上正文!!!

正文

当我们使用HX创建项目时项目中是没有package.json文件和vue.config.js文件的

  1. 在根目录下创建package.json文件,用于配置多个环境也可用于Hx自定义发行
{
"id": "sin-signature",
"name": "签名组件-兼容H5、小程序、APP",
"version": "1.0.0",
"description": "用于uni-app的签名组件,支持H5、小程序、APP,可导出svg矢量图片。",
"keywords": ["签名,签字,svg,canvas"],

"uni-app": {
"scripts": {
"h5-dev": {
"title": "H5-DEV",
"env": {
"NODE_ENV": "development",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "http://192.168.3.3:8081"
},
"define": {
"H5": true
}
},
"h5-xx": {
"title": "H5-XX",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "http://xxx.xx.xx.xxx:8092"
},
"define": {
"H5": true
}
},
"h5-test": {
"title": "H5-TEST",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "https://beta-text-api.nextopen.cn"
},
"define": {
"H5": true
}
},
"h5-prod": {
"title": "H5-PROD",
"env": {
"NODE_ENV": "production",
"UNI_PLATFORM": "h5",
"VUE_APP_BASE_API": "https://product-api.nextopen.cn"
},
"define": {
"H5": true
}
},

}
}
  1. 在根目录下创建vue.config.js文件,用于处理不同环境配置不同的基础路径
const fs = require('fs')
//此处如果是用HBuilderX创建的项目manifest.json文件在项目跟目录,如果是 cli 创建的则在 src 下,这里要注意
//process.env.UNI_INPUT_DIR为项目所在的绝对路径,经测试,相对路径会找不到文件
const manifestPath = process.env.UNI_INPUT_DIR + '/manifest.json'
let Manifest = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
function replaceManifest(path, value) {
const arr = path.split('.')
const len = arr.length
const lastItem = arr[len - 1]

let i = 0
let ManifestArr = Manifest.split(/\n/)

for (let index = 0; index < ManifestArr.length; index++) {
const item = ManifestArr[index]
if (new RegExp(`"${arr[i]}"`).test(item)) ++i;
if (i === len) {
const hasComma = /,/.test(item)
ManifestArr[index] = item.replace(new RegExp(`"${lastItem}"[\\s\\S]*:[\\s\\S]*`), `"${lastItem}": ${value}${hasComma ? ',' : ''}`)
break;
}
}

Manifest = ManifestArr.join('\n')
}

// 动态修改 h5 路由 base
if (process.UNI_SCRIPT_ENV?.NODE_ENV === 'text'){
//测试的 base
replaceManifest('h5.router.base', '"/textConfig/"')
}else if (process.UNI_SCRIPT_ENV?.NODE_ENV === 'product'){
//生产的 base
replaceManifest('h5.router.base', '"/productConfig/"')
}else {
/其他的 base
replaceManifest('h5.router.base', '""')
}

fs.writeFileSync(manifestPath, Manifest, {
"flag": "w"
})

参考uniapp官方文档:uniapp.dcloud.net.cn/collocation…


作者:快乐是Happy
来源:juejin.cn/post/7337208702201086002
收起阅读 »

程序员的副业发展

前言 之前总有小伙伴问我,现在没有工作,或者想在空闲时间做一些程序员兼职,怎么做,做什么,能赚点外快 因为我之前发别的文章的时候有捎带着说过一嘴我做一些副业,这里就说一下我是怎么做的,都做了什么 希望能对你有些帮助~ 正文 学生单 学生单是我接过最多的,已经写...
继续阅读 »

前言


之前总有小伙伴问我,现在没有工作,或者想在空闲时间做一些程序员兼职,怎么做,做什么,能赚点外快


因为我之前发别的文章的时候有捎带着说过一嘴我做一些副业,这里就说一下我是怎么做的,都做了什么


希望能对你有些帮助~


正文


学生单


学生单是我接过最多的,已经写了100多份毕设,上百份大作业了,这里给大家介绍一下


python这种的数据处理的大作业也很多,但是我个人不太会,所以没结过,我只说我做过的


我大致做过几种单子,最多的是学生的单子,分为大作业单子毕设单子


大作业单一般指一个小作业,比如:



  • 几个web界面(大多是html、css、js)

  • 一个全栈的小demo,大多是jsp+SSM或者vue+springboot,之所以不算是毕设是因为,页面的数目不多,数据库表少,而且后端也很简单


我不知道掘金这里能不能说价格,以防万一我就不说大致价格了,大家想参考价格可以去tb或者咸鱼之类的打听就行


然后最多的就是毕设单子,一般就是一个全栈的项目



  • 最多的是vue+springboot的项目,需求量特别大,这里说一下,之前基本都是vue2的项目,现在很多学校要求vue3了,但是大部分商家vue3的模板很少,所以tb上接vue3的项目要么少,要么很贵,所以我觉得能接vue3和springboot项目的可以打一定的价格战,vue2的市面上价格差不多,模板差不多,不好竞争的

  • 少数vue+node的全栈项目,一般是express或者koa,价格和springboot差不多,但是需求量特别少

  • uni+vue+springboot的项目,其实和vue+springboot项目差不多,因为单纯的vue+springboot项目太多了,所以现在很多人要求做个uni手机端,需求量适中

  • .net项目,信管专业的学生用.net的很多,需求量不少,有会的可以考虑一下


这是我接过的比较多的项目,数据库我没有单说,基本上都是MySQL,然后会要求几张表,以及主从表有几对,这就看客户具体要求了



需要注意的点:大部分你得给客户配环境,跑程序,还是就是毕设一般是要求论文的,有论文的会比单纯程序赚的多,但是一定要注意对方是否要求查重,如果要求查重,一般是不建议接的,一般都是要求维普和知网查重,会要了你的老命。还有需要注意的是,学生单子一般是需要答辩的,你可以选择是否包答辩,当然可以调整价格,但是你一旦包答辩,你的微信在答辩期间就不会停了。你永远不知道他们会有怎样的问题



商业单


商业单有大有小,小的跟毕设差不多,大的需要签合同


我接的单子大致就一种,小程序+后台管理+后端,也就是一个大型的全栈项目,要比学生单复杂,而且你还要打包、部署、上线,售后,有一个周期性,时间也比较长


72761aa2847097aa719f2c9728dc560.jpg


image.png


ff5d9aaae6207ab8cbbe847c73cbd36.jpg


9e157d5ddab294d3214fa1d8ece07dc.jpg


为了防止大家不信,稍微放几个聊天记录,是这两个月来找的,也没有给自己打广告,大家都是开发者,开发个小程序有什么打广告,可吹的(真的是被杠怕了)


技术栈有两种情况:自己定客户定


UI也有两种情况:有设计图的无设计图的(也就是自己设计)


基本上也就是两种客户:懂技术的客户,不懂技术的客户


指定技术栈的我就不说了,对于不指定技术栈的我大致分为两种



  • 小程序端:uni/小程序原生、后台:vue、后端:云开发

  • 小程序端:uni/小程序原生、后台:vue、后端:springboot


这取决于预算,预算高的就用springboot、预算不高的就云开发一把嗦,需要说的是并不是说云开发差,其实现在云开发已经满足绝大部分的需求,很完善了,而springboot则是应用广泛,客户后期找别人接手更方便


对于没有UI设计图的,我会选择去各种设计网站去找一些灵感


当项目达到一定金额,会签署合同,预付定金,这是对双方的一种保障


其实在整个项目中比较费劲的是沟通,不是单独说与客户的沟通,更多的是三方沟通,作为上线的程序,需要一些资料手续,这样就需要三方沟通,同时还有一定的周期,可能会被催


讲解单


当然,有的时候人家是有程序的,可能是别人代写的,可能是从开源扒下来的,这个时候客户有程序,但是看不懂,他们可能需要答辩,所以会花钱找人给他们梳理一下,讲一讲, 这种情况比较简单,因为不需要你去写代码,但是需要你能看懂别人的代码


这种情况不在少数,尤其是在小红书这种单子特别多,来钱快,我一般是按照小时收费


cb519bce3fedc451116b659f6cb7388.jpg


e4531c4d8d6527208a03e1dcc6ede32.jpg


aef2baeabe8859caac59fd7ae0b456c.jpg


知识付费这东西很有意思,有时候你回答别人的一些问题,对方也会象征性地给你个几十的红包


接单渠道


我觉得相对于什么单,大家更在意的是怎么接单,很多人都接不到单,这才是最难受的


其实对此我个人并没有太好的建议的方法,我认为最重要的,还是你的交际能力,你在现实中不善于交际,网络上也不善于交际,那就很难了


因为我之前是在学校,在校期间干过一些兼职,所以认识的同学比较多,同时自身能力还可以,所以会有很多人来找,然后做完之后,熟人之间会慢慢介绍,人就越来越多,所以我不太担心能否接单这件事,反而是单太多,自己甚至成立一个小型工作室去接单


如果你是学生的话,一定要在学校积累客户,这样会越来越多,哪怕是现在我还看到学校的各种群天天有毕业很多年以及社会人士来打广告呢,你为什么就不可以呢


当然但是很多人现在已经不是学生了,也不知道怎么接触学生,那么我给大家推荐另外的道路



  • 闲鱼接单

  • 小红书接单


大部分学生找的写手都会比较贵,这种情况下,很多学生都会选择去上面的两个平台去货比三家,那么你的机会就来了


有人说不行啊,这种平台发接单帖子就被删了,那么你就想,为什么那么多人没被删,我也没被删,为什么你被删除了


其次是我最不推荐的一种接单方式:tb写手


为什么不推荐呢,其实就是tb去接单,然后会在tb写手群外包给写手,也就是tb在赚你的差价


这种感觉很难受,而且赚的不多,但是如果你找不到别的渠道,也可以尝试一下


最后


我只是分享一下自己接单的方式,但是说实在的,接一个毕设单或者是商业单其实挺累的,不是说技术层面的,更多的是心累,大家自行体会吧,而且现在商场内卷严重,甚至有人200、300就一个小程序。。。


所以大家要想,走什么渠道,拿什么竞争


另外,像什么猪八戒这种的外包项目的网站,我只是见过,但是没实际用过,接过,所以不好评价


希望大家赚钱顺利,私单是一种赚钱的方式,但是是不稳定的,一定还是要以自己本身的工作为主,自行判断~


作者:Shaka
来源:juejin.cn/post/7297124052174848036
收起阅读 »

新年 10 个面试题,我曾 10 次拷问我的灵魂

web
大家好,这里是大家的林语冰。坚持阅读,自律打卡,每天一次,进步一点。 免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 10 Interview Questions Every JavaScript Developer S...
继续阅读 »

大家好,这里是大家的林语冰。坚持阅读,自律打卡,每天一次,进步一点



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 10 Interview Questions Every JavaScript Developer Should Know in 2024



本期共享的是 —— 新年里前端面试需要掌握的十大面试题,知识面虽小,但思路清晰。


JS 的世界日新月异,多年来面试趋势也与时俱进。本文科普了新年每个 JS 开发者必知必会十大基本问题,涵盖了从闭包到 TDD(测试驱动开发)的一系列主题,为大家提供应对现代 JS 挑战的知识和信心。


1. 闭包到底是什么鬼物?


闭包让我们有权从内部函数访问外部函数的作用域。当函数嵌套时,内部函数可以访问外部函数作用域中声明的变量,即使外部函数返回后也是如此:


const createCat = cat => {
return {
getCat: () => cat,
setCat: newCat => {
cat = newCat
}
}
}

const myCat = createCat('薛定谔')
console.log(myCat.getCat()) // 薛定谔

myCat.setCat('龙猫')
console.log(myCat.getCat()) // 龙猫

闭包变量是对外部作用域变量的实时引用,而不是拷贝。这意味着,如果变更外部作用域变量,那么变更会反映在闭包变量中,反之亦然,这意味着,在同一外部函数中声明的其他函数将可以访问这些变更。


闭包的常见用例包括但不限于:



  • 数据隐藏

  • 柯里化和偏函数(经常用于改进函数组合,比如形参化 Express 中间件或 React 高阶组件)

  • 与事件处理程序和回调共享数据


数据隐藏


封装是面向对象编程的一个重要特征。封装允许我们向外界隐藏类的实现细节。JS 中的闭包允许我们声明对象的私有变量:


// 数据隐藏
const createGirlFans = () => {
let fans = 0
return {
increment: () => ++fans,
decrement: () => --fans,
getFans: () => fans
}
}

柯里化函数和偏函数:


// 一个柯里化函数一次接受多个参数。
const add = a => b => a + b

// 偏函数是已经应用了某些参数的函数,
// 但没有完全应用所有参数。
const increment = add(1) // 偏函数

increment(2) // 3

2. 纯函数是什么鬼物?


纯函数在函数式编程中兹事体大。纯函数是可预测的,这使得它们比非纯函数更易理解、调试和测试。纯函数遵循两个规则:



  1. 确定性 —— 给定相同的输入,纯函数会始终返回相同的输出。

  2. 无副作用 —— 副作用是在被调用函数外部可观察到的、不是其返回值的任何 App 状态更改。


非确定性函数依赖于以下各项的函数,包括但不限于:



  • 随机数生成器

  • 可以改变状态的全局变量

  • 可以改变状态的参数

  • 当前系统时间


副作用包括但不限于:



  • 修改任何外部变量或对象属性(比如全局变量或父函数作用域链中的变量)

  • 打印到控制台

  • 写入屏幕、文件或网络

  • 报错。相反,该函数应该返回表明错误的结果

  • 触发任何外部进程


在 Redux 中,所有 reducer 都必须是纯函数。如果不是,App 的状态不可预测,且时间旅行调试等功能无法奏效。reducer 函数中的杂质还可能导致难以追踪的错误,包括过时的 React 组件状态。


3. 函数组合是什么鬼物?


函数组合是组合两个或多个函数,产生新函数或执行某些计算的过程:(f ∘ g)(x) = f(g(x))


const compose = (f, g) => x => f(g(x))

const g = num => num + 1
const f = num => num * 2

const h = compose(f, g)

h(20) // 42

React 开发者可通过函数组合来清理大型组件树。我们可以将它们组合,创建一个新的高阶组件,而不是嵌套组件,该组件可以通过附加功能强化传递给它的任何组件。


4. 函数式编程是什么鬼物?


函数式编程是一种使用纯函数作为主要组合单元的编程范式。组合在软件开发中兹事体大,几乎所有编程范式都是根据它们使用的组合单元来命名的:



  • 面向对象编程使用对象作为组合单元

  • 过程式编程使用过程作为组合单元

  • 函数式编程使用函数作为组合单元


函数式编程是一种声明式编程范式,这意味着,程序是根据它们做什么,而不是如何做来编写的。这使得函数式程序比命令式程序更容理解、调试和测试。它们往往更加简洁,这降低了代码复杂性,并使其更易维护。


函数式编程的其他关键方面包括但不限于:



  • 不变性 —— 不可变数据结构比可变数据结构更易推理

  • 高阶函数 —— 将其他函数作为参数或返回函数作为结果的函数

  • 避免共享可变状态 —— 共享可变状态使程序难以理解、调试和测试。这也使得推断程序的正确性更加头大


5. Promise 是什么鬼物?


JS 中的 Promise 是一个表示异步操作最终完成或失败的对象,它充当最初未知值的占位符,通常是因为该值的计算尚未完成。


Promise 的主要特征包括但不限于:



  • 有状态Promise 处于以下三种状态之一:

    • 待定:初始状态,既未成功也未失败

    • 已完成:操作成功完成

    • 拒绝:操作失败



  • 不可变:一旦 Promise 被完成或拒绝,其状态就无法改变。它变得不可变,永久保留其结果。这使得 Promise 在异步流控制中变得可靠。

  • 链接Promise 可以链接起来,这意味着,一个 Promise 的输出可以用作另一个 Promise 的输入。这通过使用 .then() 表示成功或使用 .catch() 处理失败来链接,从而允许优雅且可读的顺序异步操作。链接是函数组合的异步等价物。


const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('成功!')
// 我们也可以在失败时 reject 新错误。
}, 1000)
})

promise
.then(value => {
console.log(value) // 成功
})
.catch(error => {
console.log(error)
})

6. TS 是什么鬼物?


TS 是 JS 的超集,由微软开发和维护。近年来,TS 的人气与日俱增,如果您是一名 JS 工程师,您最终很可能需要使用 TS。它为 JS 添加了静态类型,JS 是一种动态类型语言。静态类型可以辅助开发者在开发过程的早期发现错误,提高代码质量和可维护性。


TS 的主要特点包括但不限于:



  • 静态类型:定义变量和函数参数的类型,确保整个代码一致性。

  • 给力的 IDE 支持:IDE(集成开发环境)可以提供更好的自动补全、导航和重构,使开发过程更加高效。

  • 编译:TS 代码被转译为 JS,使其与任何浏览器或 JS 环境兼容。在此过程中,类型错误会被捕获,使代码更鲁棒。

  • 接口:接口允许我们指定对象和函数必须满足的抽象契约。

  • 与 JS 的兼容性:Ts 与现有 JS 代码高度兼容。JS 代码可以逐步迁移到 JS,使现有项目能够顺利过渡。


interface User {
id: number
name: string
}

type GetUser = (userId: number) => User

const getUser: GetUser = userId => {
// 从数据库或 API 请求用户数据
return {
id: userId,
name: '人猫神话'
}
}

防范 bug 的最佳方案是代码审查、TDD 和 lint 工具(比如 ESLint)。TS 并不能替代这些做法,因为类型正确性并无法保证程序的正确性。即使应用了所有其他质量措施后,TS 偶尔也会发现错误。但它的主要好处是通过 IDE 支持,提供改进的开发体验。


7. Web Components 是什么鬼物?


WC(Web 组件)是一组 Web 平台 API,允许我们创建新的自定义、可重用、封装的 HTML 标签,在网页和 Web App 中使用。WC 是使用 HTML、CSS 和 JS 等开放 Web 技术构建的。它们是浏览器的一部分,不需要外部库或框架。


WC 对于拥有一大坨可能使用不同框架的工程师的大型团队特别有用。WC 允许我们创建可在任何框架或根本没有框架中使用的可重用组件。举个栗子,Adobe(PS 那个公司)的某个设计系统是使用 WC 构建的,并与 React 等流行框架顺利集成。


WC 由来已久,但最近人气爆棚,尤其是在大型组织中。它们被所有主要浏览器支持,并且是 W3C 标准。


8. React Hook 是什么鬼物?


Hook 是让我们无需编写类即可使用状态和其他 React 功能的函数。Hook 允许我们通过调用函数而不是编写类方法,来使用状态、上下文、引用和组件生命周期事件。函数的额外灵活性使我们更好地组织代码,将相关功能分组到单个钩子调用中,并通过在单独的函数调用中实现不相关功能,分离不相关的功能。Hook 提供了一种给力且富有表现力的方式来在组件内编写逻辑。


重要的 React Hook 包括但不限于:



  • useState —— 允许我们向函数式组件添加状态。状态变量在重新渲染之间保留。

  • useEffect —— 允许我们在函数式组件中执行副作用。它将 componentDidMount/componentDidUpdate/componentWillUnmount 的功能组合到单个函数调用中,减少了代码,并创建了比类组件更好的代码组织。

  • useContext —— 允许我们使用函数式组件中的上下文。

  • useRef —— 允许我们创建在组件的生命周期内持续存在的可变引用。

  • 自定义 Hook —— 封装可重用逻辑。这使得在不同组件之间共享逻辑变得容易。


Hook 的规则:Hook 必须在 React 函数的顶层使用(不能在循环、条件或嵌套函数内),且能且只能在 React 函数式组件或自定义 Hook 中使用。


Hook 解决了类组件的若干常见痛点,比如需要在构造函数中绑定方法,以及需要将功能拆分为多个生命周期方法。它们还使得在组件之间共享逻辑以及重用有状态逻辑,而无需更改组件层次结构更容易。


9. 如何在 React 中创建点击计数器?


我们可以使用 useState 钩子在 React 中创建点击计数器,如下所示:


import React, { useState } from 'react'

const ClickCounter = () => {
const [count, setCount] = useState(0) // 初始化为 0

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count => count + 1)}>Click me</button>
</div>

)
}

export default ClickCounter

粉丝请注意,当我们从现有状态派生新值时,将函数传递给 setCount 是最佳实践,确保我们始终使用最新状态。


10. TDD 是什么鬼物?


TDD(测试驱动开发)是一种软件开发方法,其中测试是在实际代码之前编写的。它围绕一个简短的重复开发周期,旨在确保代码满足指定的要求且没有错误。TDD 在提高代码质量、减少错误和提高开发者生产力方面,可以发挥至关重要的作用。


开发团队生产力最重要的衡量标准之一是部署频率。持续交付的主要障碍之一是对变化的恐惧。TDD 通过确保代码始终处于可部署状态,辅助减少这种恐惧。这使得部署新功能和错误修复更容易,提高了部署频率。


测试先行多了一大坨福利,包括但不限于:



  • 更好的代码覆盖率:测试先行更有可能覆盖所有极端情况。

  • 改进的 API 设计:测试迫使我们在编写代码之前考虑 API 设计,这有助于避免将实现细节泄漏到 API 中。

  • 更少的 bug:测试先行可以辅助在开发过程中尽早发现错误,这样更容易修复。

  • 更好的代码质量:测试先行迫使我们编写模块化、松耦合的代码,这样更容易维护和重用。


TDD 的关键步骤包括但不限于:



  1. 编写测试:此测试最初会失败,因为相应的功能尚不存在。

  2. 编写实现:足以通过测试。

  3. 自信重构:一旦测试通过,就可以自信重构代码。重构是在不改变其外部行为的情况下,重构现有代码的过程。其目的是清理代码、提高可读性并降低复杂性。测试到位后,如果我们犯错了,我们会立即因测试失败而收到警报。


重复:针对每个功能需求重复该循环,逐步构建软件,同时确保所有测试继续通过。


学习曲线:TDD 是一项需要相当长的时间才能培养的技能和纪律。经过大半年的 TDD 体验后,我们可能仍觉得 TDD 难如脱单,且妨碍了生产力。虽然但是,使用 TDD 两年后,我们可能会发现它已经成为第二天性,并且比以前更有效率。


耗时:为每个小功能编写测试一开始可能会感觉很耗时,但长远来看,这通常会带来回报,减少错误并简化维护。我常常告诫大家,“如果你认为自己没有时间进行 TDD,那么你真的没有时间跳过 TDD。”


本期话题是 —— 你遭遇灵魂拷问的回头率最高的面试题是哪一道?


欢迎在本文下方群聊自由言论,文明共享。谢谢大家的点赞,掰掰~


《前端 9 点半》每日更新,坚持阅读,自律打卡,每天一次,进步一点


26-cat.gif


作者:人猫神话
来源:juejin.cn/post/7334653735359348777
收起阅读 »

程序员必看的几大定律,你中招了吗?

一 晕轮效应 我们通常会从局部信息形成一个完整的印象,根据最少量的情况对别人或其他事物做出全面的结论。 举个简单的例子,当你看到一个陌生美女的时候,你通常会认为对方长得这么好看,笑起来这么甜,肯定哪哪都好! 当你看到一位发量稀疏的开发老哥,你通常会觉得这位...
继续阅读 »

一 晕轮效应



我们通常会从局部信息形成一个完整的印象,根据最少量的情况对别人或其他事物做出全面的结论。



举个简单的例子,当你看到一个陌生美女的时候,你通常会认为对方长得这么好看,笑起来这么甜,肯定哪哪都好!


当你看到一位发量稀疏的开发老哥,你通常会觉得这位老哥技术能力肯定非常强,做人肯定也很靠谱!



在晕轮效应影响下,一个人或事物的优点或缺点一旦变为光圈被扩大,其缺点或优点也就隐退到光圈的背后,被别人视而不见了。



对于程序员来说,有两点可以考虑:



  1. 打造自己的光晕:让自己成为专家或者像个专家,可以提升自己的话语权。


    比如在掘金写文章,当你拥有几千粉丝,几十万阅读量的时候,即使你什么都不说,别人看到这个账号都会觉得你是个厉害人物,与你交流的时候都会注意几分。


  2. 突破别人的光晕:在我使用npm上各种组件或者工具的时候,常常会感慨,好厉害!这么多star的项目,肯定没有Bug吧,如果有,那一定是我用的方式不对!打开源码看的时候更是惊呼,好厉害,完全看不懂,也不可能看的懂吧?


    但其实,褪去这些光环,你会发现,即使是成熟的项目,也会有bug,高深的代码,也都只是一个个基础的语句组合起来的,当你理解了作者的思想,也就能理解代码。



二 眼不见为净定律



看不见的,就是干净的。



看到这个定律,我的第一反应就是,屎山代码为什么会存在?


还不是因为管理人员看不到这坨屎山,他看到的是一个功能正常运行的系统,所以人家并不觉得这是屎山,而是美丽的风景线!


只有我们这些天天在这座屎山上添砖加瓦的程序员才能会感受到这种绝望!


所以面对屎山代码,不要抱怨,最好的方法就是找个机会把这座屎山丢给其他人,毕竟眼不见为净嘛!


当它不在你手上的时候,你会发现其实它也挺好的,毕竟眼不见为净嘛!


三 虚假同感偏差


你们是否会遇到这种情况:明明一件很重要的事情,催了某个人很久了,他却迟迟未做!


这里就涉及到虚假同感偏差,因为这件事对你来说很重要,所以通常会自我推断,觉得别人也会认为这件事情很重要,然而事实上,对你很重要的事,对他人来说可能回过头就给忘记了!


所以啊,要让别人重视一件你觉得很重要的事情,就是也让他感觉到重要,这样别人就不敢忘记了,比如可以补充一句:某某领导正在关注这件事,麻烦尽快,谢谢!


另外就是当我们非常确信自己观点或意见的时候,也很容易产生虚假同感偏差,这时候如果有人提出不同的观点,我们会下意识的反驳,并且觉得问题来自于他人。


比如我们自信满满地写完一段代码并且自测之后,提交给测试人员进行测试,当测试人员跟你反馈存在某BUG,我相信第一时间反应大多都是:我不信!!!


然后就有以下对话:



你:可能前端有缓存,你刷新一下再试试?


测试:行,我试一下。


过了十分钟......


测试:还是一样的问题啊,你看一下。


你:是不是测试数据有问题啊,我自己都测试过了,不应该有问题!


测试:行吧,我再看看。


过了十分钟......


测试:数据都排查过了,是正常的,你检查一下吧!


你:(还想再挣扎一下)你怎么操作的?


测试:就点击一下这个按钮,我还能玩出什么花吗?


排查了一会,哦~居然是空值的情况没有判断,我还能再白痴一点吗!


你:问题已经修复了,是某某复杂的场景我没考虑清楚,你再测试一下!



四 自我宽恕定律



人性有个根深蒂固的特点,就是容易发现别人的缺点和错误,却不容易看到自己的不足。


正所谓,见人之过易,见己之过难。




  • 当看到别人的代码存在一个空指针异常,心里想:这都能忘记判断,其他代码会不会也有问题!

  • 当发现自己的代码存在一个空指针异常,心里想:只是不小心忘记判断了嘛,加一下就好了!

  • 当接手别人项目的时候:卧槽,这代码写的啥啊,注释没几句,变量命名这么奇葩,逻辑这么混乱,我要咋接啊!!

  • 当项目给别人接手时候:我这代码虽然注释不多,但是很规范的,你看这变量命名不就能知道是什么含义了嘛,逻辑也非常顺,这个方法几百行你按顺序看下来就行了,我都给你写在一起,不需要跳来跳去地看,多方便!


五 补偿作用:



弱点也是一种力量源!



大家应该都听说过这个现象:瞎子的眼睛虽然看不见了,听力通常会变得非常灵敏!


这种生理上的现象吸引了很多有兴趣的心理学家,所谓补偿,就是发挥一个人的最大优势,激发其自信心,抵消其弱点。


看到补偿机制,我第一想到的就是在掘金看到的各种专科大佬。


虽然学历起点比其他人低一些,但有时候正是因为学历劣势,更加激发他们深耕技术的决心,反而达到其他更高学历人员都无法达到的高度。


这又让我想起了一句话:打不倒我的,会让我更强大!!


六 皮尔斯定理



意识到无知,才是知道的开始。



还有一句话,我觉得很适合接在这句话后面:知道的越多,才发现自己不懂的越多!


于是就形成了一个闭环: 意识到无知->开始知道->知道的越多->意识到无知


这句话我相信大部分人都听过很多遍了,不知道你们是从什么时候开始意识到自己的无知呢?


曾经,我还是小白的时候,在福州某公司上班,每天做的事情就是SpringBoot接口的开发,或者修改某些业务逻辑,我以为这差不多就是开发的全部了。


那时候对接的前端是使用Vue写的,我甚至不知道什么是Vue,只知道是某个挺流行的前端技术。


每次部署,我看前端文件里就只有一个index.html文件,我真的非常奇怪,为什么这么大的项目,只有一个html文件?


那时候我对前端认知还停留在html+js+css+jquery的时代,所以完全想不通。


本来还觉得自己前端也是有点基础的,直到接触Vue,我才惊呼,卧槽,前端怎么变成这样子了?什么nodejs,什么npm完全没听说过。


用过一段时间之后,我更是惊呼,卧槽,前端还能这样子?明明我HTML+CSS+JS只懂一点,都能做出这么好看的页面了。


有了各种开源前端组件,即使对原生HTML标签和CSS不太懂,也能算是个还不错的前端开发了。


还有这ES6语法用起来也太爽了吧,比JAVA可自由太多了。


所以很感慨,当我没进入前端圈子的时候,还以为自己懂一些,进入之后,才发现自己真的是一窍不通,啥都要学。


更感慨的是,当我第一次接触掘金,我惊呼,卧槽,这个社区分享的东西都好干啊,好多听都没听过的标题,原来我有这么多东西都不懂!原来前端是个这么卷的领域!


结语


感谢阅读,希望本篇文章对你有所帮助!


作者:林劭敏
来源:juejin.cn/post/7295623585363771443
收起阅读 »

对代码封装的一点思考

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考. --- 以下是之前文章内容简述 --- 在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等 ...
继续阅读 »

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考.


--- 以下是之前文章内容简述 ---


在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等

初始化封装

随着业务的发展,又出现了B模块,B模块中几乎可以直接使用A逻辑,但是需要对A逻辑进行一些小小的改动,比如入参的调整、逻辑分支的增加

再次封装

这种通过修改较小范围代码就能实现功能的方式在实际开发中是很常规的操作,它并没有不好,只是当这种修改变的次数多的时候,最开始封装的逻辑A就变成了‘四不像’,它似乎可以服务于A模块,但是确有些冗余,出现了‘我好像看不懂这段代码’,‘这段代码还是别改了’,‘你改吧 我是改不了’的场景。在多次封装之后,逻辑A容易出现以下的问题



  • 入参不可控

  • 逻辑分支复杂,不清晰,修改范围不确定,容易对其他模块产生影响

  • 复用度逐渐下降,解决方法



    1. 将就不再继续新增逻辑,变成'老大难'问题,总有爆发的一天

    2. 重写相关逻辑,功能回测范围大,影响范围不可控,容易出现漏测等




--- 以下是正文 ---


提前封装或者过度封装对实际业务的编写都有很大影响,最实际的感受就是这段代码不好写,这个逻辑看不懂。封装的代码逻辑是结果,封装的出发点是因,从出发点去看而不是面向结果的封装似乎更能编写出可维护性的代码。以下是自己对封装的一点思考。


降低复杂度


复杂度

通过对业务中分散的相似逻辑提取,就能一定程度上降低项目的复杂度和重复度的问题。在修改相应的业务逻辑时候就相对集中和可控。新增逻辑的修改也能做到应用范围的同步。


解耦逻辑


在设计模式中需要设计的模块尽量遵循单一职责,但是在实际编写代码的过程中很容易出现模块功能的耦合,比如常见的:



  • 视图层逻辑与数据层逻辑耦合

  • 非相关逻辑的入侵 比如视图的逻辑需要感知请求状态的改变做相应的逻辑处理


通过将相关的逻辑都聚合到模块封装的内部,就能较低其他模块对不相关逻辑的感知,也就做到了解耦。在下面的例子中,通过将A逻辑的关联逻辑都封装进A模块中,降低了功能模块对封装逻辑的感知。内部封装

相关例子:



  • ahooks中的useRequest中,就通过对请求状态和数据的操作就行封装,将数据相关操作做到了内聚,减轻了视图层的负担

  • Antd pro components的table


分层


分层封装

在例子中,通过将逻辑A中的相关逻辑提取到上层,分层的方式实现了模块功能的内聚与解耦。在前端的MVC中就通过将相关操作封装成Controller,来完成逻辑的解耦


复用(一致性,可控性)


从降低复杂度、逻辑结构、分层设计最终的目标是达到封装代码的复用。通过复用能控制代码的一致性,实现修改代码行为的可控。



-- 写在最后 --


代码的优化或者设计是应该在理解业务的基础上,从相对大的视角出发来看的。在实际做的时候容易出现缺少全局视角的设计缺失和不完善,也容易出现过度设计,需要仔细去把握这个度量。


作者:前端小板凳
来源:juejin.cn/post/7337354931479085096
收起阅读 »