gateway官網文件解讀(六) 彙總
終於整完了, 看了兩天,中間還穿插各種面試和會議. 看了我的英語水平...或者說google的英譯漢能力著實可以的.
看完之後有幾個感受.
gateway本身分成三個元件
routes: 路由, 也是最小的顆粒元件
predicates: 斷言, 就是滿足什麼樣的條件
filter: 過濾器, 裡面可以對請求做一些處理
application.yml
spring:
cloud:
gateway:
routes:
- id: after_route
uri: https://example.org
predicates:
- Cookie=mycookie,mycookievalue
filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
這就是一個最簡單的標準的一個路由裡面有自己的
id(唯一標識),
predicates用來標記哪些請求進來,
filter代表著請求進來以後你要做什麼,
這裡面他提供了大批量的類庫;
包括, 時間, cookie, url, 引數, header, 基本你能想到的東西都可以放到predicates和filter裡面.他也比較希望你用它的類庫.
從1~6 其實他就是在各種介紹他的類庫.....不過我說實話.....太多了,整的我都不想用了,不是僥倖, 純粹習慣問題.
這幅圖要從上往下讀: 請求進來, 進來以後經過兩個handle,然後經過filter逐層的返回, 應該用的是責任鏈, 這個我還沒有細看,後續去解讀原始碼時候看看.
Example 59. ExampleConfiguration.java
@Bean
public GlobalFilter customFilter() {
return new CustomGlobalFilter();
}
public class CustomGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("custom global filter");
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -1;
}
}
我喜歡這個:全域性filter, 裡面我們可以隨意的寫程式碼,各種搞事情,比如jwt.
這時候有個問題了,如果多個filter他究竟是咋執行的
7.10. Marking An Exchange As Routed
閘道器路由ServerWebExchange之後,通過將gatewayAlreadyRouted新增到交換屬性來將交換標記為“已路由”。 將請求標記為已路由後,其他路由篩選器將不會再次路由請求,實質上會跳過該過濾器。 您可以使用多種便捷方法將交換標記為已路由,或者檢查交換是否已路由。
-
ServerWebExchangeUtils.isAlreadyRouted
takes aServerWebExchange
object and checks if it has been “routed”. -
ServerWebExchangeUtils.setAlreadyRouted
takes aServerWebExchange
object and marks it as “routed”
也就是是說一個路由以後他就關閉了.其他的就不執行了,注意這裡說的並不包括全域性的,因為我建立了兩個globalFilter都是執行的.那globalFilter呢,他的執行順序是按照order執行的, 然後每個請求都必須執行.
我目前呢寫了兩個globalFilter, 一個是用來列印日誌的, 另外一個是用來做jwt鑑權的.還有就是直接加一個@Component標籤就行,不用非得宣告一個@Bean,他的意思大概是希望你寫的更加顯式一點. 這個在
17. Developer Guide 導讀更加明顯.
我們看一下他的建議
17.1. Writing Custom Route Predicate Factories 編寫自定義路由工廠
MyRoutePredicateFactory.java
public class MyRoutePredicateFactory extends AbstractRoutePredicateFactory<HeaderRoutePredicateFactory.Config> {
public MyRoutePredicateFactory() {
super(Config.class);
}
@Override
public Predicate<ServerWebExchange> apply(Config config) {
// grab configuration from Config object
return exchange -> {
//grab the request
ServerHttpRequest request = exchange.getRequest();
//take information from the request to see if it
//matches configuration.
return matches(config, request);
};
}
public static class Config {
//Put the configuration properties for your filter here
}
}
17.2. Writing Custom GatewayFilter Factories
To write a GatewayFilter
, you must implement GatewayFilterFactory
. You can extend an abstract class called AbstractGatewayFilterFactory
. The following examples show how to do so:
Example 76. PreGatewayFilterFactory.java
public class PreGatewayFilterFactory extends AbstractGatewayFilterFactory<PreGatewayFilterFactory.Config> {
public PreGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// grab configuration from Config object
return (exchange, chain) -> {
//If you want to build a "pre" filter you need to manipulate the
//request before calling chain.filter
ServerHttpRequest.Builder builder = exchange.getRequest().mutate();
//use builder to manipulate the request
return chain.filter(exchange.mutate().request(builder.build()).build());
};
}
public static class Config {
//Put the configuration properties for your filter here
}
}
PostGatewayFilterFactory.java
public class PostGatewayFilterFactory extends AbstractGatewayFilterFactory<PostGatewayFilterFactory.Config> {
public PostGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// grab configuration from Config object
return (exchange, chain) -> {
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
//Manipulate the response in some way
}));
};
}
public static class Config {
//Put the configuration properties for your filter here
}
}
他把rotes,prediscates 和filter給分開了.而且可以prefilter和postFilter
下面我們結合我們立下的flag, 看看怎麼搞重定向
現在我想把所有/action/*****的請求都變成/business
Example 8. application.yml
spring:
cloud:
gateway:
routes:
- id: path_route
uri: https://example.org
predicates:
- Path=/red/{segment},/blue/{segment}
巴巴的說了好久了,停了好幾天.因為在忙別的業務.在這裡說一下最終解決方案
cloud:
gateway:
locator:
enabled: true
default-filters:
- AddResponseHeader=X-Response-Default-Foo, Default-Bar
# 服務自動發現,取第一個擷取詞匹配consul
discovery:
locator:
lower-case-service-id: true
enabled: true #開啟根據微服務名稱自動轉發
filters:
- StripPrefix=1
routes:
- id: action
uri: lb://business
predicates:
- Path= /action/**
沒錯,就是加了這麼一個路由,這裡加了一個斷言predicates: 所有 /action/ 的請求 都被轉發到註冊中心的 business服務上.
比如http://gateway.com/action/abcServer 會被轉譯為 http://gateway.com/business/action/abcServer
-----------------------------------------------------
上邊解決了路由轉發,我們在這裡在從頭梳理一下我的需求
1.我有一個gateway, 一個consul 和一個business的主專案
2.我有一堆歷史的債務有個叫做static的專案需要被整合到business裡面,原因是裡面就只有兩個介面,一個2B同事搞得,個人感覺就是拿公司的服務玩.
3.我有個php的老專案api.com,原來有公網域名,現在需要整合進geteway,我需要把原有域名也可以正常通過gateway能訪問到服務
4.我需要做一個負載均衡實現灰度
-------------------------------------
1.業務合併,上邊已經解決了
2.現在我們做負載均衡和灰度
那麼我們首先設定一個負載均衡的flag:假設我有兩臺服務, (ip分別是83,84),我這裡是本地用埠91和92代替.91和92都已經註冊到了consul(註冊中心)上面.
- 實現所有的請求20%在91上, 80%在92上
- 實現header中所有請求studentId=123的學員請求都在92上
- 實現header中所有請求按照studentId分群,將20%的比例固定的分配到91上,其他在92上
- 實現header中url=abc的請求按照studentId分群,將20%的比例固定的分配到91上,其他在92上
回過來我們梳理一下無非就是按照url,studentId兩個維度將流量分配給不同的服務. 好了現在我們需求有了,開始做個設計,
這個明顯的是一個策略的模式,而且策略間應該是有優先順序的.比如一個我們策略一是按照studentId%100<20在91,上策略2是studentId=123的在91上, 1,2明顯是衝突的. 所以涉及到了優先順序.
這個策略明顯是不定長度的, 意思就是說比如url=abc的,後面還有url=bcd的,而且以後還可能有其他的專案.
最後初步設計這裡採用責任鏈的方式,因為最後我們可以很明確的抽象出來幾種規則有幾個共性, 入參是studentId和url,回參是服務地址.
話不多說直接上程式碼:
-----------------------------兜兜轉轉,寫完這個程式碼半個月了才想起來部落格還沒收尾--------------------------
首先yml的配置
#負載均衡 mybalance: open : true #灰度 grayscale: - order: -129 id: ver等於2.0.0的queryPort請求,投射到9091,ver不等於2.0.0的queryPort分發到其他服 ip: 192.168.0.225:9091 ver: '=2.0.0' url: '/statistics/testServer/queryPort' exclusive : 'trueUrl' - order: -128 id: ver>=2.4.0的所有請求,投射到9091,ver小於2.4.0分發到其他服 ip: 192.168.0.225:9092 ver: '>2.4.0' url: '*' exclusive : 'trueVer' - order: -127 id: studentId=123的,url=queryPort 投射到9091,其他請求隨機投放 ip: 192.168.0.225:9091 studentId: 123 url: '/statistics/testServer/queryPort2' exclusive : trueUrl - order: -125 id: studentId=123的,url=queryPort 投射到9091,其他請求隨機投放 ip: 192.168.0.225:9091 studentId: 123 url: '*' exclusive : '*'
其次我們看看這個策略的解析類
@Api("灰度策略") public class Grayscale { @ApiParam("優先順序,值越小優先順序越高,當出現了高優先順序的負載均衡以後低優先順序的就不再執行") private Integer order; @ApiParam("策略的唯一標識") private String id; @ApiParam("策略的ip標識,正則 consul中的address ip或者ip+埠") private String ip; @ApiParam("header中studentId,正則,如果是*就代表所有") private String studentId; @ApiParam("請求的url,正則,如果是*就代表所有") private String url; @ApiParam("強制,true如果找不到對應ip就丟擲異常,false:如果找不到對應的ip就隨機返回一臺 ") private boolean enforce=false; @ApiParam("權重0~100 當ip 有內容的時候本欄位不生效") private Integer weight; @ApiParam("排他 如果之前的條件沒有完全命中,那麼就會執行exclusive過濾, 比如 trueStu 代表如果stu判斷是true就會從服務列表摘除, falseStu代表如果stu是false就從列表摘除 ") private String exclusive; @ApiParam("版本號") private String ver; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public String getStudentId() { return studentId; } public void setStudentId(String studentId) { this.studentId = studentId; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public boolean isEnforce() { return enforce; } public void setEnforce(boolean enforce) { this.enforce = enforce; } public Integer getWeight() { return weight; } public void setWeight(Integer weight) { this.weight = weight; } public Integer getOrder() { return order; } public void setOrder(Integer order) { this.order = order; } public String getExclusive() { return exclusive; } public void setExclusive(String exclusive) { this.exclusive = exclusive; } public String getVer() { return ver; } public void setVer(String ver) { this.ver = ver; }
}
@Component @ConditionalOnProperty( matchIfMissing = true ,prefix = "mybalance",name="open",havingValue = "true" ) @Api("負載均衡") @ConfigurationProperties(prefix = "mybalance") public class MyBalanceEntity { @ApiParam("灰度") private List<Grayscale> grayscale; @Constructor @ApiParam("所有策略") public void sort() { if (grayscale != null && grayscale.size() > 1) { grayscale.sort((g1, g2) -> { if (g2.getOrder() > g1.getOrder()) return 1; if (g2.getOrder() < g1.getOrder()) return -1; return 0; }); } } public List<Grayscale> getGrayscale() { return grayscale; } public void setGrayscale(List<Grayscale> grayscale) { this.grayscale = grayscale; } }
----------------------------------------這個可以看出來策略就是先按照id升序然後相同的按照前後順序
/** * https://blog.csdn.net/zhou1124/article/details/103773835 */ @Api("負載均衡,先執行MyLoadBalancerClientFilter,再執行MyLoadBalanceRule") @Component public class MyLoadBalancerClientFilter extends LoadBalancerClientFilter { public static ThreadLocal<ServerWebExchange> exchange = new ThreadLocal<>(); private static Logger log = LoggerFactory.getLogger(DaishuCloudGatewayApplication.class); @Autowired private MyBalanceEntity myBalanceEntity; public MyLoadBalancerClientFilter(LoadBalancerClient loadBalancer, LoadBalancerProperties properties) { super(loadBalancer, properties); } @Override protected ServiceInstance choose(ServerWebExchange exchange) { //如果沒有任何策略就使用 if(myBalanceEntity==null||myBalanceEntity.getGrayscale()==null||myBalanceEntity.getGrayscale().size()==0){ return super.choose(exchange); } //這裡可以拿到web請求的上下文,可以從header中取出來自己定義的資料。 MyLoadBalancerClientFilter.exchange.set(exchange); //獲得真實的請求路徑lb://statistics/testServer/queryPort URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); HttpHeaders httpHeaders = exchange.getRequest().getHeaders(); BalanceDto balanceDto=new BalanceDto(httpHeaders,uri); balanceDto.setHttpHeaders(httpHeaders); balanceDto.setUri(uri); log.info("步驟1"); //如果在已有的ThreadLocal中沒有連線 if (MyLoadBalanceRule.originHost.get() == null) { //獲得所屬ip List<String> originHostHeader = httpHeaders.get(MyLoadBalanceRule.originHostHeader); if (originHostHeader == null || originHostHeader.size() == 0) { String host = exchange.getRequest().getURI().getHost(); //設定請求頭 exchange.getRequest().mutate().header(MyLoadBalanceRule.originHostHeader, host).build(); //設定本機地址 MyLoadBalanceRule.originHost.set(host); } else { MyLoadBalanceRule.originHost.set(originHostHeader.get(0)); } } //開始路由 if (this.loadBalancer instanceof RibbonLoadBalancerClient) { RibbonLoadBalancerClient client = (RibbonLoadBalancerClient) this.loadBalancer; String serviceId = ((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost(); //這裡使用userId做為選擇服務例項的key, 呼叫的是MyLoadBalanceRule的choose, balanceDto 就是那邊接收到的key return client.choose(serviceId, balanceDto); } return super.choose(exchange); } }
@Component @Api("路由規則") public class MyLoadBalanceRule extends BestAvailableRule { private static Logger log = LoggerFactory.getLogger(DaishuCloudGatewayApplication.class); @Autowired @Qualifier("grayscaleBalance") private Balance grayscaleBalance; public static ThreadLocal<String> originHost=new ThreadLocal<>(); public static String originHostHeader="originHost"; @Autowired private MyBalanceEntity myBalanceEntity; public Server choose(ILoadBalancer lb, Object key) { //log.info("步驟2"+key); if (lb == null) { log.error("MyLoadBalanceRule Exception no load balancer"); return null; } if(myBalanceEntity==null||myBalanceEntity.getGrayscale()==null||myBalanceEntity.getGrayscale().size()==0){ return grayscaleBalance.loadRandomServer(lb.getReachableServers()); } BalanceDto balanceDto=(BalanceDto) key; //consul 上的註冊192.168.0.225:9091 192.168.0.225:9092 consul中服務對應的address專案 List<Server> reachableServers = lb.getReachableServers(); if(reachableServers==null ||reachableServers.size()==0){ log.error("MyLoadBalanceRule Exception 沒有可用的服務"); return null; } balanceDto.setReachableServers(reachableServers); BalanceContext balanceContext=new BalanceContext(balanceDto); //進行負載均衡 grayscaleBalance.chooseServer(balanceContext); //log.info("balanceContext:" + JSONObject.toJSONString(balanceContext)); return balanceContext.getServer(); } @Override public Server choose(Object key) { return choose(getLoadBalancer(), key); } @Override public void initWithNiwsConfig(IClientConfig clientConfig) { // TODO Auto-generated method stub } }
//這兩個類是負載均衡
先執行
MyLoadBalancerClientFilter
在執行
MyLoadBalanceRule
@Api("負載均衡的抽象規範,暫時沒有其他用途") public abstract class Balance { private static Logger log = LoggerFactory.getLogger(DaishuCloudGatewayApplication.class); @ApiParam("返回一臺服務") public abstract void chooseServer(BalanceContext balanceContext); @ApiParam("隨機返回一臺服務") public abstract Server loadRandomServer(List<Server> serverList); @ApiParam("隨機返回一臺服務") protected void loadRandomServer(final @ApiParam("入參") BalanceContext balanceContext) { Server server= loadRandomServer(new ArrayList(balanceContext.getBalanceDto().getReachableServerMap().values())); balanceContext.setServer(server); balanceContext.setPolicyId("loadRandomServer"); } @ApiParam("根據策略載入服務到context") protected void loadServer(final @ApiParam("灰度策略") Grayscale grayscale, final @ApiParam("入參") BalanceContext balanceContext) { if (Collections.isEmpty(balanceContext.getBalanceDto().getReachableServerMap())) { throw new DsException(10009); } MatchMap matchMap = new MatchMap(); //判斷是否命中 effectiveStu(grayscale, balanceContext, matchMap); effectiveUrl(grayscale, balanceContext, matchMap); effectiveVer(grayscale, balanceContext, matchMap); //命中ip if (matchMap.isAllMatch()) { //構建context buildContextServer(grayscale, balanceContext); } else { //移除server exclusiveContextServer(grayscale, balanceContext, matchMap); } } @ApiParam("移除sever") private void exclusiveContextServer(Grayscale grayscale, BalanceContext balanceContext, @ApiParam("匹配結果") MatchMap matchMap) { //如果只剩下一臺服務並且是一個有效的移除策略才開始移除 if (balanceContext.getBalanceDto().getReachableServerMap().size() > 1 && DsStringUtil.isNotEmpty(grayscale.getExclusive()) && !grayscale.getExclusive().equals("*")) { String[] exp = grayscale.getExclusive().split(","); for (String str : exp) { switch (str) { case "falseVer": if (!matchMap.isVerMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; case "trueVer": if (matchMap.isVerMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; case "trueUrl": if (matchMap.isUrlMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; case "falseUrl": if (!matchMap.isUrlMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; case "trueStu": if (matchMap.isStuMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; case "falseStu": if (!matchMap.isStuMatch()) balanceContext.getBalanceDto().getReachableServerMap().remove(grayscale.getIp()); break; default: } } } } @ApiParam("構建context") private void buildContextServer(Grayscale grayscale, BalanceContext balanceContext) { if (balanceContext.getBalanceDto().getReachableServerMap().get(grayscale.getIp()) != null) { balanceContext.setOrder(grayscale.getOrder()); balanceContext.setPolicyId(grayscale.getId()); balanceContext.setServer(balanceContext.getBalanceDto().getReachableServerMap().get(grayscale.getIp())); } else { if (grayscale.isEnforce()) { log.info("策略:" + grayscale.getId() + "沒有找到服務==" + grayscale.getIp() + "強制執行失敗"); throw new DsException(10010, "策略id:" + grayscale.getId()); } else { log.info("策略:" + grayscale.getId() + "沒有找到服務==" + grayscale.getIp() + "跳過策略"); } } } @ApiParam("匹配版本") private void effectiveVer(Grayscale grayscale, BalanceContext balanceContext, MatchMap matchMap) { //如果策略有但是header沒有就不通過 if (effective(grayscale.getVer()) && DsStringUtil.isEmpty(balanceContext.getBalanceDto().getHttpHeaders().getFirst("ver"))) { matchMap.setAllMatch(false); matchMap.setVerMatch(false); return; } if (effective(grayscale.getVer())) { DsHeader dsHeader = new DsHeader(balanceContext.getBalanceDto().getHttpHeaders()); String ver = grayscale.getVer().replaceAll(">", "").replaceAll("=", "").replaceAll("<", ""); //判斷版本情況 header內容小於輸入version版本返回-1 0 等於 header內容大於version返回1 Integer i = dsHeader.afterVer(ver); //結果是小於 if (i == -1 && !grayscale.getVer().startsWith("<")) { matchMap.setAllMatch(false); matchMap.setVerMatch(false); } //結果是大於 if (i == 1 && !grayscale.getVer().startsWith(">")) { matchMap.setAllMatch(false); matchMap.setVerMatch(false); } //如果結果相等但是判斷條件是不等於,或者不包含= if (i == 0 && (grayscale.getVer().startsWith("!=") || !grayscale.getVer().contains("="))) { matchMap.setAllMatch(false); matchMap.setVerMatch(false); } } } @ApiParam("判斷地址匹配結果") private void effectiveUrl(Grayscale grayscale, BalanceContext balanceContext, MatchMap matchMap) { //如果需要檢測url if (effective(grayscale.getUrl()) && !match(grayscale.getUrl(), balanceContext.getBalanceDto().getUri().toString())) { matchMap.setAllMatch(false); matchMap.setUrlMatch(false); } } @ApiParam("判斷學生匹配結果") private void effectiveStu(Grayscale grayscale, BalanceContext balanceContext, MatchMap matchMap) { //如果策略有但是header沒有 if (effective(grayscale.getStudentId()) && DsStringUtil.isEmpty(balanceContext.getBalanceDto().getStudentId())) { matchMap.setAllMatch(false); matchMap.setStuMatch(false); return; } if (effective(grayscale.getStudentId()) && !match(grayscale.getStudentId(), balanceContext.getBalanceDto().getStudentId().toString())) { matchMap.setAllMatch(false); matchMap.setStuMatch(false); } } @ApiParam("是否是有效欄位") private boolean effective(String str) { if (DsStringUtil.isEmpty(str) || str.trim().equals("*")) { return false; } return true; } @ApiParam("正規表示式是否匹配") private boolean match(@ApiParam("正規表示式") String exp, @ApiParam("內容") String str) { // 忽略大小寫的寫法 Pattern pattern = Pattern.compile(exp, Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(str.replaceAll("lb://","/")); boolean rs = matcher.matches(); return rs; } }
@Api("灰度負載均衡") @Component public class GrayscaleBalance extends Balance { @Autowired private MyBalanceEntity myBalanceEntity; @Override public void chooseServer(BalanceContext balanceContext) { if (myBalanceEntity.getGrayscale() != null && myBalanceEntity.getGrayscale().size() > 0) { for (Grayscale grayscale : myBalanceEntity.getGrayscale()) { //如果還沒有產生有效策略 if (balanceContext.getServer() == null) { //根據策略載入服務到context super.loadServer(grayscale, balanceContext); } else { //因為本身就已經進行過 break; } } //如果所有的都執行完了還沒有拿到有效的策略 if (balanceContext.getServer() == null) { super.loadRandomServer(balanceContext); } } } @ApiParam("隨機返回一臺服務") @Override public Server loadRandomServer(List<Server> serverList) { Random random = new Random(); int index = random.nextInt(serverList.size()); return serverList.get(index); } }
//這兩個類是實際的邏輯
這裡面用了繼承的方式倒不是說必須的,主要是考慮以後萬一有擴充套件
通過這些配置就可以實現負載均衡的灰度, 其實如果需求不這麼複雜的話還是建議用自帶的斷言和filter,或者自帶的ribbon.可讀性更好,效能也高.而且權重什麼的也不用自己去做.主要還是看需求吧.
相關文章
- Taro官網文件總結
- 大資料實戰之hadoop生態概況和官網文件解讀大資料Hadoop
- kubernets官網文件地址
- jmeter_彙總報告_資料解讀JMeter
- 終、《圖解HTTP》讀書筆記 - 彙總篇(總結)圖解HTTP筆記
- influxdb官網文件翻譯UX
- 類的基礎語法閱讀【Python3.8官網文件】Python
- 產品經理需要的文件彙總
- 2022年中國及31省市物聯網行業彙總及解讀行業
- 樹模型調參指南——官網文件模型
- MapStruct - 註解彙總Struct
- LeetCode 解題彙總LeetCode
- Kubernetes Gateway API 深入解讀和落地指南GatewayAPI
- Spring Cloud Gateway 聚合swagger文件SpringCloudGatewaySwagger
- 本週閱讀清單彙總
- Spring Cloud Zuul中使用Swagger彙總API介面文件SpringCloudZuulSwaggerAPI
- 圖解 -- 樹的彙總圖解
- Oracle官網文件學習路線導圖Oracle
- spring官網線上學習文件翻譯Spring
- 初入小程式 | 文件使用 | 注意彙總 - 檢視篇
- 初入小程式 | 文件使用 | 注意彙總 - 自定義元件元件
- linux 故障解決方法彙總Linux
- Vagrant box 命令彙總彙總
- OpenHarmony 官網文件有哪些上新?下篇:裝置開發文件上新
- OpenHarmony 官網文件有哪些上新?上篇:應用開發文件上新
- 常見網路協議彙總協議
- BSN-DDC應用合約解讀彙總(2023年一季度)
- PostgreSQL 15新版本特性解讀(含直播問答、PPT資料彙總)SQL
- Python常用的六款程式設計開發工具彙總!Python程式設計
- 解壓命令unzip常用方法彙總
- JPA常用註解彙總紀要
- 征服面試官:OkHttp 原理篇 掌握這篇面試題彙總,吊打面試官!HTTP面試題
- C#XmlHelper幫助類操作Xml文件的通用方法彙總C#XML
- webpack官網文件 :指南 -- 7.程式碼分割 - 使用import()WebImport
- 【乾貨】Android 一線網際網路面試題彙總,13模組200+題,征服面試官不是夢!Android面試題
- React中文文件閱讀總結——快速入門React
- Redux中文文件閱讀總結——快速入門Redux
- sbc(六) Zuul GateWay 閘道器應用ZuulGateway