前端劝退师

前端劝退师 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

前端劝退师 发布了文章 · 9月11日

前端需要关注的几种“握手”

前言

失业期间闲来无事,看了本《网络是怎样连接的》与两本HTTP相关的专栏。

一方面补充专业知识,另一方面也是为了跳槽面试做准备。

避免看了即忘,就画了一张XMind图:

值得深入的问题太多了,今儿就先来讲讲: Web中的几种“握手”

1. 不止一种握手

在早期的网络传输中,也就存在TCP协议需要“握手”的过程,但早期的协议有一个缺陷:通信只能由客户端发起,做不到服务器主动向客户端推送信息。

于是WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。

而随着SSL/TLS的完善,存在已久的安全版网络协议:HTTPS也是迸发式发展。

最后前端领域的协议握手便成了三分天下:

  1. TCP三次握手,归HTTP
  2. TLS握手,归HTTPS
  3. WebSocket握手,基于TCP协议,都能用。

2. TCP三次握手的终极意义

在我之前的文章:[《「真香警告」重学 TCP/IP 协议 与三次握手
》](https://juejin.im/post/5ca95e...

也详细的讲述过TCP三次握手,但那时我未明确意识到其深刻含义。

就和大家一样,只在面试前会记得,过后即忘。

直到我看到《网络是怎样连接的》中的一段话:

**在实际的通信中,序号并不是从 1 开始的,而是需要用随机数计算出一个初始值,这是因为
如果序号都从 1 开始,通信过程就会非常容易预测,有人会利用这一点来发动攻击。<br/><br/>但是如果初始值是随机的,那么对方就搞不清楚序号到底是从
多少开始计算的,因此需要在开始收发数据之前将初始值告知通信对象。**

你品,你细品。三次握手不就是相互试探暗号,来确定是不是对的人吗?

2.1 知识补充:一个网络包的最大长度

计算每个网络包能容纳的数据长度,协议栈会根据一个叫作 MTU的参数来进行判断。

MTU表示一个网络包的最大长度,在以太网中一般是1500字节

MTU是包含头部的总长度,因此需要从MTU减去头部的长度,然后得到的长度就是一个网络包中所能容纳的最大数据长度,这一长度叫作MSS

由上两图可知,MSS值是1460(1500-40)字节,其中:

  1. TCP固定头部20字节。
  2. IP固定头部20字节。
  3. TCP头部最长可以达到60字节。

3. TLS握手:HTTPS的核心

HTTPS 其实是一个“非常简单”的协议,RFC 文档很小,只有短短的 7 页,里面规定了新的协议名“https”,默认端口号 443,至于其他的什么请求 - 应答模式、报文结构、请求方法、URI、头字段、连接管理等等都完全沿用 HTTP,没有任何新的东西。---- 《透视HTTP协议》

感兴趣的可以到这里看看:链接:https://tools.ietf.org/html/r...

3.1 TLS/SSL究竟是啥?

很多人看到TLS/SSL这对词就开始蒙圈了。实际上,这两个东西是一个玩意儿:

1999 年改名:SSL 3 === TLS 1.0

目前运用最广泛的是TLS 1.2:

TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议组成,综合使用了对称加密、非对称加密、身份认证等许多密码学前沿技术。

由于TLS/SSL 协议位于应用层和传输层 TCP 协议之间。TLS 粗略的划分又可以分为 2 层:

  1. 靠近应用层的握手协议 TLS Handshaking Protocols
  2. 靠近 TCP 的记录层协议 TLS Record Protocol

这个篇幅展开来写就太多了,我们先关心下TLS握手吧。

3.2 TLS握手详解

TLS握手何时发生?:

  1. 每当用户通过HTTPS导航到网站并且浏览器首先开始查询网站的原始服务器时,就会进行TLS握手。
  2. 每当其他任何通信使用HTTPS(包括API调用和HTTPS查询上的DNS)时,也会发生TLS握手。
  3. 通过TCP握手打开TCP连接后,会发生TLS 握手。

TLS握手期间会发生什么?

TLS握手过程中,客户端和服务器将共同执行以下操作:

  • 指定将使用的TLS版本(TLS 1.0、1.2、1.3等)
  • 确定将使用哪些加密套件。
  • 通过服务器的公钥和SSL证书颁发机构的数字签名来验证服务器的身份
  • 握手完成后,生成会话密钥以使用对称加密

加密套件决定握手方式:

摘自:《HTTPS篇之SSL握手过程详解》
TLS中有两种主要的握手类型:一种基于RSA,一种基于Diffie-Hellman。 这两种握手类型的主要区别在于主秘钥交换和认证上。
秘钥交换身份验证
RSA握手RSARSA
DH握手DHRSA/DSA

主流的握手类型,基本都是基于RSA,所以以下讲解都基于 RSA版握手。

整个流程如下图所示:

具体流程描述:

  1. 客户端hello:客户端通过向服务器发送“问候”消息来发起握手。该消息将包括客户端支持的TLS版本,支持的加密套件以及称为“客户端随机”的随机字节字符串。
  2. 服务器hello:为回复客户端hello消息,服务器发送一条消息,其中包含服务器的SSL证书,服务器选择的加密套件和“服务器随机数”,即服务器生成的另一个随机字节串。
  3. 客户端发送公钥加密的预主密钥。
  4. 服务器用自己的私钥解密加密的预主密钥。

    • 客户端finished:客户端发送“完成”消息,该消息已用会话密钥加密。
    • 服务器finished:服务器发送一条用会话密钥加密的“完成”消息。
  5. 握手完成,后续通过主密钥加解密。

只有加密套件,讲解的话需要有抓包基础。改天,改天我一定讲。。。

4. WebSocket握手

WebSocket协议实现起来相对简单。它使用HTTP协议进行初始握手。成功握手之后,就建立了连接,WebSocket基本上使用原始TCP读取/写入数据。

《图解HTTP》一书中的图讲的比较清楚:

具体步骤表现是:

  1. 客户端请求:
  GET /chat HTTP/1.1     
Host: server.example.com     
Upgrade: websocket     
Connection: Upgrade     
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==     
Sec-WebSocket-Protocol: chat, superchat     
Sec-WebSocket-Version: 13     
Origin: http://example.com
  1. 服务端响应:
    HTTP/1.1 101 
Switching Protocols     
Upgrade: websocket     
Connection: Upgrade     
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=     
Sec-WebSocket-Protocol: chat

4.1 Websocket全双工通信

Websocket协议解决了服务器与客户端全双工通信的问题。

那什么是单工、半双工、全双工通信?

类型能力
单工信息单向传送
半双工信息能双向传送,但不能同时双向传送
全双工信息能够同时双向传送

4.2 WebsocketSocket区别

可以把WebSocket想象成HTTP应用层),HTTPSocket什么关系,WebSocketSocket就是什么关系。

1. WebSocketHTTP的关系

相同点

  1. 都是一样基于TCP的,都是可靠性传输协议。
  2. 都是应用层协议。

不同点

  1. WebSocket是双向通信协议,模拟Socket协议,可以双向发送或接受信息。HTTP是单向的。
  2. WebSocket是需要握手进行建立连接的。

2. Socket是什么?

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。

在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

4.1 扩展知识:Socket.IO的七层降级

GolangJava Spring等框架中,websocket都有一套实现API

Socket.IO 由两部分组成:

  1. 一个服务端用于集成 (或挂载) 到 Node.JS HTTP 服务器: socket.io
  2. 一个加载到浏览器中的客户端: socket.io-client

很多人以为Socket.IO只是WebSocketXHR长轮询。

实际上,Socket.io有很多传输机制:

1. WebSockets 
2. FlashSocket 
3. XHR长轮询
4. XHR部分流:multipart/form-data
5. XHR轮询
6. JSONP轮询
7. iframe

得益于这么多种传输机制,Socket.io兼容性完全不用担心。

5. 扩展:HTTPSHTTP 核心区别

上面讲到 Socket是什么?,有一点我忘了讲:

HTTPSHTTP 核心区别在于两点:

  1. HTTP 下层的传输协议由 TCP/IP 换成了 SSL/TLS
  2. 收发报文不再使用 Socket API,而是调用专门的安全接口。

具体区别:

  1. HTTPS协议需要到CA申请证书,一般免费证书很少,需要交费。
  2. HTTP是超文本传输协议,信息是明文传输,HTTPS 则是具有安全性的ssl加密传输协议。
  3. HTTPhttps使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443
  4. HTTP的连接很简单,是无状态的。HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比HTTP协议安全。

后记及引用

本篇引用了大量资料和专栏:

1.《HTTPS篇之SSL握手过程详解》
<br/>
2.[《网络是怎样连接的》--户根勤]()
<br/>
3.[《图解HTTP》--上野宣 ]()
<br/>
4.[《透视HTTP协议》--罗剑峰]()
<br/>
5.《What Happens in a TLS Handshake?》
<br/>

  1. 《How to Use Websockets in Golang: Best Tools and Step-by-Step Guide》

在我的脑图中,总结概括了8种HTTP核心问题。

作为一个转行的前端,理解这些HTTP的过程既痛苦又有趣。想要脑图的可以扫码加我,或公众号回复:HTTP

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注「前端劝退师」,不定期分享原创知识。
  3. 也看看其它文章

劝退师个人微信:huab119

也可以来我的GitHub博客里拿所有文章的源文件:

前端劝退指南https://github.com/roger-hiro...
一起玩耍呀。~

查看原文

赞 18 收藏 11 评论 2

前端劝退师 发布了文章 · 9月11日

窥探数据结构的世界- ES6版

1. 什么是数据结构?

数据结构是在计算机中组织和存储数据的一种特殊方式,使得数据可以高效地被访问和修改。更确切地说,数据结构是数据值的集合,表示数据之间的关系,也包括了作用在数据上的函数或操作。

1.1 为什么我们需要数据结构?

  • 数据是计算机科学当中最关键的实体,而数据结构则可以将数据以某种组织形式存储,因此,数据结构的价值不言而喻。
  • 无论你以何种方式解决何种问题,你都需要处理数据——无论是涉及员工薪水、股票价格、购物清单,还是只是简单的电话簿问题。
  • 数据需要根据不同的场景,按照特定的格式进行存储。有很多数据结构能够满足以不同格式存储数据的需求。

1.2 八大常见的数据结构

  1. 数组:Array
  2. 堆栈:Stack
  3. 队列:Queue
  4. 链表:Linked Lists
  5. 树:Trees
  6. 图:Graphs
  7. 字典树:Trie
  8. 散列表(哈希表):Hash Tables

在较高的层次上,基本上有三种类型的数据结构:

  • 堆栈和队列是类似于数组的结构,仅在项目的插入和删除方式上有所不同。
  • 链表,树,和图 结构的节点是引用到其他节点。
  • 散列表依赖于散列函数来保存和定位数据。

在复杂性方面:

  • 堆栈和队列是最简单的,并且可以从中构建链表。
  • 树和图 是最复杂的,因为它们扩展了链表的概念。
  • 散列表和字典树 需要利用这些数据结构来可靠地执行。

就效率而已:

  • 链表是记录和存储数据的最佳选择
  • 而哈希表和字典树 在搜索和检索数据方面效果最佳。

2. 数组 - 知识补充

数组是最简单的数据结构,这里就不讲过多了。
贴一张每个函数都运行10,000次迭代:

  • 10,000个随机密钥在10,000个对象的数组中查找的执行效率对比图:
[
  {
    id: "key0",
    content: "I ate pizza 0 times"
  },
  {
    id: "key1",
    content: "I ate pizza 1 times"
  },
  {
    id: "key2",
    content: "I ate pizza 2 times"
  },
  ...
]

["key284", "key958", "key23", "key625", "key83", "key9", ... ]

2.1 for... in为何这么慢?

for... in语法令人难以置信的缓慢。在测试中就已经比正常情况下慢近9倍的循环。

这是因为for ... in语法是第一个能够迭代对象键的JavaScript语句。

循环对象键({})与在数组([])上进行循环不同,

因为引擎会执行一些额外的工作来跟踪已经迭代的属性。

3. 堆栈:Stack


堆栈是元素的集合,可以在顶部添加项目,我们有几个实际的堆栈示例:

  • 浏览器历史记录
  • 撤消操作
  • 递归以及其它。

三句话解释堆栈:

  1. 两个原则操作:pushpopPush 将元素添加到数组的顶部,而Pop将它们从同一位置删除。
  2. 遵循"Last In,First Out",即:LIFO,后进先出。
  3. 没了。

3.1 堆栈的实现。

请注意,下方例子中,我们可以颠倒堆栈的顺序:底部变为顶部,顶部变为底部。

因此,我们可以分别使用数组unshiftshift方法代替pushpop

class Stack {
    constructor(...items) {
        this.reverse = false;
        this.stack = [...items];
    }

    push(...items) {
        return this.reverse
            ? this.stack.unshift(...items)
            : this.stack.push(...items);
    }
    
    pop() {
        return this.reverse ? this.stack.shift() : this.stack.pop();
    }
}

const stack = new Stack(4, 5);
stack.reverse = true;
console.log(stack.push(1, 2, 3) === 5) // true
console.log(stack.stack ===[1, 2, 3, 4, 5]) // true

4. 队列:Queue

在计算机科学中,一个队列(queue)是一种特殊类型的抽象数据类型或集合。集合中的实体按顺序保存。


而在前端开发中,最著名的队列使用当属浏览器/NodeJs中 关于宏任务与微任务,任务队列的知识。这里就不再赘述了。

在后端领域,用得最广泛的就是消息队列:Message queue:如RabbitMQActiveMQ等。

以编程思想而言,Queue可以用两句话描述:

  • 只是具有两个主要操作的数组:unshiftpop
  • 遵循"Fist In,first out"即:FIFO,先进先出。

4.1 队列的实现

请注意,下方例子中,我们可以颠倒堆队列的顺序。

因此,我们可以分别使用数组unshiftshift方法代替pushpop

class Queue {
    constructor(...items) {
        this.reverse = false;
        this.queue = [...items];
    }

    enqueue(...items) {
        return this.reverse
            ? this.queue.push(...items)
            : this.queue.unshift(...items);
    }

    dequeue() {
        return this.reverse ? this.queue.shift() : this.queue.pop();
    }
}

5. 链表:Linked Lists

与数组一样,链表是按顺序存储数据元素。

链表不是保留索引,而是指向其他元素。


第一个节点称为头部(head),而最后一个节点称为尾部(tail)。

单链表与双向链表:

  • 单链表是表示一系列节点的数据结构,其中每个节点指向列表中的下一个节点。
  • 链表通常需要遍历整个操作列表,因此性能较差。
  • 提高链表性能的一种方法是在每个节点上添加指向列表中上一个节点的第二个指针。
  • 双向链表具有指向其前后元素的节点。

链表的优点:

  • 链接具有常量时间 插入和删除,因为我们可以只更改指针。
  • 与数组一样,链表可以作为堆栈运行。

链表的应用场景:

链接列表在客户端和服务器上都很有用。

  • 在客户端上,像Redux就以链表方式构建其中的逻辑。
  • React 核心算法 React Fiber的实现就是链表。

    • React Fiber之前的Stack Reconciler,是自顶向下的递归mount/update,无法中断(持续占用主线程),这样主线程上的布局、动画等周期性任务以及交互响应就无法立即得到处理,影响体验。
    • React Fiber解决过去Reconciler存在的问题的思路是把渲染/更新过程(递归diff)拆分成一系列小任务,每次检查树上的一小部分,做完看是否还有时间继续下一个任务,有的话继续,没有的话把自己挂起,主线程不忙的时候再继续。
  • 在服务器上,像Express这样的Web框架也以类似的方式构建其中间件逻辑。当请求被接收时,它从一个中间件管道输送到下一个,直到响应被发出。

5.1 单链表实现

单链表的操作核心有:

  • push(value) - 在链表的末尾/头部添加一个节点
  • pop() - 从链表的末尾/头部删除一个节点
  • get(index) - 返回指定索引处的节点
  • delete(index) - 删除指定索引处的节点
  • isEmpty() - 根据列表长度返回true或false
  • print() - 返回链表的可见表示
class Node {
  constructor(data) {
    this.data = data
    this.next = null
  }
}

class LinkedList {
  constructor() {
    this.head = null
    this.tail = null
    // 长度非必要
    this.length = 0
  }
  push(data) {
    // 创建一个新节点
    const node = new Node(data)
    // 检查头部是否为空
    if (this.head === null) {
      this.head = node
      this.tail = node
    } 
    this.tail.next = node
    this.tail = node
    this.length++
  }
  pop(){
    // 先检查链表是否为空
    if(this.isEmpty()) {
      return null
    } 
    // 如果长度为1
    if (this.head === this.tail) {
      this.head = null
      this.tail = null
      this.length--
      return this.tail
    }
    let node = this.tail
    let currentNode = this.head
    let penultimate
    
    while (currentNode) {
      if (currentNode.next === this.tail) {
        penultimate = currentNode
        break
      }
      currentNode = currentNode.next
    }
    
    penultimate.next = null
    this.tail = penultimate
    this.length --
    return node
  }
  
  get(index){
    // 处理边界条件
    if (index === 0) {
      return this.head
    }
    if (index < 0 || index > this.length) {
      return null
    }
    
    let currentNode = this.head
    let i = 0
    while(i < index) {
      i++
      currentNode = currentNode.next
    }
    return currentNode
    
  }
  delete(index){
    let currentNode = this.head
    
    if (index === 0) {
      let deletedNode
      currentNode.next = this.head
      deletedNode = currentNode
      this.length--
      
      return deletedNode
    }
    
    if (index < 0 || index > this.length) {
      return null
    }
    
    let i = 0
    let previous
    
    while(i < index) {
      i++
      previous = currentNode
      currentNode = currentNode.next
    }
    previous.next = currentNode.next
    this.length--
    return currentNode
  }
  
  isEmpty() {
    return this.length === 0
  }
  print() {
    const list = []
    let currentNode = this.head
    while(currentNode){
      list.push(currentNode.data)
      currentNode = currentNode.next
    }
    return list.join(' => ')
  }
}

测试一下:

const l = new LinkedList()

// 添加节点
const values = ['A', 'B', 'C']
values.forEach(value => l.push(value))

console.log(l)
console.log(l.pop())
console.log(l.get(1))
console.log(l.isEmpty())
console.log(l.print())

5.2 双向链表实现

1. 双向链表的设计

类似于单链表,双向链表由一系列节点组成。每个节点包含一些数据以及指向列表中下一个节点的指针和指向前一个节点的指针。这是JavaScript中的简单表示:

class Node {
    constructor(data) {
        // data 包含链表项应存储的值
        this.data = data;
        // next 是指向列表中下一项的指针
        this.next = null;
        // prev 是指向列表中上一项的指针
        this.prev = null;
    }
}


还是敲一遍吧:

class DoublyLinkedList {
    constructor() {
        this.head = null;
        this.tail = null;
    }
    // 各种操作方法
    // ...
}

2. 双向链表的操作方法

  • Append & AppendAt: 在链表的尾部/ 指定位置添加节点
append( item ) {
  let node = new Node( item );
  if(!this.head) {
    this.head = node;
    this.tail = node;
  } else {
    node.prev = this.tail;
    this.tail.next = node;
    this.tail = node
  }
}

appendAt( pos, item ) {
   let current = this.head;
   let counter = 1;
   let node = new Node( item );
   if( pos == 0 ) {
     this.head.prev = node
     node.next = this.head
     this.head = node
   } else {
     while(current) {
      current = current.next;
      if( counter == pos ) {
        node.prev = current.prev
        current.prev.next = node
        node.next = current
        current.prev = node
      }
      counter++
    }
  }
}

  • Remove & RemoveAt: 在链表的尾部/ 指定位置删除节点
remove( item ) {
  let current = this.head;
  while( current ) {
    if( current.data === item ) {
      if( current == this.head && current == this.tail ) {
        this.head = null;
        this.tail = null;
      } else if ( current == this.head ) {
        this.head = this.head.next
        this.head.prev = null
      } else if ( current == this.tail ) {
        this.tail = this.tail.prev;
        this.tail.next = null;
      } else {
        current.prev.next = current.next;
        current.next.prev = current.prev;
      }
   }
   current = current.next
  }
}

removeAt( pos ) {
  let current = this.head;
  let counter = 1;
  if( pos == 0 ) {
   this.head = this.head.next;
   this.head.prev = null;
  } else {
   while( current ) {
    current = current.next
    if ( current == this.tail ) {
     this.tail = this.tail.prev;
     this.tail.next = null;
    } else if( counter == pos ) {
     current.prev.next = current.next;
     current.next.prev = current.prev;
     break;
    }
    counter++;
   }
  }
}

  • Reverse: 翻转双向链表
reverse(){
  let current = this.head;
  let prev = null;
  while( current ){
   let next = current.next
   current.next = prev
   current.prev = next
   prev = current
   current = next
  }
  this.tail = this.head
  this.head = prev
}

  • Swap:两节点间交换。
swap( nodeOne, nodeTwo ) {
   let current = this.head;
   let counter = 0;
   let firstNode;
   while( current !== null ) {
     if( counter == nodeOne ){
       firstNode = current;
     } else if( counter == nodeTwo ) {
       let temp = current.data;
       current.data = firstNode.data;
       firstNode.data = temp;
     }
     current = current.next;
     counter++;
   }
   return true
}

  • IsEmpty & Length:查询是否为空或长度。
length() {
  let current = this.head;
  let counter = 0;
  while( current !== null ) {
   counter++
   current = current.next
  }
  return counter;
}
isEmpty() {
  return this.length() < 1
}

  • Traverse: 遍历链表
traverse( fn ) {
   let current = this.head;
   while( current !== null ) {
    fn(current)
    current = current.next;
   }
   return true;
}


<center>每一项都加10</center>

  • Search:查找节点的索引。
search( item ) {
  let current = this.head;
  let counter = 0;
  while( current ) {
    if( current.data == item ) {
     return counter
    }
    current = current.next
    counter++
  }
  return false;
}

6. 树:Tree

计算机中经常用到的一种非线性的数据结构——树(Tree),由于其存储的所有元素之间具有明显的层次特性,因此常被用来存储具有层级关系的数据,比如文件系统中的文件;也会被用来存储有序列表等。

  • 在树结构中,每一个结点只有一个父结点,若一个结点无父节点,则称为树的根结点,简称树的根(root)。
  • 每一个结点可以有多个子结点。
  • 没有子结点的结点称为叶子结点。
  • 一个结点所拥有的子结点的个数称为该结点的度。
  • 所有结点中最大的度称为树的度。树的最大层次称为树的深度。

6.1 树的分类

常见的树分类如下,其中我们掌握二叉搜索树即可。

  • 二叉树:Binary Search Tree
  • AVL树:AVL Tree
  • 红黑树:Red-Black Tree
  • 线段树: Segment Tree - with min/max/sum range queries examples
  • 芬威克树:Fenwick Tree (Binary Indexed Tree)

6.2 树的应用

  1. DOM树。每个网页都有一个树数据结构。

  1. VueReactVirtual DOM也是树。

6.3 二叉树:Binary Search Tree

  • 二叉树是一种特殊的树,它的子节点个数不超过两个。
  • 且分别称为该结点的左子树(left subtree)与右子树(right subtree)。
  • 二叉树常被用作二叉查找树和二叉搜索树、或是二叉排序树(BST)。

6.4 二叉树的遍历

按一定的规则和顺序走遍二叉树的所有结点,使每一个结点都被访问一次,而且只被访问一次,这个操作被称为树的遍历,是对树的一种最基本的运算。

由于二叉树是非线性结构,因此,树的遍历实质上是将二叉树的各个结点转换成为一个线性序列来表示。

按照根节点访问的顺序不同,二叉树的遍历分为以下三种:前序遍历,中序遍历,后序遍历;

前序遍历Pre-Order

根节点->左子树->右子树

中序遍历In-Order

左子树->根节点->右子树

后序遍历Post-Order

左子树->右子树->根节点

因此我们可以得之上面二叉树的遍历结果如下:

  • 前序遍历:ABDEFGC
  • 中序遍历:DEBGFAC
  • 后序遍历:EDGFBCA

6.5 二叉树的实现

class Node { 
  constructor(data) { 
    this.left = null
    this.right = null
    this.value = data
  } 
} 

class BST {
    constructor() {
        this.root = null
    }
    // 二叉树的各种操作
    // insert(value) {...}
    // insertNode(root, newNode) {...}
    // ...

1. insertNode& insert:插入新子节点/节点

  insertNode(root, newNode) {
    if (newNode.value < root.value) {
      // 先执行无左节点操作
      (!root.left) ? root.left = newNode : this.insertNode(root.left, newNode)
    } else {
      (!root.right) ? root.right = newNode : this.insertNode(root.right, newNode)
    }
  }
  
insert(value) {
    let newNode = new Node(value)
    // 如果没有根节点
    if (!this.root) {
      this.root = newNode
    } else {
      this.insertNode(this.root, newNode)
    }
}

2. removeNode& remove:移除子节点/节点

  removeNode(root, value) {
    if (!root) {
      return null
    }
    
    // 从该值小于根节点开始判断
    if (value < root.value) {
      root.left = this.removeNode(root.left, value)
      return root
    } else if (value > root.value) {
      root.right = tis.removeNode(root.right, value)
      return root
    } else {
      // 如果没有左右节点
      if (!root.left && !root.right) {
        root = null
        return root
      }
      
      // 存在左节点
      if (root.left) {
        root = root.left
        return root
      // 存在右节点
      } else if (root.right) {
        root = root.right
        return root
      }
      
      // 获取正确子节点的最小值以确保我们有有效的替换
      let minRight = this.findMinNode(root.right)
      root.value = minRight.value
      // 确保删除已替换的节点
      root.right = this.removeNode(root.right, minRight.value)
      return root
    }
  }
  
remove(value) {
    if (!this.root) {
      return 'Tree is empty!'
    } else {
      this.removeNode(this.root, value)
    }
}
  

3. findMinNode:获取子节点的最小值

findMinNode(root) {
    if (!root.left) {
      return root
    } else {
      return this.findMinNode(root.left)
    }
}

4. searchNode & search:查找子节点/节点

searchNode(root, value) {
    if (!root) {
      return null
    }
    
    if (value < root.value) {
      return this.searchNode(root.left, value)
    } else if (value > root.value) {
      return this.searchNode(root.right, value)
    }
    
    return root
}

search(value) {
    if (!this.root) {
      return 'Tree is empty'
    } else {
      return Boolean(this.searchNode(this.root, value))
    }
}
  1. Pre-Order:前序遍历
preOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      console.log(root.value)
      this.preOrder(root.left)
      this.preOrder(root.right)
    }
  }
  1. In-Order:中序遍历
inOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      this.inOrder(root.left)
      console.log(root.value)
      this.inOrder(root.right)
    }
  }
  1. Post-Order:后序遍历
postOrder(root) {
    if (!root) {
      return 'Tree is empty'
    } else {
      this.postOrder(root.left)
      this.postOrder(root.right)
      console.log(root.value)
    }
}

7. 图:Graph

图是由具有边的节点集合组成的数据结构。图可以是定向的或不定向的。

图的介绍普及,找了一圈文章,还是这篇最佳:

Graphs—-A Visual Introduction for Beginners

7.1 图的应用

在以下场景中,你都使用到了图:

  • 使用搜索服务,如Google,百度。
  • 使用LBS地图服务,如高德,谷歌地图。
  • 使用社交媒体网站,如微博,Facebook

图用于不同的行业和领域:

  • GPS系统和谷歌地图使用图表来查找从一个目的地到另一个目的地的最短路径。
  • 社交网络使用图表来表示用户之间的连接。
  • Google搜索算法使用图 来确定搜索结果的相关性。
  • 运营研究是一个使用图 来寻找降低运输和交付货物和服务成本的最佳途径的领域。
  • 甚至化学使用图 来表示分子!

图,可以说是应用最广泛的数据结构之一,真实场景中处处有图。

7.2 图的构成

图表用于表示,查找,分析和优化元素(房屋,机场,位置,用户,文章等)之间的连接。

1. 图的基本元素

  • 节点:Node,比如地铁站中某个站/多个村庄中的某个村庄/互联网中的某台主机/人际关系中的人.
  • 边:Edge,比如地铁站中两个站点之间的直接连线, 就是一个边。

2. 符号和术语

  • |V|=图中顶点(节点)的总数。
  • |E|=图中的连接总数(边)。

在下面的示例中

|V| = 6 
|E| = 7 

3. 有向图与无向图

图根据其边(连接)的特征进行分类。

1. 有向图

在有向图中,边具有方向。它们从一个节点转到另一个节点,并且无法通过该边返回到初始节点。

如下图所示,边(连接)现在具有指向特定方向的箭头。 将这些边视为单行道。您可以向一个方向前进并到达目的地,但是你无法通过同一条街道返回,因此您需要找到另一条路径。


<center>有向图</center>

2. 无向图

在这种类型的图中,边是无向的(它们没有特定的方向)。将无向边视为双向街道。您可以从一个节点转到另一个节点并返回相同的“路径”。

4. 加权图

在加权图中,每条边都有一个与之相关的值(称为权重)。该值用于表示它们连接的节点之间的某种可量化关系。例如:

  • 权重可以表示距离,时间,社交网络中两个用户之间共享的连接数。
  • 或者可以用于描述您正在使用的上下文中的节点之间的连接的任何内容。

著名的Dijkstra算法,就是使用这些权重通过查找网络中节点之间的最短或最优的路径来优化路由。

5. 稀疏图与密集图

当图中的边数接近最大边数时,图是密集的。


<center>密集图</center>

当图中的边数明显少于最大边数时,图是稀疏的。

<center>稀疏图</center>

6. 循环

如果你按照图中的一系列连接,可能会找到一条路径,将你带回到同一节点。这就像“走在圈子里”,就像你在城市周围开车一样,你走的路可以带你回到你的初始位置。🚗

在图中,这些“圆形”路径称为“循环”。它们是在同一节点上开始和结束的有效路径。例如,在下图中,您可以看到,如果从任何节点开始,您可以通过跟随边缘返回到同一节点。

循环并不总是“孤立的”,因为它们可以是较大图的一部分。可以通过在特定节点上开始搜索并找到将你带回同一节点的路径来检测它们。


<center>循环图</center>

7.3 图的实现

我们将实现具有邻接列表的有向图。

class Graph {
  constructor() {
    this.AdjList = new Map();
  }
  
  // 基础操作方法
  // addVertex(vertex) {}
  // addEdge(vertex, node) {}
  // print() {}
}

1. addVertex:添加顶点

addVertex(vertex) {
  if (!this.AdjList.has(vertex)) {
    this.AdjList.set(vertex, []);
  } else {
    throw 'Already Exist!!!';
  }
}

尝试创建顶点:

let graph = new Graph();
graph.addVertex('A');
graph.addVertex('B');
graph.addVertex('C');
graph.addVertex('D');

打印后将会发现:

Map {
  'A' => [],
  'B' => [],
  'C' => [],
  'D' => []
}

之所以都为空数组'[]',是因为数组中需要储存边(Edge)的关系。
例如下图:

该图的Map将为:

Map {
  'A' => ['B', 'C', 'D'],
  // B没有任何指向
  'B' => [],
  'C' => ['B'],
  'D' => ['C']
}

2. addEdge:添加边(Edge)

 addEdge(vertex, node) {
    // 向顶点添加边之前,必须验证该顶点是否存在。
    if (this.AdjList.has(vertex)) {
      // 确保添加的边尚不存在。
      if (this.AdjList.has(node)){
        let arr = this.AdjList.get(vertex);
        // 如果都通过,那么可以将边添加到顶点。
        if(!arr.includes(node)){
          arr.push(node);
        }
      }else {
        throw `Can't add non-existing vertex ->'${node}'`;
      }
    } else {
      throw `You should add '${vertex}' first`;
    }
}

3. print:打印图(Graph)

print() {
  for (let [key, value] of this.AdjList) {
    console.log(key, value);
  }
}

测试一下

let g = new Graph();
let arr = ['A', 'B', 'C', 'D', 'E', 'F'];
for (let i = 0; i < arr.length; i++) {
  g.addVertex(arr[i]);
}
g.addEdge('A', 'B');
g.addEdge('A', 'D');
g.addEdge('A', 'E');
g.addEdge('B', 'C');
g.addEdge('D', 'E');
g.addEdge('E', 'F');
g.addEdge('E', 'C');
g.addEdge('C', 'F');
g.print();
/* PRINTED */
// A [ 'B', 'D', 'E' ]
// B [ 'C' ]
// C [ 'F' ]
// D [ 'E' ]
// E [ 'F', 'C' ]
// F []

到目前为止,这就是创建图所需的。但是,99%的情况下,会要求你实现另外两种方法:

  • 广度优先算法,BFS
  • 深度优先算法,DFS
  • BFS 的重点在于队列,而 DFS 的重点在于递归。这是它们的本质区别。

5. 广度优先算法实现

广度优先算法(Breadth-First Search),同广度优先搜索。

是一种利用队列实现的搜索算法。简单来说,其搜索过程和 “湖面丢进一块石头激起层层涟漪” 类似。


如上图所示,从起点出发,对于每次出队列的点,都要遍历其四周的点。所以说 BFS 的搜索过程和 “湖面丢进一块石头激起层层涟漪” 很相似,此即 “广度优先搜索算法” 中“广度”的由来。

该算法的具体步骤为:

  • BFS将起始节点作为参数。(例如'A'
  • 初始化一个空对象:visited
  • 初始化一个空数组:q,该数组将用作队列。
  • 将起始节点标记为已访问。(visited = {'A': true})
  • 将起始节点放入队列中。(q = ['A'])
  • 循环直到队列为空

循环内部:

  • 从中获取元素q并将其存储在变量中。(let current = q.pop())
  • 打印 当前 current
  • 从图中获取current的边。(let arr = this.AdjList.get(current))
  • 如果未访问元素,则将每个元素标记为已访问并将其放入队列中。
visited = {
  'A': true,
  'B': true,
  'D': true,
  'E': true
}
q = ['B', 'D', 'E']

具体实现

createVisitedObject(){
  let arr = {};
  for(let key of this.AdjList.keys()){
    arr[key] = false;
  }
  return arr;
}

bfs(startingNode){
  let visited = this.createVisitedObject();
  let q = [];

  visited[startingNode] = true;
  q.push(startingNode);

  while(q.length){
    let current = q.pop()
    console.log(current);

    let arr = this.AdjList.get(current);

    for(let elem of arr){
      if(!visited[elem]){
        visited[elem] = true;
        q.unshift(elem)
      }
    }

  }
}

6. 深度优先算法实现

深度优先搜索算法(Depth-First-Search,缩写为 DFS),是一种利用递归实现的搜索算法。简单来说,其搜索过程和 “不撞南墙不回头” 类似。

如上图所示,从起点出发,先把一个方向的点都遍历完才会改变方向...... 所以说,DFS 的搜索过程和 “不撞南墙不回头” 很相似,此即 “深度优先搜索算法” 中“深度”的由来。

该算法的前期步骤和BFS相似,接受起始节点并跟踪受访节点,最后执行递归的辅助函数。

具体步骤:

  • 接受起点作为参数dfs(startingNode)
  • 创建访问对象let visited = this.createVisitedObject()
  • 调用辅助函数递归起始节点和访问对象this.dfsHelper(startingNode, visited)
  • dfsHelper 将其标记为已访问并打印出来。
createVisitedObject(){
  let arr = {};
  for(let key of this.AdjList.keys()){
    arr[key] = false;
  }
  return arr;
}

 dfs(startingNode){
    console.log('\nDFS')
    let visited = this.createVisitedObject();
    this.dfsHelper(startingNode, visited);
  }

  dfsHelper(startingNode, visited){
    visited[startingNode] = true;
    console.log(startingNode);

    let arr = this.AdjList.get(startingNode);

    for(let elem of arr){
      if(!visited[elem]){
        this.dfsHelper(elem, visited);
      }
    }
  }

  doesPathExist(firstNode, secondNode){
    let path = [];
    let visited = this.createVisitedObject();
    let q = [];
    visited[firstNode] = true;
    q.push(firstNode);
    while(q.length){
      let node = q.pop();
      path.push(node);
      let elements = this.AdjList.get(node);
      if(elements.includes(secondNode)){
        console.log(path.join('->'))
        return true;
      }else{
        for(let elem of elements){
          if(!visited[elem]){
            visited[elem] = true;
            q.unshift(elem);
          }
        }
      }
    }
    return false;
  }
}

Vans,下一个。

8. 字典树:Trie

Trie(通常发音为“try”)是针对特定类型的搜索而优化的树数据结构。当你想要获取部分值并返回一组可能的完整值时,可以使用Trie。典型的例子是自动完成。

Trie,是一种搜索树,也称字典树或单词查找树,此外也称前缀树,因为某节点的后代存在共同的前缀。

它的特点:

  • key都为字符串,能做到高效查询和插入,时间复杂度为O(k),k为字符串长度
  • 缺点是如果大量字符串没有共同前缀时很耗内存。
  • 它的核心思想就是减少没必要的字符比较,使查询高效率。
  • 即用空间换时间,再利用共同前缀来提高查询效率。

例如:
搜索前缀“b”的匹配将返回6个值:bebearbellbidbullbuy

搜索前缀“be”的匹配将返回2个值:bear,bell

8.1 字典树的应用

只要你想要将前缀与可能的完整值匹配,就可以使用Trie

现实中多运用在:

  • 自动填充/预先输入
  • 搜索
  • 输入法选项
  • 分类

也可以运用在:

  • IP地址检索
  • 电话号码
  • 以及更多...

8.2 字典树的实现

class PrefixTreeNode {
  constructor(value) {
    this.children = {};
    this.endWord = null;
    this.value = value;
  }
}
class PrefixTree extends PrefixTreeNode {
  constructor() {
    super(null);
  }
  // 基础操作方法
  // addWord(string) {}
  // predictWord(string) {}
  // logAllWords() {}
}

1. addWord: 创建一个节点

addWord(string) {
    const addWordHelper = (node, str) => {
        if (!node.children[str[0]]) {
            node.children[str[0]] = new PrefixTreeNode(str[0]);
            if (str.length === 1) {
                node.children[str[0]].endWord = 1;
            } else if (str.length > 1) {
                addWordHelper(node.children[str[0]], str.slice(1));
        }
    };
    addWordHelper(this, string);
}

2. predictWord:预测单词

即:给定一个字符串,返回树中以该字符串开头的所有单词。

predictWord(string) {
    let getRemainingTree = function(string, tree) {
      let node = tree;
      while (string) {
        node = node.children[string[0]];
        string = string.substr(1);
      }
      return node;
    };

    let allWords = [];
    
    let allWordsHelper = function(stringSoFar, tree) {
      for (let k in tree.children) {
        const child = tree.children[k]
        let newString = stringSoFar + child.value;
        if (child.endWord) {
          allWords.push(newString);
        }
        allWordsHelper(newString, child);
      }
    };

    let remainingTree = getRemainingTree(string, this);
    if (remainingTree) {
      allWordsHelper(string, remainingTree);
    }

    return allWords;
  }

3. logAllWords:打印所有的节点

  logAllWords() {
    console.log('------ 所有在字典树中的节点 -----------')
    console.log(this.predictWord(''));
  }

logAllWords,通过在空字符串上调用predictWord来打印Trie中的所有节点。

9. 散列表(哈希表):Hash Tables

使用哈希表可以进行非常快速的查找操作。但是,哈希表究竟是什么玩意儿?

很多语言的内置数据结构像python中的字典,java中的HashMap,都是基于哈希表实现。但哈希表究竟是啥?

9.1 哈希表是什么?

散列(hashing)是电脑科学中一种对资料的处理方法,通过某种特定的函数/算法(称为散列函数/算法)将要检索的项与用来检索的索引(称为散列,或者散列值)关联起来,生成一种便于搜索的数据结构(称为散列表)。也译为散列。旧译哈希(误以为是人名而采用了音译)。

它也常用作一种资讯安全的实作方法,由一串资料中经过散列算法(Hashing algorithms)计算出来的资料指纹(data fingerprint),经常用来识别档案与资料是否有被窜改,以保证档案与资料确实是由原创者所提供。 —-Wikipedia

9.2 哈希表的构成

Hash Tables优化了键值对的存储。在最佳情况下,哈希表的插入,检索和删除是恒定时间。哈希表用于存储大量快速访问的信息,如密码。

哈希表可以概念化为一个数组,其中包含一系列存储在对象内部子数组中的元组:

{[[['a',9],['b',88]],[['e',7],['q',8]],[['j',7],['l ',8]]]};
  • 外部数组有多个等于数组最大长度的桶(子数组)。
  • 在桶内,元组或两个元素数组保持键值对。

9.3 哈希表的基础知识

这里我就尝试以大白话形式讲清楚基础的哈希表知识:

散列是一种用于从一组相似对象中唯一标识特定对象的技术。我们生活中如何使用散列的一些例子包括:

  • 在大学中,每个学生都会被分配一个唯一的卷号,可用于检索有关它们的信息。
  • 在图书馆中,每本书都被分配了一个唯一的编号,可用于确定有关图书的信息,例如图书馆中的确切位置或已发给图书的用户等。

在这两个例子中,学生和书籍都被分成了一个唯一的数字。

1. 思考一个问题

假设有一个对象,你想为其分配一个键以便于搜索。要存储键/值对,您可以使用一个简单的数组,如数据结构,其中键(整数)可以直接用作存储值的索引。

但是,如果密钥很大并且无法直接用作索引,此时就应该使用散列。

2, 一个哈希表的诞生

具体步骤如下:

  1. 在散列中,通过使用散列函数将大键转换为小键。
  2. 然后将这些值存储在称为哈希表的数据结构中。
  3. 散列的想法是在数组中统一分配条目(键/值对)。为每个元素分配一个键(转换键)。
  4. 通过使用该键,您可以在O(1)时间内访问该元素。
  5. 使用密钥,算法(散列函数)计算一个索引,可以找到或插入条目的位置。

具体执行分两步:

  • 通过使用散列函数将元素转换为整数。此元素可用作存储原始元素的索引,该元素属于哈希表。
  • 该元素存储在哈希表中,可以使用散列键快速检索它。
hash = hashfunc(key)
index = hash % array_size

在此方法中,散列与数组大小无关,然后通过使用运算符(%)将其缩减为索引(介于0array_size之间的数字 - 1)。

3. 哈希函数

  • 哈希函数是可用于将任意大小的数据集映射到固定大小的数据集的任何函数,该数据集属于散列表
  • 哈希函数返回的值称为哈希值,哈希码,哈希值或简单哈希值。

要实现良好的散列机制,需要具有以下基本要求:

  1. 易于计算:它应该易于计算,并且不能成为算法本身。
  2. 统一分布:它应该在哈希表中提供统一分布,不应导致群集。
  3. 较少的冲突:当元素对映射到相同的哈希值时发生冲突。应该避免这些。
注意:无论散列函数有多健壮,都必然会发生冲突。因此,为了保持哈希表的性能,通过各种冲突解决技术来管理冲突是很重要的。

4. 良好的哈希函数

假设您必须使用散列技术{“abcdef”,“bcdefa”,“cdefab”,“defabc”}等字符串存储在散列表中。

首先是建立索引:

  • a,b,c,d,efASCII值分别为97,98,99,100,101102,总和为:597
  • 597不是素数,取其附近的素数599,来减少索引不同字符串(冲突)的可能性。

哈希函数将为所有字符串计算相同的索引,并且字符串将以下格式存储在哈希表中。

由于所有字符串的索引都相同,此时所有字符串都在同一个“桶”中。

  • 这里,访问特定字符串需要O(n)时间(其中n是字符串数)。
  • 这表明该哈希函数不是一个好的哈希函数。

如何优化这个哈希函数?

注意观察这些字符串的异同

{“abcdef”,“bcdefa”,“cdefab”,“defabc”}
  • 都是由a,b,c,d,ef组成
  • 不同点在于组成顺序。

来尝试不同的哈希函数。

  • 特定字符串的索引将等于字符的ASCII值之和乘以字符串中它们各自的顺序
  • 之后将它与2069(素数)取余。

字符串哈希函数索引

字符串索引生成计算值
abcdef(97 1 + 98 2 + 99 3 + 100 4 + 101 5 + 102 6)%206938
bcdefa(98 1 + 99 2 + 100 3 + 101 4 + 102 5 + 97 6)%206923
cdefab(99 1 + 100 2 + 101 3 + 102 4 + 97 5 + 98 6)%206914
defabc(100 1 + 101 2 + 102 3 + 97 4 + 98 5 + 99 6)%206911

在合理的假设下,在哈希表中搜索元素所需的平均时间应是O(1)。

9.4 哈希表的实现

class Node {
    constructor( data ){
        this.data = data;
        this.next = null;
    }
}

class HashTableWithChaining {
    constructor( size = 10 ) {
        this.table = new Array( size );
    }
    
    // 操作方法
    // computeHash( string ) {...}
    // ...
}

1. isPrime:素数判断

isPrime( num ) {
    for(let i = 2, s = Math.sqrt(num); i <= s; i++)
        if(num % i === 0) return false; 
    return num !== 1;
}

2. computeHash|findPrime:哈希函数生成

computeHash( string ) {
    let H = this.findPrime( this.table.length );
    let total = 0;
    for (let i = 0; i < string.length; ++i) {
          total += H * total + string.charCodeAt(i);
       }
    return total % this.table.length;
}
// 取模
findPrime( num ) {
    while(true) {
        if( this.isPrime(num) ){ break; }
        num += 1
    }
    return num;
}

3. Put:插入值

put( item ) {
    let key = this.computeHash( item );
    let node = new Node(item)
    if ( this.table[key] ) {
        node.next = this.table[key]
    }
    this.table[key] = node
}

4. Remove:删除值

remove( item ) {
    let key = this.computeHash( item );
    if( this.table[key] ) {
        if( this.table[key].data === item ) {
            this.table[key] = this.table[key].next
        } else {
            let current = this.table[key].next;
            let prev = this.table[key];
            while( current ) {
                if( current.data === item ) {
                    prev.next = current.next
                }
                prev = current
                current = current.next;
            }
        }
    }
}


5. contains:判断包含

contains(item) {
    for (let i = 0; i < this.table.length; i++) {
        if (this.table[i]) {
            let current = this.table[i];
            while (current) {
                if (current.data === item) {
                    return true;
                }
                current = current.next;
            }
        }
    }
    return false;
}

6. Size & IsEmpty:判断长度或空

size( item ) {
  let counter = 0
  for(let i=0; i<this.table.length; i++){
    if( this.table[i] ) {
      let current = this.table[i]
      while( current ) {
        counter++
        current = current.next
      }
    }
  }
  return counter
}
isEmpty() {
  return this.size() < 1
}

7. Traverse:遍历

traverse( fn ) {
  for(let i=0; i<this.table.length; i++){
    if( this.table[i] ) {
      let current = this.table[i];
      while( current ) {
        fn( current );
        current = current.next;
      }
    }
  }
}

10. 为啥写这篇

还是和面试有关。虽然leetcode上的题刷过一些,但因为缺乏对数据结构的整体认知。很多时候被问到或考到,会无所下手。

网上的帖子大多深浅不一,写这篇的过程中翻阅了大量的资料和示例。在下的文章都是学习过程中的总结,如果发现错误,欢迎留言指出。

参考:

  1. DS with JS - Hash Tables— I
  2. Joseph Crick - Practical Data Structures for Frontend Applications: When to use Tries
  3. [Thon Ly - Data Structures in JavaScript

](https://medium.com/siliconwat...

  1. Graphs — A Visual Introduction for Beginners
  2. Graph Data Structure in JavaScript
  3. Trie (Keyword Tree)
查看原文

赞 20 收藏 16 评论 1

前端劝退师 发布了文章 · 9月11日

一文搞懂Web中暗藏的密码学

前言

开发网站登录功能时,如何保证密码在传输过程/储存的安全?

相信不少前后端的朋友,在面试时都会被问到类似的问题。

在我对密码学一无所知时,也仅会回答:“MD5加密啊。”

诸不知,密码学在网络七层模型,甚至web开发中的应用比我想象得多得多。

1. 什么是密码学?

密码学是各种安全应用程序所必需的,现代密码学旨在创建通过应用数学原理和计算机科学来保护信息的机制。但相比之下,密码分析旨在解密此类机制,以便获得对信息的非法访问。


密码学具有三个关键属性:

  • 机密性,为了防止未经授权的各方访问信息(换句话说,是要确保只有经过授权的人才能访问受限制的数据)。
  • 完整性,是指保护信息不被随意篡改
  • 真实性,与识别信息的所有者有关。

例如个人医疗数据:

  • 机密性,个人医疗数据需要保密,这意味着只有医生或医护人员才能访问它。
  • 完整性,还必须保护其完整性,因为篡改此类数据可能导致错误的诊断或治疗,并给患者带来健康风险。
  • 真实性,患者数据应与已识别的个人联系起来,且患者需要知道操作者(医生)是谁。

在本文中,我们将从加密,哈希,编码和混淆四种密码学基础技术来入门。

本文图片经过再制,方便看懂。

大纲和主体内容引自: How Secure Are Encryption, Hashing, Encoding and Obfuscation?

2. 什么是加密?

加密定义:以保证机密性的方式转换数据的过程。

为此,加密需要使用一个保密工具,就密码学而言,我们称其为“密钥”。

加密密钥和任何其他加密密钥应具有一些属性:

  • 为了保护机密性,密钥的值应难以猜测。
  • 应该在单个上下文中使用它,避免在不同上下文中重复使用(类比JS作用域)。密钥重用会带来安全风险,如果规避了其机密性,则影响更大,因为它“解锁”了更敏感的数据。

2.1 加密的分类:对称和非对称

加密分为两类:对称和非对称

对称加密:

用途:文件系统加密,Wi-Fi保护访问(WPA),数据库加密(例如信用卡详细信息)

非对称加密:


用途: TLSVPNSSH

其主要区别是:所需的密钥数量

  • 在对称加密算法中,单个密用于加密和解密数据。只有那些有权访问数据的人才能拥有单个共享密钥。
  • 在非对称加密算法中,使用了两个密钥:一个是公用密钥,一个是私有密钥。顾名思义,私钥必须保密,而每个人都可以知道公钥。

    • 应用加密时,将使用公钥,而解密则需要私钥。
    • 任何人都应该能够向我们发送加密数据,但是只有我们才能够解密和读取它。
  1. 通常使用非对称加密来在不安全的通道上进行通信时,两方之间会安全地建立公共密钥。
  2. 通过此共享密钥,双方切换到对称加密。
  3. 这种加密速度更快,更适合处理大量数据。


能被密码界承认的加密算法都是公开的

  • 某些公司使用专有或“军事级”加密技术进行加密,这些技术是“私有的”。且基于“复杂“算法,但这不是加密的工作方式。
  • 密码界广泛使用和认可的所有加密算法都是公开的,因为它们基于数学算法,只有拥有密钥或先进的计算能力才能解决。
  • 公开算法是得到广泛采用,证明了其价值的。

3. 什么是哈希?

哈希算法定义:·一种只能加密,不能解密的密码学算法,可以将任意长度的信息转换成一段固定长度的字符串。

加密算法是可逆的(使用密钥),并且可以提供机密性(某些较新的加密算法也可以提供真实性),而哈希算法是不可逆的,并且可以提供完整性,以证明未修改特定数据。

哈希算法的前提很简单:给定任意长度的输入,输出特定长度的字节。在大多数情况下,此字节序列对于该输入将是唯一的,并且不会给出输入是什么的指示。换一种说法:

  1. 仅凭哈希算法的输出,是无法确定原始数据的。
  2. 取一些任意数据以及使用哈希算法输出,就可以验证此数据是否与原始输入数据匹配,从而无需查看原始数据。

为了说明这一点,请想象一个强大的哈希算法通过将每个唯一输入放在其自己的存储桶中而起作用。当我们要检查两个输入是否相同时,我们可以简单地检查它们是否在同一存储桶中。

散列文件的存储单位称为桶(Bucket)

3.1 例子一:资源下载

提供文件下载的网站通常会返回每个文件的哈希值,以便用户可以验证其下载副本的完整性。

例如,在Debian的图像下载服务中,您会找到其他文件,例如SHA256SUMS,其中包含可供下载的每个文件的哈希输出(在本例中为SHA-256算法)。

  • 下载文件后,可以将其传递给选定的哈希算法,输出一段哈希值
  • 用该哈希值来与校验和文件中列出的哈希值作匹配,以校验是否一致。

在终端中,可以用openssl来对文件进行哈希处理:

$ openssl sha256 /Users/hiro/Downloads/非对称.png
SHA256(/Users/hiro/Downloads/非对称.png)= 7c264efc9ea7d0431e7281286949ec4c558205f690c0df601ff98d59fc3f4f64

同一个文件采用相同的hash算法时,就可以用来校验是否同源。

在强大的哈希算法中,如果有两个不同的输入,则几乎不可能获得相同的输出。

而相反的,如果计算后的结果范围有限,就会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。(两个不同的数据计算后的结果一样)

这种称为:哈希碰撞(哈希冲突)

如果两个不同的输入最终出现在同一个存储桶中,则会发生冲突。如MD5SHA-1,就会出现这种情况。这是有问题的,因为我们无法区分哪个碰撞的值匹配输入。

强大的哈希算法几乎会为每个唯一输入创建一个新存储桶。

3.2 例子二:网站登陆

web开发中,哈希算法使用最频繁的是在网站登陆应用上:

绝大多数的网站,在将登陆数据存入时,都会将密码哈希后存储。

  • 这是为了避免他人盗取数据库信息后,还原出你的初始输入。
  • 且下次登录时,Web应用程序将再次对你的密码进行哈希处理,并将此哈希与之前存储的哈希进行比较。
  • 如果哈希匹配,即使Web应用程序中没有实际的密码存储,Web应用程序也确信你知道密码。

注册:

登陆:

哈希算法的一个有趣的方面是:无论输入数据的长度如何,散列的输出始终是相同的长度。

从理论上讲,碰撞冲突将始终在可能性的范围之内,尽管可能性很小。

与之相反的是编码

4. 什么是编码?

编码定义:将数据从一种形式转换为另一种形式的过程,与加密无关

它不保证机密性,完整性和真实性这三种加密属性,因为:

  • 不涉及任何秘密且是完全可逆的。
  • 通常会输出与输入值成比例的数据量,并且始终是该输入的唯一值。
  • 编码方法被认为是公共的,普遍用于数据处理
  • 编码永远不适用于操作安全性相关

4.1 URL编码

又叫百分号编码,是统一资源定位(URL)编码方式。URL地址(常说网址)规定了:

  • 常用地数字,字母可以直接使用,另外一批作为特殊用户字符也可以直接用(/,:@等)
  • 剩下的其它所有字符必须通过%xx编码处理。

现在已经成为一种规范了,基本所有程序语言都有这种编码,如:

  • js:encodeURI、encodeURIComponent
  • PHP:urlencode、urldecode等。

编码方法很简单,在该字节ascii码的16进制字符前面加%. 如 空格字符,ascii码是32,对应16进制是'20',那么urlencode编码结果是:%20

# 源文本:
The quick brown fox jumps over the lazy dog

# 编码后:
#!shell
%54%68%65%20%71%75%69%63%6b%20%62%72%6f%77%6e%20%66%6f%78%20%6a%75%6d%70%73%20%6f%76%65%72%20%74%68%65%20%6c%61%7a%79%20%64%6f%67

4.2 HTML实体编码

HTML中,需要对数据进行HTML编码以遵守所需的HTML字符格式。转义避免XSS攻击也是如此。

4.3 Base64/32/16编码

base64base32base16可以分别编码转化8位字节为6位、5位、4位。

16,32,64分别表示用多少个字符来编码,

Base64常用于在通常处理文本数据的场合,表示、传输、存储一些二进制数据。包括MIMEemail,email via MIME,在XML中存储复杂数据。

编码原理:

  1. Base64编码要求把3个8位字节转化为4个6位的字节
  2. 之后在6位的前面补两个0,形成8位一个字节的形式
  3. 6位2进制能表示的最大数是2的6次方是64,这也是为什么是64个字符的原因

    • A-Z,a-z,0-9,+,/这64个编码字符,=号不属于编码字符,而是填充字符

Base64映射表,如下:


举个栗子:

引自:一篇文章彻底弄懂Base64编码原理

  • 第一步:“M”、“a”、"n"对应的ASCII码值分别为77,97,110,对应的二进制值是010011010110000101101110。如图第二三行所示,由此组成一个24位的二进制字符串。
  • 第二步:如图红色框,将24位每6位二进制位一组分成四组。
  • 第三步:在上面每一组前面补两个0,扩展成32个二进制位,此时变为四个字节:00010011000101100000010100101110。分别对应的值(Base64编码索引)为:19、22、5、46。
  • 第四步:用上面的值在Base64编码表中进行查找,分别对应:T、W、F、u。因此“ManBase64编码之后就变为:TWFu

上面的示例旨在指出,编码的用例仅是数据处理,而不为编码的数据提供保护。

4. 什么是混淆?

混淆定义:将人类可读的字符串转换为难以理解的字符串

  • 与加密相反,混淆处理不包含加密密钥。
  • 与编码类似,混淆不能保证任何安全性,尽管有时会误将其用作加密方法

尽管不能保证机密性,但混淆仍有其它应用:

  • 用于防止篡改和保护知识产权。
  • APP源代码通常在打包之前就被混淆了

    • 因为源代码位于用户的设备中,可以从中提取代码。由于混淆后代码不友好,因此会阻止逆向工程,从而有助于保护知识产权。
    • 反过来,这可以防止篡改代码并将其重新分发以供恶意使用。

但是,如此存在许多有助于消除应用程序代码混淆的工具。那就是其它话题了。。。

4.1 例子一:JavaScript混淆

JavaScript源代码:

function hello(name) {
  console.log('Hello, ' + name);
}

hello('New user');

混淆后:

var _0xa1cc=["\x48\x65\x6C\x6C\x6F\x2C\x20","\x6C\x6F\x67","\x4E\x65\x77\x20\x75\x73\x65\x72"];
function hello(_0x2cc8x2){console[_0xa1cc[1]](_0xa1cc[0]+ _0x2cc8x2)}hello(_0xa1cc[2])

总结

从机密性,完整性,真实性分析四种密码技术:

加密哈希编码混淆
机密性
完整性
真实性
  • 加密,虽然是为了保证数据的机密性,但某些现代加密算法还采用了其他策略来保证数据的完整性(有时通过嵌入式哈希算法)和真实性。
  • 哈希,只能保证完整性,但可以通过完整性对比来做权限控制,如:基于哈希的消息认证码(HMAC)和某些传输层安全性(TLS)方法。
  • 编码,过去曾被用来表示加密,并在技术领域之外仍具有这种含义,但在编程世界中,它仅是一种数据处理机制,从未提供任何安全措施
  • 混淆,可以用来提高抵御攻击的能力;但是,它永远不能保证数据的机密性。狡猾的对手最终将绕过混淆策略。与编码一样,永远不要将混淆视为可靠的安全控制

附录:哈希函数

常用的哈希函数

  • MD5,一种被广泛使用的密码杂凑函数,可以产生出一个128位元(16位元组)的哈希值,用于确保信息传输完整一致。*虽广泛,但过时。
  • SHA-256/SHA512 , "加盐"。在比特币中,区块链使用SHA-256算法作为基础的加密哈希函数。

    • 安全散列算法secure hash algorithm,是一个密码哈希函数家族。
    • SHA家族有五个算法,分别是SHA-1,SHA-224,SHA-256,SHA-384,SHA-512
    • 它们是美国的政府标准,后面的四个称之为SHA-2
  • bcrypt:bcrypt算法相对来说是运算比较慢的算法。

    • 在密码学界有句常话:越慢的算法越安全。算法越算,黑客破解成本越高:
    • 通过saltconst这两个值来减缓加密过程,ta的加密时间(百ms级)远远超过md5(大概1ms左右)。
    • 对于计算机来说,Bcrypt 的计算速度很慢,但是对于用户来说,这个过程不算慢。
    • bcrypt是单向的,而且经过saltcost的处理,使其受rainbow攻击破解的概率大大降低,同时破解的难度也提升不少。
    • 相对于MD5等加密方式更加安全,而且使用也比较简单.
    • 设计良好的密钥扩展算法,如PBKDF2bcryptscrypt

    后记 & 引用

    ❤️ 看完三件事

    如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

    1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
    2. 关注「前端劝退师」,不定期分享原创知识。
    3. 也看看其它文章

    也可以来我的GitHub博客里拿所有文章的源文件:

    前端劝退指南https://github.com/roger-hiro...

    查看原文

    赞 4 收藏 2 评论 0

    前端劝退师 发布了文章 · 9月11日

    一文搞懂Web中暗藏的密码学

    前言

    开发网站登录功能时,如何保证密码在传输过程/储存的安全?

    相信不少前后端的朋友,在面试时都会被问到类似的问题。

    在我对密码学一无所知时,也仅会回答:“MD5加密啊。”

    诸不知,密码学在网络七层模型,甚至web开发中的应用比我想象得多得多。

    1. 什么是密码学?

    密码学是各种安全应用程序所必需的,现代密码学旨在创建通过应用数学原理和计算机科学来保护信息的机制。但相比之下,密码分析旨在解密此类机制,以便获得对信息的非法访问。


    密码学具有三个关键属性:

    • 机密性,为了防止未经授权的各方访问信息(换句话说,是要确保只有经过授权的人才能访问受限制的数据)。
    • 完整性,是指保护信息不被随意篡改
    • 真实性,与识别信息的所有者有关。

    例如个人医疗数据:

    • 机密性,个人医疗数据需要保密,这意味着只有医生或医护人员才能访问它。
    • 完整性,还必须保护其完整性,因为篡改此类数据可能导致错误的诊断或治疗,并给患者带来健康风险。
    • 真实性,患者数据应与已识别的个人联系起来,且患者需要知道操作者(医生)是谁。

    在本文中,我们将从加密,哈希,编码和混淆四种密码学基础技术来入门。

    本文图片经过再制,方便看懂。

    大纲和主体内容引自: How Secure Are Encryption, Hashing, Encoding and Obfuscation?

    2. 什么是加密?

    加密定义:以保证机密性的方式转换数据的过程。

    为此,加密需要使用一个保密工具,就密码学而言,我们称其为“密钥”。

    加密密钥和任何其他加密密钥应具有一些属性:

    • 为了保护机密性,密钥的值应难以猜测。
    • 应该在单个上下文中使用它,避免在不同上下文中重复使用(类比JS作用域)。密钥重用会带来安全风险,如果规避了其机密性,则影响更大,因为它“解锁”了更敏感的数据。

    2.1 加密的分类:对称和非对称

    加密分为两类:对称和非对称

    对称加密:

    用途:文件系统加密,Wi-Fi保护访问(WPA),数据库加密(例如信用卡详细信息)

    非对称加密:


    用途: TLSVPNSSH

    其主要区别是:所需的密钥数量

    • 在对称加密算法中,单个密用于加密和解密数据。只有那些有权访问数据的人才能拥有单个共享密钥。
    • 在非对称加密算法中,使用了两个密钥:一个是公用密钥,一个是私有密钥。顾名思义,私钥必须保密,而每个人都可以知道公钥。

      • 应用加密时,将使用公钥,而解密则需要私钥。
      • 任何人都应该能够向我们发送加密数据,但是只有我们才能够解密和读取它。
    1. 通常使用非对称加密来在不安全的通道上进行通信时,两方之间会安全地建立公共密钥。
    2. 通过此共享密钥,双方切换到对称加密。
    3. 这种加密速度更快,更适合处理大量数据。


    能被密码界承认的加密算法都是公开的

    • 某些公司使用专有或“军事级”加密技术进行加密,这些技术是“私有的”。且基于“复杂“算法,但这不是加密的工作方式。
    • 密码界广泛使用和认可的所有加密算法都是公开的,因为它们基于数学算法,只有拥有密钥或先进的计算能力才能解决。
    • 公开算法是得到广泛采用,证明了其价值的。

    3. 什么是哈希?

    哈希算法定义:·一种只能加密,不能解密的密码学算法,可以将任意长度的信息转换成一段固定长度的字符串。

    加密算法是可逆的(使用密钥),并且可以提供机密性(某些较新的加密算法也可以提供真实性),而哈希算法是不可逆的,并且可以提供完整性,以证明未修改特定数据。

    哈希算法的前提很简单:给定任意长度的输入,输出特定长度的字节。在大多数情况下,此字节序列对于该输入将是唯一的,并且不会给出输入是什么的指示。换一种说法:

    1. 仅凭哈希算法的输出,是无法确定原始数据的。
    2. 取一些任意数据以及使用哈希算法输出,就可以验证此数据是否与原始输入数据匹配,从而无需查看原始数据。

    为了说明这一点,请想象一个强大的哈希算法通过将每个唯一输入放在其自己的存储桶中而起作用。当我们要检查两个输入是否相同时,我们可以简单地检查它们是否在同一存储桶中。

    散列文件的存储单位称为桶(Bucket)

    3.1 例子一:资源下载

    提供文件下载的网站通常会返回每个文件的哈希值,以便用户可以验证其下载副本的完整性。

    例如,在Debian的图像下载服务中,您会找到其他文件,例如SHA256SUMS,其中包含可供下载的每个文件的哈希输出(在本例中为SHA-256算法)。

    • 下载文件后,可以将其传递给选定的哈希算法,输出一段哈希值
    • 用该哈希值来与校验和文件中列出的哈希值作匹配,以校验是否一致。

    在终端中,可以用openssl来对文件进行哈希处理:

    $ openssl sha256 /Users/hiro/Downloads/非对称.png
    SHA256(/Users/hiro/Downloads/非对称.png)= 7c264efc9ea7d0431e7281286949ec4c558205f690c0df601ff98d59fc3f4f64

    同一个文件采用相同的hash算法时,就可以用来校验是否同源。

    在强大的哈希算法中,如果有两个不同的输入,则几乎不可能获得相同的输出。

    而相反的,如果计算后的结果范围有限,就会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。(两个不同的数据计算后的结果一样)

    这种称为:哈希碰撞(哈希冲突)

    如果两个不同的输入最终出现在同一个存储桶中,则会发生冲突。如MD5SHA-1,就会出现这种情况。这是有问题的,因为我们无法区分哪个碰撞的值匹配输入。

    强大的哈希算法几乎会为每个唯一输入创建一个新存储桶。

    3.2 例子二:网站登陆

    web开发中,哈希算法使用最频繁的是在网站登陆应用上:

    绝大多数的网站,在将登陆数据存入时,都会将密码哈希后存储。

    • 这是为了避免他人盗取数据库信息后,还原出你的初始输入。
    • 且下次登录时,Web应用程序将再次对你的密码进行哈希处理,并将此哈希与之前存储的哈希进行比较。
    • 如果哈希匹配,即使Web应用程序中没有实际的密码存储,Web应用程序也确信你知道密码。

    注册:

    登陆:

    哈希算法的一个有趣的方面是:无论输入数据的长度如何,散列的输出始终是相同的长度。

    从理论上讲,碰撞冲突将始终在可能性的范围之内,尽管可能性很小。

    与之相反的是编码

    4. 什么是编码?

    编码定义:将数据从一种形式转换为另一种形式的过程,与加密无关

    它不保证机密性,完整性和真实性这三种加密属性,因为:

    • 不涉及任何秘密且是完全可逆的。
    • 通常会输出与输入值成比例的数据量,并且始终是该输入的唯一值。
    • 编码方法被认为是公共的,普遍用于数据处理
    • 编码永远不适用于操作安全性相关

    4.1 URL编码

    又叫百分号编码,是统一资源定位(URL)编码方式。URL地址(常说网址)规定了:

    • 常用地数字,字母可以直接使用,另外一批作为特殊用户字符也可以直接用(/,:@等)
    • 剩下的其它所有字符必须通过%xx编码处理。

    现在已经成为一种规范了,基本所有程序语言都有这种编码,如:

    • js:encodeURI、encodeURIComponent
    • PHP:urlencode、urldecode等。

    编码方法很简单,在该字节ascii码的16进制字符前面加%. 如 空格字符,ascii码是32,对应16进制是'20',那么urlencode编码结果是:%20

    # 源文本:
    The quick brown fox jumps over the lazy dog
    
    # 编码后:
    #!shell
    %54%68%65%20%71%75%69%63%6b%20%62%72%6f%77%6e%20%66%6f%78%20%6a%75%6d%70%73%20%6f%76%65%72%20%74%68%65%20%6c%61%7a%79%20%64%6f%67

    4.2 HTML实体编码

    HTML中,需要对数据进行HTML编码以遵守所需的HTML字符格式。转义避免XSS攻击也是如此。

    4.3 Base64/32/16编码

    base64base32base16可以分别编码转化8位字节为6位、5位、4位。

    16,32,64分别表示用多少个字符来编码,

    Base64常用于在通常处理文本数据的场合,表示、传输、存储一些二进制数据。包括MIMEemail,email via MIME,在XML中存储复杂数据。

    编码原理:

    1. Base64编码要求把3个8位字节转化为4个6位的字节
    2. 之后在6位的前面补两个0,形成8位一个字节的形式
    3. 6位2进制能表示的最大数是2的6次方是64,这也是为什么是64个字符的原因

      • A-Z,a-z,0-9,+,/这64个编码字符,=号不属于编码字符,而是填充字符

    Base64映射表,如下:


    举个栗子:

    引自:一篇文章彻底弄懂Base64编码原理

    • 第一步:“M”、“a”、"n"对应的ASCII码值分别为77,97,110,对应的二进制值是010011010110000101101110。如图第二三行所示,由此组成一个24位的二进制字符串。
    • 第二步:如图红色框,将24位每6位二进制位一组分成四组。
    • 第三步:在上面每一组前面补两个0,扩展成32个二进制位,此时变为四个字节:00010011000101100000010100101110。分别对应的值(Base64编码索引)为:19、22、5、46。
    • 第四步:用上面的值在Base64编码表中进行查找,分别对应:T、W、F、u。因此“ManBase64编码之后就变为:TWFu

    上面的示例旨在指出,编码的用例仅是数据处理,而不为编码的数据提供保护。

    4. 什么是混淆?

    混淆定义:将人类可读的字符串转换为难以理解的字符串

    • 与加密相反,混淆处理不包含加密密钥。
    • 与编码类似,混淆不能保证任何安全性,尽管有时会误将其用作加密方法

    尽管不能保证机密性,但混淆仍有其它应用:

    • 用于防止篡改和保护知识产权。
    • APP源代码通常在打包之前就被混淆了

      • 因为源代码位于用户的设备中,可以从中提取代码。由于混淆后代码不友好,因此会阻止逆向工程,从而有助于保护知识产权。
      • 反过来,这可以防止篡改代码并将其重新分发以供恶意使用。

    但是,如此存在许多有助于消除应用程序代码混淆的工具。那就是其它话题了。。。

    4.1 例子一:JavaScript混淆

    JavaScript源代码:

    function hello(name) {
      console.log('Hello, ' + name);
    }
    
    hello('New user');

    混淆后:

    var _0xa1cc=["\x48\x65\x6C\x6C\x6F\x2C\x20","\x6C\x6F\x67","\x4E\x65\x77\x20\x75\x73\x65\x72"];
    function hello(_0x2cc8x2){console[_0xa1cc[1]](_0xa1cc[0]+ _0x2cc8x2)}hello(_0xa1cc[2])

    总结

    从机密性,完整性,真实性分析四种密码技术:

    加密哈希编码混淆
    机密性
    完整性
    真实性
    • 加密,虽然是为了保证数据的机密性,但某些现代加密算法还采用了其他策略来保证数据的完整性(有时通过嵌入式哈希算法)和真实性。
    • 哈希,只能保证完整性,但可以通过完整性对比来做权限控制,如:基于哈希的消息认证码(HMAC)和某些传输层安全性(TLS)方法。
    • 编码,过去曾被用来表示加密,并在技术领域之外仍具有这种含义,但在编程世界中,它仅是一种数据处理机制,从未提供任何安全措施
    • 混淆,可以用来提高抵御攻击的能力;但是,它永远不能保证数据的机密性。狡猾的对手最终将绕过混淆策略。与编码一样,永远不要将混淆视为可靠的安全控制

    附录:哈希函数

    常用的哈希函数

    • MD5,一种被广泛使用的密码杂凑函数,可以产生出一个128位元(16位元组)的哈希值,用于确保信息传输完整一致。*虽广泛,但过时。
    • SHA-256/SHA512 , "加盐"。在比特币中,区块链使用SHA-256算法作为基础的加密哈希函数。

      • 安全散列算法secure hash algorithm,是一个密码哈希函数家族。
      • SHA家族有五个算法,分别是SHA-1,SHA-224,SHA-256,SHA-384,SHA-512
      • 它们是美国的政府标准,后面的四个称之为SHA-2
    • bcrypt:bcrypt算法相对来说是运算比较慢的算法。

    • 在密码学界有句常话:越慢的算法越安全。算法越算,黑客破解成本越高:
    • 通过saltconst这两个值来减缓加密过程,ta的加密时间(百ms级)远远超过md5(大概1ms左右)。
    • 对于计算机来说,Bcrypt 的计算速度很慢,但是对于用户来说,这个过程不算慢。
    • bcrypt是单向的,而且经过saltcost的处理,使其受rainbow攻击破解的概率大大降低,同时破解的难度也提升不少。
    • 相对于MD5等加密方式更加安全,而且使用也比较简单.
    • 设计良好的密钥扩展算法,如PBKDF2bcryptscrypt

    后记 & 引用

    ❤️ 看完三件事

    如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

    1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
    2. 关注公众号「前端劝退师」,不定期分享原创知识。
    3. 也看看其它文章

    也可以来我的GitHub博客里拿所有文章的源文件:

    前端劝退指南https://github.com/roger-hiro...

    查看原文

    赞 2 收藏 2 评论 0

    前端劝退师 发布了文章 · 9月1日

    想要成为前端Star 吗?一首歌时间将React/Vue 应用Docker 化

    前言

    以前一直有疑问困扰着我:人人都在吹的Docker容器化,与前端有何关系?

    然而在近两年的编程生涯,在每一次产品迭代中,渐渐体会到了容器化其魅力所在。

    应用部署从刀耕火种,到DevOps崛起,原来不止前端在迅捷发展。接下来,我将用一首歌的时间,带大家真实的体验一番Docker容器化。

    1. 朴素的Dockerfile

    首先准备一个有标准运行指令的Web应用,用脚手架creat-react-appVue CLI等生成的即可。

    以下的Dockerfile不参杂其它依赖,争取做到都能看懂:

    # 指定Node版本
    FROM node:12.18.3
    
    # 容器中应用程序的路径。将Web目录作为工作目录
    WORKDIR /web
    
    # 将package.json 复制到 Docker 环境
    COPY ./package.json /web/package.json
    
    # 安装依赖
    RUN yarn
    
    # 将代码复制到Docker容器中的Web目录 
    COPY . /web/
    
    # 暴露容器内部访问端口,根据项目变动
    EXPOSE 8080
    
    ## 如果是Vue CLi,则换成 yarn serve
    CMD ["npm", "start"]

    是的,开发环境在Docker 部署,关键配置就那么几行。

    此外,还需要添加一个.dockerignore文件,加快构建过程的速度

    node_modules/**/*
    build/**/*
    .DS_Store

    2. 为应用构建Docker镜像

    首先确认你的Dcoker 正在运行。

    运行以下命令来构建Docker映像。react-docker 可以替换为你要为镜像命名的任何值。

    docker build -t react-docker .
    

    其中-t 为打标签的意思,执行完后将会看到:

    Successfully built 137c69857dd0
    Successfully tagged react-docker:latest

    您的镜像已经嗷嗷待发。

    3. 运行Docker + React/Vue App

    现在,使用以下docker run命令, 通过Docker在端口3000上运行React应用。

    docker run -p 3000:3000 react-docker

    其中:前一个3000对应本机http://localhost:3000/,第二个3000则是Docker容器端口。

    可以通过Dcoker ps查看容器信息

    DockerDashboard中也可以看到:

    此时打开http://localhost:3000/就会看到熟悉又亲切的画面

    到这里,你的一首歌的时间之Docker之旅就结束了。接下来的将是更标准化的流程,劝退劝退!

    4. 使用Docker Compose标准化流程

    docker-compose.yml文件添加到项目根目录:

    version: '3.7'
    
    services:
    
      sample:
        container_name: sample
        build:
          context: .
          dockerfile: Dockerfile
        volumes:
          - '.:/app'
          - '/app/node_modules'
        ports:
          - 3000:3000
        environment:
          - CHOKIDAR_USEPOLLING=true

    有了该文件,就不需要分步执行了,直接:

    docker-compose up -d --build

    就能看到一样构建了:

    5. 生产环境下的Dockerfile

    生产环境下需要nginx配置,在根目录先创建nginx.config

    server {
        listen       ${PORT:-80};
        server_name  _;
    
        root /usr/share/nginx/html;
        index index.html;
    
        location / {
            try_files $$uri /index.html;
        }
    }

    让我们创建一个单独的Dockerfile,用于生产环境,称为Dockerfile.prod

    FROM node:12.18.3 AS builder
    
    WORKDIR /app
    
    ENV PATH /app/node_modules/.bin:$PATH
    COPY package.json ./
    COPY package-lock.json ./
    
    # 前端项目构建命令 — npm ci 或 npm install 
    # http://www.gaoxiukun.com/wp/archives/509
    
    RUN npm ci
    # React 应用需要react-script
    RUN npm install react-scripts@3.4.1 -g
    
    COPY . ./
    RUN npm run build
    
    # 安装nginx
    FROM nginx:1.17-alpine
    RUN apk --no-cache add curl
    RUN curl -L https://github.com/a8m/envsubst/releases/download/v1.1.0/envsubst-`uname -s`-`uname -m` -o envsubst && \
        chmod +x envsubst && \
        mv envsubst /usr/local/bin
    COPY ./nginx.config /etc/nginx/nginx.template
    CMD ["/bin/sh", "-c", "envsubst < /etc/nginx/nginx.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
    
    COPY --from=builder /app/build /usr/share/nginx/html

    因为Dockerfile.prod不是默认的执行文件,所以需要构建并标记:

    docker build -f Dockerfile.prod -t sample:prod .

    接下来执行docker run

    docker run -it --rm -p 3000:80 sample:prod
    • -i: 以交互模式运行容器。
    • -t: 为容器重新分配一个伪输入终端,通常与 -i 同时使用。
    • --rm:在容器退出时自动清理容器内部的文件系统,不懂可忽略
    • -p: 指定端口。

    成功运行:

    在浏览器中导航到http://localhost:3000 以查看该应用程序。

    接下来使用新的Docker Compose文件以及docker-compose.prod.yml进行测试:

    version: '3.7'
    
    services:
      sample-prod:
        container_name: sample-prod
        build:
          context: .
          dockerfile: Dockerfile.prod
        ports:
          - '3000:80'

    启动容器:

    docker-compose -f docker-compose.prod.yml up -d --build

    在浏览器中再次进行校验。

    ❤️ 结语

    在以往,我对Docker容器化的概念,仅停留在了解。而真正实操中,也是被一群指令,配置给吓到劝退。

    本文弱化了命令行参数,希望能让广大萌新们能先看懂,再去演练一番,举一反三,不再怕Docker,然后再去学习k8s相关。

    Docker 在接下来的几年里,会逐渐成为开发的标配,希望大家能放下对运维领域的偏见,多多学习这些行业内的新标准与概念。

    如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

    1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
    2. 关注公众号「前端劝退师」,不定期分享原创知识。
    3. 也看看其它文章

    也可以来我的GitHub博客里拿所有文章的源文件:

    前端劝退指南https://github.com/roger-hiro...
    一起玩耍呀。~

    查看原文

    赞 40 收藏 27 评论 9

    前端劝退师 关注了标签 · 7月21日

    android

    Android(安卓或安致)是一种以 Linux 为基础的开放源码操作系统,主要使用于便携设备。2005 年由 Google 收购注资,并拉拢多家制造商组成开放手机联盟开发改良,逐渐扩展到到平板电脑及其他领域上。

    简介

      Android一词的本义指“机器人”,同时也是Google于2007年11月5日宣布的基于Linux平台的开源手机操作系统的名称,该平台由操作系统、中间件、用户界面和应用软件组成。 

      系统架构

      android的系统架构和其操作系统一样,采用了分层的架构。从架构图看,android分为四个层,从高层到低层分别是应用程序层、应用程序框架层、系统运行库层和linux核心层。

      应用程序

      Android会同一系列核心应用程序包一起发布,该应用程序包包括客户端,SMS短消息程序,日历,地图,浏览器,联系人管理程序等。所有的应用程序都是使用JAVA语言编写的。

      应用程序框架

      开发人员也可以完全访问核心应用程序所使用的API框架。该应用程序的架构设计简化了组件的重用;任何一个应用程序都可以发布它的功能块并且任何其它的应用程序都可以使用其所发布的功能块(不过得遵循框架的安全性)。同样,该应用程序重用机制也使用户可以方便的替换程序组件。

      隐藏在每个应用后面的是一系列的服务和系统, 其中包括;

      丰富而又可扩展的视图(Views),可以用来构建应用程序, 它包括列表(lists),网格(grids),文本框(text boxes),按钮(buttons), 甚至可嵌入的web浏览器。

      内容提供器(Content Providers)使得应用程序可以访问另一个应用程序的数据(如联系人数据库), 或者共享它们自己的数据

      资源管理器(Resource Manager)提供 非代码资源的访问,如本地字符串,图形,和布局文件( layout files )。

      通知管理器 (Notification Manager) 使得应用程序可以在状态栏中显示自定义的提示信息。

      活动管理器( Activity Manager) 用来管理应用程序生命周期并提供常用的导航回退功能。

      有关更多的细节和怎样从头写一个应用程序,请参考 如何编写一个 Android 应用程序。

      系统运行库

      Android 包含一些C/C++库,这些库能被Android系统中不同的组件使用。它们通过 Android 应用程序框架为开发者提供服务。以下是一些核心库:

      * 系统 C 库 - 一个从BSD继承来的标准 C 系统函数库( libc ), 它是专门为基于 embedded linux的设备定制的。

      * 媒体库 - 基于PacketVideo OpenCORE;该库支持多种常用的音频、视频格式回放和录制,同时支持静态图像文件。编码格式包括MPEG4, H.264, MP3, AAC, AMR, JPG, PNG 。

      * Surface Manager - 对显示子系统的管理,并且为多个应用程序提 供了2D和3D图层的无缝融合。

      * LibWebCore - 一个最新的web浏览器引擎用,支持Android浏览器和一个可嵌入的web视图。

      应用程序组件

      Android开发四大组件分别是:活动(Activity): 用于表现功能。服务(Service): 后台运行服务,不提供界面呈现。广播接收器(BroadcastReceiver):用于接收广播。内容提供商(Content Provider): 支持在多个应用中存储和读取数据,相当于数据库。

      活动

      Android 中,Activity 是所有程序的根本,所有程序的流程都运行在Activity 之中,Activity可以算是开发者遇到的最频繁,也是Android 当中最基本的模块之一。在Android的程序当中,Activity 一般代表手机屏幕的一屏。如果把手机比作一个浏览器,那么Activity就相当于一个网页。在Activity 当中可以添加一些Button、Check box 等控件。可以看到Activity 概念和网页的概念相当类似。

      一般一个Android 应用是由多个Activity 组成的。这多个Activity 之间可以进行相互跳转,例如,按下一个Button 按钮后,可能会跳转到其他的Activity。和网页跳转稍微有些不一样的是,Activity 之间的跳转有可能返回值,例如,从Activity A 跳转到Activity B,那么当Activity B 运行结束的时候,有可能会给Activity A 一个返回值。这样做在很多时候是相当方便的。

      当打开一个新的屏幕时,之前一个屏幕会被置为暂停状态,并且压入历史堆栈中。用户可以通过回退操作返回到以前打开过的屏幕。我们可以选择性的移除一些没有必要保留的屏幕,因为Android会把每个应用的开始到当前的每个屏幕保存在堆栈中。

      服务

      Service 是android 系统中的一种组件,它跟Activity 的级别差不多,但是他不能自己运行,只能后台运行,并且可以和其他组件进行交互。Service 是没有界面的长生命周期的代码。Service 是一种程序,它可以运行很长时间,但是它却没有用户界面。这么说有点枯燥,来看个例子。打开一个音乐播放器的程序,这个时候若想上网了,那么,我们打开Android 浏览器,这个时候虽然我们已经进入了浏览器这个程序,但是,歌曲播放并没有停止,而是在后台继续一首接着一首的播放。其实这个播放就是由播放音乐的Service进行控制。当然这个播放音乐的Service也可以停止,例如,当播放列表里边的歌曲都结束,或者用户按下了停止音乐播放的快捷键等。service 可以在和多场合的应用中使用,比如播放多媒体的时候用户启动了其他Activity这个时候程序要在后台继续播放,比如检测SD 卡上文件的变化,再或者在后台记录你地理信息位置的改变等等,总之服务嘛,总是藏在后头的。

      开启service有两种方式:

      (1) Context.startService():Service会经历onCreate -> onStart(如果Service还没有运行,则android先调用onCreate()然后调用onStart();如果Service已经运行,则只调用onStart(),所以一个Service的onStart方法可能会重复调用多次 );stopService的时候直接onDestroy,如果是调用者自己直接退出而没有调用stopService的话,Service会一直在后台运行。该Service的调用者再启动起来后可以通过stopService关闭Service。 注意,多次调用Context.startservice()不会嵌套(即使会有相应的onStart()方法被调用),所以无论同一个服务被启动了多少次,一旦调用Context.stopService()或者stopSelf(),他都会被停止。补充说明:传递给startService()的Intent对象会传递给onStart()方法。调用顺序为:onCreate --> onStart(可多次调用) --> onDestroy。

      (2) Context.bindService():Service会经历onCreate() --> onBind(),onBind将返回给客户端一个IBind接口实例,IBind允许客户端回调服务的方法,比如得到Service运行的状态或其他操作。这个时候把调用者(Context,例如Activity)会和Service绑定在一起,Context退出了,Srevice就会调用onUnbind --> onDestroyed相应退出,所谓绑定在一起就共存亡了。[20]

      广播接收器

      在Android 中,Broadcast 是一种广泛运用的在应用程序之间传输信息的机制。而BroadcastReceiver 是对发送出来的Broadcast进行过滤接受并响应的一类组件。可以使用BroadcastReceiver 来让应用对一个外部的事件做出响应。这是非常有意思的,例如,当电话呼入这个外部事件到来的时候,可以利用BroadcastReceiver 进行处理。例如,当下载一个程序成功完成的时候,仍然可以利用BroadcastReceiver 进行处理。BroadcastReceiver不能生成UI,也就是说对于用户来说不是透明的,用户是看不到的。BroadcastReceiver通过NotificationManager 来通知用户这些事情发生了。BroadcastReceiver 既可以在AndroidManifest.xml 中注册,也可以在运行时的代码中使用Context.registerReceiver()进行注册。只要是注册了,当事件来临的时候,即使程序没有启动,系统也在需要的时候启动程序。各种应用还可以通过使用Context.sendBroadcast () 将它们自己的intent broadcasts广播给其他应用程序。

      注册BroadcastReceiver有两种方式:

      (1)在AndroidManifest.xml进行注册。这种方法有一个特点即使你的应用程序已经关闭了,但这个BroadcastReceiver依然会接受广播出来的对象,也就是说无论你这个应用程序时开还是关都属于活动状态都可以接受到广播的事件;

      (2)在代码中注册广播。

      第一种俗称静态注册,第二种俗称动态注册,这两种注册Broadcast Receiver的区别:

      动态注册较静态注册灵活。实验证明:当静态注册一个Broadcast Receiver时,不论应用程序是启动与否。都可以接受对应的广播。

      动态注册的时候,如果不执行unregister Receiver();方法取消注册,跟静态是一样的。但是如果执行该方法,当执行过以后,就不能接受广播了。

      内容提供

      Content Provider 是Android提供的第三方应用数据的访问方案。

      在Android中,对数据的保护是很严密的,除了放在SD卡中的数据,一个应用所持有的数据库、文件等内容,都是不允许其他直接访问的。Andorid当然不会真的把每个应用都做成一座孤岛,它为所有应用都准备了一扇窗,这就是Content Provider。应用想对外提供的数据,可以通过派生Content Provider类, 封装成一枚Content Provider,每个Content Provider都用一个uri作为独立的标识,形如:content://com.xxxxx。所有东西看着像REST的样子,但实际上,它比REST 更为灵活。和REST类似,uri也可以有两种类型,一种是带id的,另一种是列表的,但实现者不需要按照这个模式来做,给你id的uri你也可以返回列表类型的数据,只要调用者明白,就无妨,不用苛求所谓的REST。

      另外,Content Provider不和REST一样只有uri可用,还可以接受Projection,Selection,OrderBy等参数,这样,就可以像数据库那样进行投影,选择和排序。查询到的结果,以Cursor(参见:reference/android/database/Cursor.html )的形式进行返回,调用者可以移动Cursor来访问各列的数据。

      Content Provider屏蔽了内部数据的存储细节,向外提供了上述统一的接口模型,这样的抽象层次,大大简化了上层应用的书写,也对数据的整合提供了更方便的途径。Content Provider内部,常用数据库来实现,Android提供了强大的Sqlite支持,但很多时候,你也可以封装文件或其他混合的数据。

      在Android中,Content Resolver是用来发起Content Provider的定位和访问的。不过它仅提供了同步访问的Content Provider的接口。但通常,Content Provider需要访问的可能是数据库等大数据源,效率上不足够快,会导致调用线程的拥塞。因此Android提供了一个AsyncQueryHandler(参见:reference/android/content/AsyncQueryHandler.html),帮助进行异步访问Content Provider。

      在各大组件中,Service和Content Provider都是那种需要持续访问的。Service如果是一个耗时的场景,往往会提供异步访问的接口,而Content Provider不论效率如何,都提供的是约定的同步访问接口。

    软件开发

      Java方面

      Android支持使用Java作为编程语言来开发应用程序,而Android的Java开发方面从接口到功能,都有层出不穷的变化。考虑到Java虚拟机的效率和资源占用,谷歌重新设计了Android的Java,以便能提高效率和减少资源占用,因而与J2ME等不同。其中Activity等同于J2ME的MIDlet,一个 Activity 类(Class)负责创建视窗(Windows),一个活动中的Activity就是在 foreground(前景)模式,背景运行的程序叫做Service。两者之间通过由ServiceConnection和AIDL连结,达到复数程序同时运行效果。如果运行中的 Activity 全部画面被其他 Activity 取代时,该 Activity 便被停止(Stopped),甚至被系统清除(Kill)。

      View等同于J2ME的Displayable,程序人员可以通过 View 类与“XML layout”档将UI放置在视窗上,Android 1.5的版本可以利用 View 打造出所谓的 Widgets,其实Widget只是View的一种,所以可以使用xml来设计layout,HTC的Android Hero手机即含有大量的widget。至于ViewGroup 是各种layout 的基础抽象类(abstract class),ViewGroup之内还可以有ViewGroup。View的构造函数不需要再Activity中调用,但是Displayable的是必须的,在Activity 中,要通过findViewById()来从XML 中取得View,Android的View类的显示很大程度上是从XML中读取的。View 与事件(event)息息相关,两者之间通过Listener 结合在一起,每一个View都可以注册一个event listener,例如:当View要处理用户触碰(touch)的事件时,就要向Android框架注册View.OnClickListener。另外还有BitMap等同于J2ME的Image。   

    关注 62766

    前端劝退师 关注了标签 · 7月21日

    前端

    Web前端开发是从网页制作演变而来的,名称上有很明显的时代特征。在互联网的演化进程中,网页制作是Web 1.0时代的产物,那时网站的主要内容都是静态的,用户使用网站的行为也以浏览为主。2005年以后,互联网进入Web 2.0时代,各种类似桌面软件的Web应用大量涌现,网站的前端由此发生了翻天覆地的变化。网页不再只是承载单一的文字和图片,各种富媒体让网页的内容更加生动,网页上软件化的交互形式为用户提供了更好的使用体验,这些都是基于前端技术实现的。

    Web前端优化
    1. 尽量减少HTTP请求 (Make Fewer HTTP Requests)
    2. 减少 DNS 查找 (Reduce DNS Lookups)
    3. 避免重定向 (Avoid Redirects)
    4. 使得 Ajax 可缓存 (Make Ajax Cacheable)
    5. 延迟载入组件 (Post-load Components)
    6. 预载入组件 (Preload Components)
    7. 减少 DOM 元素数量 (Reduce the Number of DOM Elements)
    8. 切分组件到多个域 (Split Components Across Domains)
    9. 最小化 iframe 的数量 (Minimize the Number of iframes)
    10. 杜绝 http 404 错误 (No 404s)

    关注 150390

    前端劝退师 关注了标签 · 7月21日

    关注 63096

    前端劝退师 关注了标签 · 7月21日

    html5

    HTML5 是 HTML 下一个的主要修订版本,现在仍处于发展阶段。广义论及 HTML5 时,实际指的是包括 HTML、CSS 和 JavaScript 在内的一套技术组合。

    关注 88019

    前端劝退师 关注了标签 · 7月21日

    java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的 Java 程序设计语言和 Java 平台(即 JavaSE, JavaEE, JavaME)的总称。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    Java编程语言的风格十分接近 C++ 语言。继承了 C++ 语言面向对象技术的核心,Java舍弃了 C++ 语言中容易引起错误的指針,改以引用取代,同时卸载原 C++ 与原来运算符重载,也卸载多重继承特性,改用接口取代,增加垃圾回收器功能。在 Java SE 1.5 版本中引入了泛型编程、类型安全的枚举、不定长参数和自动装/拆箱特性。太阳微系统对 Java 语言的解释是:“Java编程语言是个简单、面向对象、分布式、解释性、健壮、安全与系统无关、可移植、高性能、多线程和动态的语言”。

    版本历史

    重要版本号版本代号发布日期
    JDK 1.01996 年 1 月 23 日
    JDK 1.11997 年 2 月 19 日
    J2SE 1.2Playground1998 年 12 月 8 日
    J2SE 1.3Kestrel2000 年 5 月 8 日
    J2SE 1.4Merlin2002 年 2 月 6 日
    J2SE 5.0 (1.5.0)Tiger2004 年 9 月 30 日
    Java SE 6Mustang2006 年 11 月 11 日
    Java SE 7Dolphin2011 年 7 月 28 日
    Java SE 8JSR 3372014 年 3 月 18 日
    最新发布的稳定版本:
    Java Standard Edition 8 Update 11 (1.8.0_11) - (July 15, 2014)
    Java Standard Edition 7 Update 65 (1.7.0_65) - (July 15, 2014)

    更详细的版本更新查看 J2SE Code NamesJava version history 维基页面

    新手帮助

    不知道如何开始写你的第一个 Java 程序?查看 Oracle 的 Java 上手文档

    在你遇到问题提问之前,可以先在站内搜索一下关键词,看是否已经存在你想提问的内容。

    命名规范

    Java 程序应遵循以下的 命名规则,以增加可读性,同时降低偶然误差的概率。遵循这些命名规范,可以让别人更容易理解你的代码。

    • 类型名(类,接口,枚举等)应以大写字母开始,同时大写化后续每个单词的首字母。例如:StringThreadLocaland NullPointerException。这就是著名的帕斯卡命名法。
    • 方法名 应该是驼峰式,即以小写字母开头,同时大写化后续每个单词的首字母。例如:indexOfprintStackTraceinterrupt
    • 字段名 同样是驼峰式,和方法名一样。
    • 常量表达式的名称static final 不可变对象)应该全大写,同时用下划线分隔每个单词。例如:YELLOWDO_NOTHING_ON_CLOSE。这个规范也适用于一个枚举类的值。然而,static final 引用的非不可变对象应该是驼峰式。

    Hello World

    public class HelloWorld {
        public static void main(String[] args) {
            System.out.println("Hello, World!");
        }
    }

    编译并调用:

    javac -d . HelloWorld.java
    java -cp . HelloWorld

    Java 的源代码会被编译成可被 Java 命令执行的中间形式(用于 Java 虚拟机的字节代码指令)。

    可用的 IDE

    学习资源

    常见的问题

    下面是一些 SegmentFault 上在 Java 方面经常被人问到的问题:

    (待补充)

    关注 103109

    认证与成就

    • 获得 84 次点赞
    • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

    擅长技能
    编辑

    (゚∀゚ )
    暂时没有

    开源项目 & 著作
    编辑

    (゚∀゚ )
    暂时没有

    注册于 7月21日
    个人主页被 1.3k 人浏览