云原生丨一文教你轻松借助DEX实现单点登录~

news2024/11/17 22:33:55

文章目录

  • 前言
  • 一、分析思路
    • 1、单点登录
      • 授权码认证
      • 隐式认证
      • 混合认证
    • 2、会话管理
  • 二、实现过程
    • 1、搭建DEX认证中⼼
    • 2、登录
      • 流程说明
      • 授权码认证示例代码
    • 3、登出
      • 流程说明
      • 登出代码示例


前言

通常,我们在登录单系统时,都希望只需要登录⼀次,就能访问本系统中包含的所有资源。但实际中,单系统往往⽆法囊括所有内容,总会出现其他系统资源的情况,⽽访问其他系统时,⼜需要重新登录。因此⼀次登录,访问多个系统的资源,成了⼤多⽤户的痛点。

然而,多系统的访问需要解决以下⼏个问题:

①⽤户只需要登录⼀次,就能访问所有系统的资源。

②⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

本期我们就基于上述问题一起来探讨分析,看看如何解决实现。


一、分析思路

1、单点登录

单点登录(SSO,Single sign-on)⽤来解决第⼀个问题:⽤户只需要登录⼀次,就能访问所有系统的资源。

单点登录是⼀种身份验证解决⽅案,可让⽤户通过⼀次性⽤户身份验证登录多个应⽤程序和⽹站。

本次采⽤DEX来实现单点登录。DEX是基于OpenID Connect协议实现的⼀个认证服务,OpenID Connect是从oauth2认证协议演进过来的。

DEX⼤致分为两个部分:

  • ⼀个是实现OpenID Connect协议的服务端。服务端包含登录⻚⾯以及⼀些⽤于验证的http后端接⼝。
  • ⼀个是⽤于验证账号的连接器。连接器将⽤户输⼊的账号密码发送到账号系统进⾏认证。DEX官⽅⽀持的连接器有:LDAP,GitHub,SAML
    2.0,Gitlab,OpenID Connect,OAuth2.0,Google,LinkedIn,Microsoft,AuthProxy,Bitucket
    Cloud,OpenShift,Atlassian,Crowd,Gitea,Open Stack Keystone,Integration
    kubelogin and Active Directory。

OpenID Connect协议中包含了三种认证模式:授权码认证,隐式认证,混合认证。

授权码认证

OpenID Connect授权代码流程通过以下步骤进⾏:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、 客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、授权服务器使⽤授权代码将最终⽤户发送回客户端。

6、客户端使⽤令牌端点的授权代码请求响应。

7、 客户端在响应主体中收到包含ID令牌和访问令牌的响应。

8、客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:
在这里插入图片描述

授权码

授权码在请求⼀次token端点后就会失效,超过⼀定时间,也会⾃动失效。

token

返回的token信息中,包含了access_token,id_token,refresh_token。

  • access_token:可⽤于应⽤内部的请求验证。其hash值包含于id_token中,即可通过id_token直接验证access_token。
  • id_token:可⽤于跨应⽤的请求验证。跨应⽤时,需在client中设置跨应⽤权限。id_token超过⼀定时间,会⾃动失效。id_token的验证需要通过dex提供的接⼝进⾏验证。
  • refresh_token:access_token或id_token失效时,⽤于刷新access_token,id_token。
  • refresh_token超过⼀定时间后,会⾃动失效。实际场景中,会将refresh_token的超时时间设置的⽐较⼤。

隐式认证

隐式流程按照以下步骤操作:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、 授权服务器将最终⽤户发送回客户端,并带有ID令牌,如果需要,则发送访问令牌。

6、客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:

在这里插入图片描述
token信息

隐式认证返回的token信息中,只包含了access_token和id_token。id_token到期后,需要重新认证。适⽤于认证周期⽐较短的场景。

混合认证

混合流遵循以下步骤:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、授权服务器使⽤授权代码将最终⽤户发送回客户端,并根据响应类型发送⼀个或多个附加参数。

6、客户端使⽤令牌端点的授权代码请求响应。

7、 客户端在响应主体中收到包含ID令牌和访问令牌的响应。

8、 客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:
在这里插入图片描述
授权码和token

认证完成后,DEX会返回授权码,access_token和id_token。

2、会话管理

会话管理⽤来解决第⼆个问题:⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

⽤户登录后,开始会话,⽤户登出(主动登出或超时⾃动登出)后结束会话,整个会话期间,认为是同⼀个⽤户进⾏操作。

此时会话需要有以下⼏个要求:

  • 每个会话独⽴,所有系统共有同⼀个会话;
  • 不做任何操作时,会话⾃动到期;
  • 访问任意系统时,会话⾃动续期。

redis完美符合。redis中,key的唯⼀性,区分不同的会话,value可以存储会话⾥⾯的数据。redis超时删除机制,符合会话⾃动到期。redis重新设置超时时间,可以实现会话⾃动续期。


二、实现过程

1、搭建DEX认证中⼼

Step 1: 使⽤docker-compose搭建Openldap账号系统,DEX服务端和Redis认证中⼼。


version: "3"
services:
openldap:
image: bitnami/openldap:latest
ports:
- 1389:1389
environment:
- LDAP_ADMIN_USERNAME=admin
- LDAP_ADMIN_PASSWORD=adminpassword
dex:
image: bitnami/dex:latest
ports:
- 5556:5556
- 5557:5557
command:
- serve
- /dex/config.yaml
volumes:
- config.yaml:/dex/config.yaml
redis:
image: redis:latest
ports:
- 6379:6379

Step 2: 启动DEX时需要⽤到的配置⽂件,示例如下:


enablePasswordDB: true
# dex服务地址
issuer: http://localhost:5556/dex
oauth2:
# 可⽤的返回类型
responseTypes: [ "code","token","id_token" ]
skipApprovalScreen: true
staticClients:
- id: app1
name: app1
redirectURIs:
- http://localhost:8080/callback
secret: app1-secret
# trustedPeers表app2⽣成的token可⽤于app1的认证。
trustedPeers:
- app2
- id: app2
name: app2
redirectURIs:
- http://localhost:8081/callback
secret: app2-secret
trustedPeers:
- app1
storage:
type: sqlite3
config:
file: local-example/dex.db
web:
# http 接⼝地址
http: 0.0.0.0:5556
grpc:
# grpc接⼝地址。⽀持通过grpc来扩充dex配置。
addr: 0.0.0.0:5557
# # Server certs. If TLS credentials aren't provided dex will run in
plaintext (HTTP) mode.
# tlsCert: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-server.crt
# tlsKey: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-server.key
#
# # Client auth CA.
# tlsClientCA: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-client.crt
# enable reflection
reflection: true
connectors:
# 指定账号连接器。这⾥配置的是openldap
- type: ldap
name: OpenLDAP
id: ldap
config:
# The following configurations seem to work with OpenLDAP:
#
# 1) Plain LDAP, without TLS:
host: openldap:1389
insecureNoSSL: true
#
# 2) LDAPS without certificate validation:
#host: localhost:636
#insecureNoSSL: false
#insecureSkipVerify: true
#
# 3) LDAPS with certificate validation:
#host: YOUR-HOSTNAME:636
#insecureNoSSL: false
#insecureSkipVerify: false
#rootCAData: 'CERT'
# ...where CERT
=
"$( base64 -w 0 your-cert.crt )"
# This would normally be a read-only user.
bindDN: cn=admin,dc=example,dc=org
bindPW: adminpassword
usernamePrompt: LDAP ⽤户名
userSearch:
baseDN: ou=users,dc=example,dc=org
filter: "(objectClass=person)"
username: cn
# "DN" (case sensitive) is a special attribute name. It
indicates that
# this value should be taken from the entity's DN not an
attribute on
# the entity.
idAttr: DN
emailAttr: mail
nameAttr: cn
groupSearch:
baseDN: ou=Groups,dc=example,dc=org
filter: "(objectClass=groupOfNames)"
userMatchers:
# A user is a member of a group when their DN matches
# the value of a "member" attribute on the group entity.
- userAttr: DN
groupAttr: member
# The group name should be the "cn" value.
nameAttr: cn
# 超时时间设置
expiry:
deviceRequests: "5m"
signingKeys: "6h"
idTokens: "24h"
refreshTokens:
reuseInterval: "30s"
validIfNotUsedFor: "2160h" # 90 days
absoluteLifetime: "3960h" # 165 days

issuer:配置dex的服务地址。

oauth2:配置⽀持的oauth2认证类型。

staticClients:配置可以通过dex进⾏认证的客户端应⽤。这⾥配置了两个应⽤,app1和app2。⼀般情况下,应⽤⽣成的token只能⽤于本应⽤的认证,配置trustedPeers后,可以进⾏跨应⽤资源认证。

storage:DEX的数据存储。DEX需要存储的数据如下:

在这里插入图片描述
web:dex认证http服务。

grpc:dex配置修改的grpc服务。

connectors:配置账号连接器。

expiry:配置超时时间

2、登录

流程说明

在这里插入图片描述
Step 1:⽤户访问应⽤1前端,应⽤1前端根据路由进⾏鉴权,鉴权不通过跳转到SSO登录⻚⾯(DEX提供);

Step 2:通过LDAP账号进⾏登录,登录成功,回调应⽤1前端的callback⻚⾯,返回Authorization Code;

Step 3:应⽤1前端调⽤login接⼝,传⼊Authorization Code值;

Step 4:应⽤1后端根据Authorization Code从DEX进⾏认证;

Step 5:DEX认证成功,返回AccessToken,RefreshToken,IdToken;

Step 6:应⽤1后端在redis上构建⼀个全局会话(redis中通过随机⽣成的key值sid来表示),将AccessToken,RefreshToken,和IdToken存⼊全局会话,并⽣成应⽤1的局部认证⽅式(这⾥采⽤AccessToken1和RefreshToken1)返回到应⽤1前端;

Step 7:应⽤1前端将RefreshToken1和AccessToken1缓存到Local Storage中;

Step 8:应⽤1前端每次请求接⼝时携带AccessToken1到应⽤1后端;

Step 9:应⽤1后端校验AccessToken1是否有效和AccessToken1中包含的sid全局会话是否有效,当AccessToken1失效时,云航前端调取RefreshToken1接⼝,重新获取AccessToken1;

Step 10:应⽤1前端SSO认证应⽤2前端时,获取会话中的数据sid和全局IdToken传⼊到应⽤2前端;

Step 11:应⽤2调⽤登录接⼝,校验IdToken的有效性和全局会话sid的有效性,校验通过,⽣成应⽤2⾃⼰的认证⽅式⽤于前后端交互;

Step 12:应⽤2前端登录成功,跳转到应⽤2主⻚;

授权码认证示例代码

1、访问登陆页面

curl http://localhost:5556/dex/auth/ldap?
client_id=app1&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_t
ype=code&scope=openid+profile+email+federated:id+offline_access+audience:server
:client_id:zadig+audience:server:client_id:app2&state=gHoisYYgsmpc

2、使⽤code获取token

func TestAuthCode(t *testing.T) {
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
// 连接dex
provider, err := oidc.NewProvider(ctx, "http://localhost:5556/dex")
if err != nil {
t.Error(err)
}
oauth2Config := &oauth2.Config{
ClientID: "app1",
ClientSecret: "app1-secret",
Endpoint: provider.Endpoint(),
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email", "groups"},
}
// 请求dex的token端点获取token
oauth2Token, err := oauth2Config.Exchange(ctx, authCode)
if err != nil {
t.Error(err)
}
rawIDToken, _ = oauth2Token.Extra("id_token").(string)
t.Logf("accessToken:%v", oauth2Token.AccessToken)
t.Logf("refreshToken:%v", oauth2Token.RefreshToken)
t.Logf("idToken:%v", rawIDToken)
}

3、验证idToken

func TestIDToken(t *testing.T) {
// 连接dex
provider, err := oidc.NewProvider(ctx, "http://localhost:5556/dex")
if err != nil {
t.Error(err)
}
// 验证idToken
idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: "app1"})
idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
if err != nil {
t.Error(err)
}
ac := make(map[string]any)
if err := idToken.Claims(ac); err != nil {
t.Error(err)
}
t.Logf("claims:%v", ac)
}

4、创建全局会话,并构建局部会话

func TestSession(t *testing.T){
sk := "xxxxx" // base64格式的pem私钥
// ⽣成局部会话
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.StandardClaims{
Subject: "user1",
ExpiresAt: time.Now().Add(time.Hour).Unix(), // Second
})
accessTokenStr, err := accessToken.SignedString([]byte(sk))
if err != nil {
t.Error(err)
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.StandardClaims{
Subject: "user1",
ExpiresAt: time.Now().Add(time.Hour*2).Unix(), // Second
})
refreshTokenStr, err := refreshToken.SignedString([]byte(sk))
if err != nil {
t.Error(err)
}
// 构建全局会话
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
sid := uuid.New().String()
rc := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
rc.Set(ctx, sid, map[string]any{
"dex": map[string]any{ // 存储dex信息
"accessToken": "xxxx",
"refreshToken": "xxxx",
"idToken": "xxxx",
},
"local": map[string]string { // 存储局部会话
accessTokenStr: refreshTokenStr,
},
}.Hour)
}

5、局部会话滚动更新

func TestRefreshToken(t *testing.T){
sid := "xxxx" //全局会话
refreshTokenLocal := "xxxxx" // 局部会话的refreshToken
refreshTokenDex := "xxxxx" // dex的refreshToken
// 解析局部会话的refreshToken中的jwt.Cl。重新⽣成accessToken
...
// 获取全局会话
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
sid := uuid.New().String()
rc := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
val := make(map[string]any)
err := rc.Get(ctx, sid).Scan(val)
if err != nil {
t.Error(err)
}
// 更新dex的token
oauth2Config := &oauth2.Config{
ClientID: "app1",
ClientSecret: "app1-secret",
Endpoint: provider.Endpoint(),
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email", "groups"},
}
oauth2Token, err := oauth2Config.TokenSource(ctx,
&oauth2.Token{RefreshToken: ac.Metadata["refreshToken"]}).Token()
if err != nil {
t.Error(err)
}
rawIDToken, _ = oauth2Token.Extra("id_token").(string)
newLocal := val["local"].(map[string]string)
newLocal[newAccessTokenLocal] = newRefreshTokenLocal
// 更新全局会话
rc.Set(ctx, sid, map[string]any{
"dex": map[string]any{ // 存储dex信息
"accessToken": oauth2Token.AccessToken,
"refreshToken": oauth2Token.RefreshToken,
"idToken": rawIDToken,
},
"local": newLocal,
}.Hour)
}

3、登出

流程说明

在这里插入图片描述

Step1:⽤户主动登出时,调⽤登出接⼝,失效全局会话(删除redis中的sid);

Step2:应⽤1,应⽤2全部不操作时,失效全局会话(redis的超时机制);

Step3:应⽤1或应⽤2进⾏访问时,检测到全局会话已经失效,需要失效本地局部会话。

登出代码示例

删除全局会话

func TestLogout(t *testing.T) {
sid := "xxxxx" // 前端传⼊
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
rc := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
rc.Set(ctx, sid, map[string]any{
"accessToken": accessTokenStr,
"refreshToken": refreshTokenStr,
}, time.Hour)
}

通过以上操作,就能够实现DEX的单点登录(SSO),解决了⽤户只需要登录⼀次,就能访问所有系统的资源。⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/349063.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

分享一下最近使用python字典取值用法的收获

假设现在有一个字典,内容如下:data {a: 1, b: 2}初级版本 我最开始学python的时候, 要从字典中取值,我可能会采用下面的写法:print(data["key"])上面的用法中,如果输入的key在字典中不存在的时候…

【蓝桥集训】第二天——差分

作者:指针不指南吗 专栏:Acwing 蓝桥集训每日一题 🐾做题过程中首先应该注意时间复杂度问题🐾 文章目录1.改变数组元素2.差分3.差分矩阵1.改变数组元素 给定一个空数组 V 和一个整数数组 a1,a2,…,an。 现在要对数组 V 进行 n 次操…

tkinter如何绑定鼠标和键盘等事件

文章目录鼠标点击事件进入或离开控件键盘事件Configure事件控件和人通过事件来交互,Tkinter中则通过Bind来绑定事件。例如 import tkinter as tkroot tk.Tk() txt tk.StringVar() btn tk.Button(root, textvariabletxt, width30, height5) btn.pack()btn.bind(&…

RS485三线制和两线制差别

RS-485是一种应用十分广泛的通信协议。其显著特点是信号采用“差分”的方式传输,因此抗干扰能力很强,通信距离也比RS-232远得多。RS-485通信一般是半双工的,仅需要2根信号线,也可以是全双工的,需要4根信号线。如何解读…

【项目实战】MySQL使用CONCAT字符串拼接函数实现与特殊字符的拼接

一、需求说明 因为有新功能需要上生产环境,总有一些乱七八糟的兼容历史数据的活要去做,比如以下。 需要批量的更新数据库中某个字段(如id列中原来是ABCDEFG,需要改成[“ABCDEFG”]), 没错,就是…

python 的用户输入和 while 循环使用说明

文章目录1. 函数 input() 的工作原理1.1 使用 int() 来获取整数类型1.2 % 求模运算符1.3 版本问题2. while 循环简介2.1 使用示例2.2 利用while循环实现用户选择退出2.3 标志的使用2.4 break 语句2.5 continue 语句2.6 避免无限循环3. 使用 while 循环来处理列表和字典3.1 在列…

mysql8.0(单表查询与多表拆线)

目录 单表查询 1、显示所有职工的基本信息。 2、查询所有职工所属部门的部门号,不显示重复的部门号。 3、求出所有职工的人数。 4、列出最高工资和最低工资。 5、列出职工的平均工资和总工资。 6、创建一个只有职工号、姓名和工作时间的新表&…

Vue2.0项目重构到Vue3.0流程

1.重构的流程 1-1新建项目,确定脚手架版本 首先呢,我们新建项目有两种方法 第一种:vue-cli : 安装并执行 npm init vuelatest 选择项目功能时: 除了第一项的项目名字外,其他可以暂时No cd 到自…

安装SQL Server2017 过程中报KB29119355失败的解决方案

SQLServer 2017脱机版下载地址:http://download.microsoft.com/download/6/4/A/64A05A0F-AB28-4583-BD7F-139D0495E473/SQLServer2017-x64-CHS-Dev.isoMicrosoft SQL Server Management Studio 18管理工具下载https://learn.microsoft.com/zh-cn/sql/ssms/download-…

公民自动化开发平台(CADP)列入Gartner《2022-2024 中型企业技术采用路线图》

近日,全球知名咨询公司 Gartner 发布《2022-2024 中型企业技术采用路线图》(获取方式见文末)。该路线图汇集了全球 400 多家中型企业技术领导者的集体智慧,共囊括 53 项技术,涉及多个核心基础设施领域。其中包括计算和…

Wi-Fi 7全新升级,小米蓄势待发!

目前,Wi-Fi 已经成为人们最常用的无线连接技术。随着智能化时代的发展,终端设备对 Wi-Fi 技术的速率、延迟和稳定性等都提出了更高的要求。此前,电气和电子工程师协会 IEEE 发布了 802.11be 草案,Wi-Fi联盟将其命名为 Wi-Fi 7。小…

labelme脚本使用报错:TypeError: ‘NoneType‘ object is not subscriptable

今天好不容易终于把标注做完了,花了我两天时间,终于做到最后用脚本将json文件转成png图片,结果出现了以下报错。 Traceback (most recent call last):File "E:/pythonconda3/Deeplabv3_plus/datasets/Json2Image.py", line 8, in …

虚拟环境的创建以及labelme的使用教程

本来打算是将这两部分分开的,但写完虚拟环境的创建似乎字数太少了,不过二者有关联,所以就放一起了。简单介绍一下,虚拟环境的创建有win11系统已经Ubuntu系统,labelme教程包括了下载及其使用的全部流程,以及…

MySQL参数优化之innodb_buffer_pool_size

innodb_buffer_pool我们俗称缓冲池, 缓冲池简单来说就是一块内存区域,通过内存的速度来弥补磁盘速度较慢对数据库性能的影响。 写入时,先将数据写入缓冲池种,再定期刷新到磁盘;读取时,将读到的页放到缓冲池…

RPC与HTTP的区别与联系(二)

目录 1.远程调用方式 2.认识RPC 3.认识Http 4.RPC与HTTP选择 5.深入分析 1.远程调用方式 无论是微服务还是分布式服务(都是SOA,都是面向服务编程),都面临着服务间的远程调用。那么服务间的远程调用方式有哪些呢?…

【PR】时间轴窗口

【PR】时间轴窗口时间轴窗口工具按钮—视频轨道切换轨道输出切换同步锁定目标切换轨道锁定轨道对插入和覆盖进行源修补工具按钮—音频轨道静音轨道独奏轨道画外音录制时间轴窗口基础操作添加轨道查看完成视频和音频缩放轨道删除轨道添加关键帧使用软件:Premiere2020…

前端学习第一阶段——第五章 CSS(上)

5-1 CSS基本选择器 01-CSS层叠样式表导读 02-CSS简介 03-体验CSS语法规范 04-CSS代码风格 05-CSS选择器的作用 06-标签选择器 07-类选择器 08-使用类选择器画盒子 09-类选择器特殊使用-多类名 10-id选择器 11-通配符选择器 5-2 CSS样式 12-font-family设置字体系列 13-font-s…

Linux的sysstat(sar)的详细使用

文章目录安装使用内存和存储器页面换入换出统计信息I/O和传输速率统计信息块设备的活动统计信息网络统计信息队列长度和负载平均值统计信息内存利用率统计信息CPU利用率统计信息安装 yum install -y sysstat使用 内存和存储器页面换入换出统计信息 sar -B -f /var/log/sa/sa…

(考研湖科大教书匠计算机网络)第四章网络层-第六节1:路由选择协议概述

获取pdf:密码7281专栏目录首页:【专栏必读】考研湖科大教书匠计算机网络笔记导航 文章目录一:路由选择概述二:因特网采用的路由选择协议(1)特点(2)常见的路由选择协议三:…

CocoaPods使用指南

前言 对于大多数软件开发团队来说,依赖管理工具必不可少,它能针对开源和私有依赖进行安装与管理,从而提升开发效率,降低维护成本。针对不同的语言与平台,其依赖管理工具也各有不同,例如 npm 管理 Javascri…