標籤: 租車

  • Spring Boot 集成 Swagger 構建接口文檔

    Spring Boot 集成 Swagger 構建接口文檔

    在應用開發過程中經常需要對其他應用或者客戶端提供 RESTful API 接口,尤其是在版本快速迭代的開發過程中,修改接口的同時還需要同步修改對應的接口文檔,這使我們總是做着重複的工作,並且如果忘記修改接口文檔,就可能造成不必要的麻煩。

    為了解決這些問題,Swagger 就孕育而生了,那讓我們先簡單了解下。

    Swagger 簡介

    Swagger 是一個規範和完整的框架,用於生成、描述、調用和可視化 RESTful 風格的 Web 服務

    總體目標是使客戶端和文件系統作為服務器,以同樣的速度來更新。文件的方法、參數和模型緊密集成到服務器端的代碼中,允許 API 始終保持同步。

    下面我們在 Spring Boot 中集成 Swagger 來構建強大的接口文檔。

    Spring Boot 集成 Swagger

    Spring Boot 集成 Swagger 主要分為以下三步:

    1. 加入 Swagger 依賴
    2. 加入 Swagger 文檔配置
    3. 使用 Swagger 註解編寫 API 文檔

    加入依賴

    首先創建一個項目,在項目中加入 Swagger 依賴,項目依賴如下所示:

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger2</artifactId>
                <version>2.9.2</version>
            </dependency>
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger-ui</artifactId>
                <version>2.9.2</version>
            </dependency>
    

    加入配置

    接下來在 config 包下創建一個 Swagger 配置類 Swagger2Configuration,在配置類上加入註解 @EnableSwagger2,表明開啟 Swagger,注入一個 Docket 類來配置一些 API 相關信息,apiInfo() 方法內定義了幾個文檔信息,代碼如下:

    @Configuration
    @EnableSwagger2
    public class Swagger2Configuration {
    
        @Bean
        public Docket createRestApi() {
            return new Docket(DocumentationType.SWAGGER_2)
                    .apiInfo(apiInfo())
                    .select()
                    // swagger 文檔掃描的包
                    .apis(RequestHandlerSelectors.basePackage("com.wupx.interfacedoc.controller"))
                    .paths(PathSelectors.any())
                    .build();
        }
    
        private ApiInfo apiInfo() {
            return new ApiInfoBuilder()
                    .title("測試接口列表")
                    .description("Swagger2 接口文檔")
                    .version("v1.0.0")
                    .contact(new Contact("wupx", "https://www.tianheyu.top", "wupx@qq.com"))
                    .license("Apache License, Version 2.0")
                    .licenseUrl("http://www.apache.org/licenses/LICENSE-2.0.html")
                    .build();
        }
    }
    

    列舉其中幾個文檔信息說明下:

    • title:接口文檔的標題
    • description:接口文檔的詳細描述
    • termsOfServiceUrl:一般用於存放公司的地址
    • version:API 文檔的版本號
    • contact:維護人、維護人 URL 以及 email
    • license:許可證
    • licenseUrl:許可證 URL

    編寫 API 文檔

    domain 包下創建一個 User 實體類,使用 @ApiModel 註解表明這是一個 Swagger 返回的實體,@ApiModelProperty 註解表明幾個實體的屬性,代碼如下(其中 getter/setter 省略不显示):

    @ApiModel(value = "用戶", description = "用戶實體類")
    public class User {
    
        @ApiModelProperty(value = "用戶 id", hidden = true)
        private Long id;
    
        @ApiModelProperty(value = "用戶姓名")
        private String name;
    
        @ApiModelProperty(value = "用戶年齡")
        private String age;
    
        // getter/setter
    }
    

    最後,在 controller 包下創建一個 UserController 類,提供用戶 API 接口(未使用數據庫),代碼如下:

    @RestController
    @RequestMapping("/users")
    @Api(tags = "用戶管理接口")
    public class UserController {
    
        Map<Long, User> users = Collections.synchronizedMap(new HashMap<>());
    
        @GetMapping("/")
        @ApiOperation(value = "獲取用戶列表", notes = "獲取用戶列表")
        public List<User> getUserList() {
            return new ArrayList<>(users.values());
        }
    
        @PostMapping("/")
        @ApiOperation(value = "創建用戶")
        public String addUser(@RequestBody User user) {
            users.put(user.getId(), user);
            return "success";
        }
    
        @GetMapping("/{id}")
        @ApiOperation(value = "獲取指定 id 的用戶")
        @ApiImplicitParam(name = "id", value = "用戶 id", paramType = "query", dataTypeClass = Long.class, defaultValue = "999", required = true)
        public User getUserById(@PathVariable Long id) {
            return users.get(id);
        }
    
        @PutMapping("/{id}")
        @ApiOperation(value = "根據 id 更新用戶")
        @ApiImplicitParams({
                @ApiImplicitParam(name = "id", value = "用戶 id", defaultValue = "1"),
                @ApiImplicitParam(name = "name", value = "用戶姓名", defaultValue = "wupx"),
                @ApiImplicitParam(name = "age", value = "用戶年齡", defaultValue = "18")
        })
        public User updateUserById(@PathVariable Long id, @RequestParam String name, @RequestParam Integer age) {
            User user = users.get(id);
            user.setName(name);
            user.setAge(age);
            return user;
        }
    
        @DeleteMapping("/{id}")
        @ApiOperation(value = "刪除用戶", notes = "根據 id 刪除用戶")
        @ApiImplicitParam(name = "id", value = "用戶 id", dataTypeClass = Long.class, required = true)
        public String deleteUserById(@PathVariable Long id) {
            users.remove(id);
            return "success";
        }
    }
    

    啟動項目,訪問 http://localhost:8080/swagger-ui.html,可以看到我們定義的文檔已經在 Swagger 頁面上显示了,如下圖所示:

    到此為止,我們就完成了 Spring Boot 與 Swagger 的集成。

    同時 Swagger 除了接口文檔功能外,還提供了接口調試功能,以創建用戶接口為例,單擊創建用戶接口,可以看到接口定義的參數、返回值、響應碼等,單擊 Try it out 按鈕,然後點擊 Execute 就可以發起調用請求、創建用戶,如下圖所示:

    註解介紹

    由於 Swagger 2 提供了非常多的註解供開發使用,這裏列舉一些比較常用的註解。

    @Api

    @Api 用在接口文檔資源類上,用於標記當前類為 Swagger 的文檔資源,其中含有幾個常用屬性:

    • value:定義當前接口文檔的名稱。
    • description:用於定義當前接口文檔的介紹。
    • tag:可以使用多個名稱來定義文檔,但若同時存在 tag 屬性和 value 屬性,則 value 屬性會失效。
    • hidden:如果值為 true,就會隱藏文檔。

    @ApiOperation

    @ApiOperation 用在接口文檔的方法上,主要用來註解接口,其中包含幾個常用屬性:

    • value:對API的簡短描述。
    • note:API的有關細節描述。
    • esponse:接口的返回類型(注意:這裏不是返回實際響應,而是返回對象的實際結果)。
    • hidden:如果值為 true,就會在文檔中隱藏。

    @ApiResponse、@ApiResponses

    @ApiResponses 和 @ApiResponse 二者配合使用返回 HTTP 狀態碼。@ApiResponses 的 value 值是 @ApiResponse 的集合,多個 @ApiResponse 用逗號分隔,其中 @ApiResponse 包含的屬性如下:

    • code:HTTP狀態碼。
    • message:HTTP狀態信息。
    • responseHeaders:HTTP 響應頭。

    @ApiParam

    @ApiParam 用於方法的參數,其中包含以下幾個常用屬性:

    • name:參數的名稱。
    • value:參數值。
    • required:如果值為 true,就是必傳字段。
    • defaultValue:參數的默認值。
    • type:參數的類型。
    • hidden:如果值為 true,就隱藏這個參數。

    @ApiImplicitParam、@ApiImplicitParams

    二者配合使用在 API 方法上,@ApiImplicitParams 的子集是 @ApiImplicitParam 註解,其中 @ApiImplicitParam 註解包含以下幾個參數:

    • name:參數的名稱。
    • value:參數值。
    • required:如果值為 true,就是必傳字段。
    • defaultValue:參數的默認值。
    • dataType:數據的類型。
    • hidden:如果值為 true,就隱藏這個參數。
    • allowMultiple:是否允許重複。

    @ResponseHeader

    API 文檔的響應頭,如果需要設置響應頭,就將 @ResponseHeader 設置到 @ApiResponseresponseHeaders 參數中。@ResponseHeader 提供了以下幾個參數:

    • name:響應頭名稱。
    • description:響應頭備註。

    @ApiModel

    設置 API 響應的實體類,用作 API 返回對象。@ApiModel 提供了以下幾個參數:

    • value:實體類名稱。
    • description:實體類描述。
    • subTypes:子類的類型。

    @ApiModelProperty

    設置 API 響應實體的屬性,其中包含以下幾個參數:

    • name:屬性名稱。
    • value:屬性值。
    • notes:屬性的註釋。
    • dataType:數據的類型。
    • required:如果值為 true,就必須傳入這個字段。
    • hidden:如果值為 true,就隱藏這個字段。
    • readOnly:如果值為 true,字段就是只讀的。
    • allowEmptyValue:如果為 true,就允許為空值。

    到此為止,我們就介紹完了 Swagger 提供的主要註解。

    總結

    Swagger 可以輕鬆地整合到 Spring Boot 中構建出強大的 RESTful API 文檔,可以減少我們編寫接口文檔的工作量,同時接口的說明內容也整合入代碼中,可以讓我們在修改代碼邏輯的同時方便的修改接口文檔說明,另外 Swagger 也提供了頁面測試功能來調試每個 RESTful API。

    如果項目中還未使用,不防嘗試一下,會發現效率會提升不少。

    本文的完整代碼在 https://github.com/wupeixuan/SpringBoot-Learn 的 interface-doc 目錄下。

    最好的關係就是互相成就,大家的在看、轉發、留言三連就是我創作的最大動力。

    參考

    http://swagger.io

    https://github.com/wupeixuan/SpringBoot-Learn

    《Spring Boot 2 實戰之旅》

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※超省錢租車方案

    ※別再煩惱如何寫文案,掌握八大原則!

    ※回頭車貨運收費標準

    ※教你寫出一流的銷售文案?

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    網頁設計最專業,超強功能平台可客製化

  • 容器技術之Docker私有鏡像倉庫docker-distribution

    容器技術之Docker私有鏡像倉庫docker-distribution

      在前邊的博客中我們說到docker的架構由docker客戶端、服務端以及倉庫組成;docker倉庫就是用來存放鏡像的地方;其實docker registry我們理解為存放docker鏡像倉庫的倉庫比較準確吧;因為docker的鏡像倉庫通常是把同一類的鏡像用不同的版本來區別,而registry則是用來存放這些倉庫的倉庫;默認安裝docker都是從dockerhub鏡像倉庫下載鏡像;其實在生產環境中,我們很少去公有倉庫上下載鏡像,原因之一是公有倉庫中的鏡像在生產環境中使用,有些不適配,通常我們是去公有倉庫下載基礎鏡像,然後基於基礎鏡像構建適合自己生產環境中的鏡像;其次公有倉庫鏡像有很多都不是安全的鏡像,這麼說吧,我們不確定自己下載的鏡像是否有後門,是否有挖礦代碼,所以基於種種因素,我們還是有必要搭建自己私有的鏡像倉庫;今天我們就來聊一聊docker的私有鏡像倉庫的搭建;

      1、查看docker-distribution包簡介

    [root@docker_registry ~]# yum info docker-distribution
    Loaded plugins: fastestmirror
    Loading mirror speeds from cached hostfile
     * base: mirrors.aliyun.com
     * extras: mirrors.aliyun.com
     * updates: mirrors.aliyun.com
    Available Packages
    Name        : docker-distribution
    Arch        : x86_64
    Version     : 2.6.2
    Release     : 2.git48294d9.el7
    Size        : 3.5 M
    Repo        : extras/7/x86_64
    Summary     : Docker toolset to pack, ship, store, and deliver content
    URL         : https://github.com/docker/distribution
    License     : ASL 2.0
    Description : Docker toolset to pack, ship, store, and deliver content
    
    [root@docker_registry ~]# 
    

      提示:docker-distribution這個包就是提供簡單倉庫服務軟件實現;

      2、安裝docker-distribution

    [root@docker_registry ~]# yum install -y docker-distribution
    Loaded plugins: fastestmirror
    Loading mirror speeds from cached hostfile
     * base: mirrors.aliyun.com
     * extras: mirrors.aliyun.com
     * updates: mirrors.aliyun.com
    Resolving Dependencies
    There are unfinished transactions remaining. You might consider running yum-complete-transaction, or "yum-complete-transaction --cleanup-only" and "yum history redo last", first to finish them. If those don't work you'll have to try removing/installing packages by hand (maybe package-cleanup can help).
    The program yum-complete-transaction is found in the yum-utils package.
    --> Running transaction check
    ---> Package docker-distribution.x86_64 0:2.6.2-2.git48294d9.el7 will be installed
    --> Finished Dependency Resolution
    
    Dependencies Resolved
    
    ===================================================================================================================
     Package                         Arch               Version                               Repository          Size
    ===================================================================================================================
    Installing:
     docker-distribution             x86_64             2.6.2-2.git48294d9.el7                extras             3.5 M
    
    Transaction Summary
    ===================================================================================================================
    Install  1 Package
    
    Total download size: 3.5 M
    Installed size: 12 M
    Downloading packages:
    docker-distribution-2.6.2-2.git48294d9.el7.x86_64.rpm                                       | 3.5 MB  00:00:03     
    Running transaction check
    Running transaction test
    Transaction test succeeded
    Running transaction
      Installing : docker-distribution-2.6.2-2.git48294d9.el7.x86_64                                               1/1 
      Verifying  : docker-distribution-2.6.2-2.git48294d9.el7.x86_64                                               1/1 
    
    Installed:
      docker-distribution.x86_64 0:2.6.2-2.git48294d9.el7                                                              
    
    Complete!
    [root@docker_registry ~]# 
    

      3、查看docker-distribution安裝了那些文件

    [root@docker_registry ~]# rpm -ql docker-distribution
    /etc/docker-distribution/registry/config.yml
    /usr/bin/registry
    /usr/lib/systemd/system/docker-distribution.service
    /usr/share/doc/docker-distribution-2.6.2
    /usr/share/doc/docker-distribution-2.6.2/AUTHORS
    /usr/share/doc/docker-distribution-2.6.2/CONTRIBUTING.md
    /usr/share/doc/docker-distribution-2.6.2/LICENSE
    /usr/share/doc/docker-distribution-2.6.2/MAINTAINERS
    /usr/share/doc/docker-distribution-2.6.2/README.md
    /var/lib/registry
    [root@docker_registry ~]# 
    

      提示:/etc/docker-distribution/registry/config.yml這個文件用於配置registry的配置文件;/usr/bin/registry是二進制應用程序;/usr/lib/systemd/system/docker-distribution.service 這個文件是docker-distribution的unit file;/var/lib/registry這個目錄用於存放我們上傳到registry上的鏡像存放地;

      4、查看配置文件

    [root@docker_registry ~]# cat /etc/docker-distribution/registry/config.yml
    version: 0.1
    log:
      fields:
        service: registry
    storage:
        cache:
            layerinfo: inmemory
        filesystem:
            rootdirectory: /var/lib/registry
    http:
        addr: :5000
    [root@docker_registry ~]# 
    

      提示:這個配置文件是一個yml語法的配置文件,從上面的信息可以看到,默認情況docker-distribution監聽在tcp的5000端口;存放鏡像的目錄是/var/lib/registry/目錄下;

      5、啟動docker-distribution

    [root@docker_registry ~]# systemctl start docker-distribution
    [root@docker_registry ~]# ss -tnl
    State       Recv-Q Send-Q            Local Address:Port                           Peer Address:Port              
    LISTEN      0      128                           *:22                                        *:*                  
    LISTEN      0      100                   127.0.0.1:25                                        *:*                  
    LISTEN      0      128                          :::22                                       :::*                  
    LISTEN      0      100                         ::1:25                                       :::*                  
    LISTEN      0      128                          :::5000                                     :::*                  
    [root@docker_registry ~]# 
    

      提示:可以看到5000端口已經處於監聽狀態了;到此docker-distribution就啟動起來了;這個倉庫服務很簡陋,沒有用戶認證功能,默認是基於http通信而非https,所以從某些角度講,不是一個安全的倉庫;所以一般不見在互聯網上使用,在自己的內外環境中可以使用;

      這裏補充一點,docker的鏡像通常是 registry地址加repository名稱加版本這三部分組成,registry可以是域名,可以是ip地址加端口,也可以說域名加端口,默認https是443端口,http是80端口,如果不寫端口默認是443而非80(原因是docker默認不支持從http協議的倉庫下載/上傳鏡像);例如 quay.io/coreos/flannel:v0.12.0-s390x  從這個鏡像名我們就可以知道registry是https://quay.io;repository名稱為coreos/flannel 版本是v0.12.0-s390x;

      示例:下載第三方倉庫鏡像到本地

      提示:可以看到下載下來的鏡像名稱就是我們剛才說的registry+repository+版本;從上面的信息我們可以總結一點,docker鏡像的名稱(標籤)反應了該鏡像來自哪個registry的那個倉庫;所以我們要下載私有鏡像倉庫中的鏡像就需要把加上私有registry的名稱或地址+repository+版本來下載私有鏡像倉庫中的鏡像;同理上傳鏡像也需要寫明上傳到那個registry中的那個repository中去;

      示例:上傳本地鏡像到私有倉庫

      提示:要把本地倉庫鏡像傳到私有倉庫中去,首先我們要把本地鏡像打一個新的標籤,按照我們剛才上面說的邏輯,然後在上傳新打到標籤的鏡像到私有倉庫就可以了;從上面的信息我們看到當我們打好標籤后,上傳鏡像時報錯了,提示我們倉庫不是https的;默認情況docker不支持http明文上傳/下載鏡像;如果我們非要用http上傳下載鏡像我們需要在配置文件中明確的告訴docker非安全倉庫地址;

      配置docker支持私有倉庫上傳下載鏡像

    [root@docker_registry ~]# cat /etc/docker/daemon.json
    {
            "registry-mirrors": ["https://registry.docker-cn.com","https://cyr1uljt.mirror.aliyuncs.com"],
            "insecure-registries": ["192.168.0.99:5000"]
    }
    
    [root@docker_registry ~]# systemctl daemon-reload    
    [root@docker_registry ~]# systemctl restart docker 
    

      提示:我們通過在配置文件中配置insecure-registries來告訴docker192.168.0.99:5000這個registry是不安全的,但是我們信任這個倉庫,大概就是這個意思嘛;通常我們是寫主機名然後配合hosts文件來解析的方式來對registry解析;從而把鏡像命名為主機名+倉庫名+版本的形式;如下所示;這裏還需要注意一點insecure-registries後面的列表中的倉庫如果有域名,域名不能有下劃線(“_”),否則重啟docker會起不來;

    [root@docker_registry ~]# cat /etc/docker/daemon.json 
    {
            "registry-mirrors": ["https://registry.docker-cn.com","https://cyr1uljt.mirror.aliyuncs.com"],
            "insecure-registries": ["192.168.0.99:5000","docker-registry.io:5000"]
    
    }
    [root@docker_registry ~]# cat /etc/hosts
    127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
    ::1         localhost localhost.localdomain localhost6 localhost6.localdomain6
    192.168.0.99 docker-registry.io registry
    192.168.0.22 docker-node01.io node01
    192.168.0.23 docker-node02.io node02
    [root@docker_registry ~]# systemctl restart docker
    [root@docker_registry ~]# docker info
    Client:
     Debug Mode: false
    
    Server:
     Containers: 0
      Running: 0
      Paused: 0
      Stopped: 0
     Images: 1
     Server Version: 19.03.11
     Storage Driver: overlay2
      Backing Filesystem: xfs
      Supports d_type: true
      Native Overlay Diff: true
     Logging Driver: json-file
     Cgroup Driver: cgroupfs
     Plugins:
      Volume: local
      Network: bridge host ipvlan macvlan null overlay
      Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
     Swarm: inactive
     Runtimes: runc
     Default Runtime: runc
     Init Binary: docker-init
     containerd version: 7ad184331fa3e55e52b890ea95e65ba581ae3429
     runc version: dc9208a3303feef5b3839f4323d9beb36df0a9dd
     init version: fec3683
     Security Options:
      seccomp
       Profile: default
     Kernel Version: 3.10.0-693.el7.x86_64
     Operating System: CentOS Linux 7 (Core)
     OSType: linux
     Architecture: x86_64
     CPUs: 4
     Total Memory: 1.785GiB
     Name: docker_registry
     ID: R34V:IG2F:23I6:6WG6:FFQ4:75SV:3UKZ:RFH7:DGCO:QS7V:CS7K:NSH6
     Docker Root Dir: /var/lib/docker
     Debug Mode: false
     Registry: https://index.docker.io/v1/
     Labels:
     Experimental: false
     Insecure Registries:
      192.168.0.99:5000
      docker-registry.io:5000
      127.0.0.0/8
     Registry Mirrors:
      https://registry.docker-cn.com/
      https://cyr1uljt.mirror.aliyuncs.com/
     Live Restore Enabled: false
    
    [root@docker_registry ~]#
    

      提示:重啟docker后,如果在docker info 中能夠看到我們配置的內容說明配置生效了;現在我們再來傳我們新打的標籤的鏡像,看看是否能夠傳到我們的私有倉庫呢?

    [root@docker_registry ~]# docker images
    REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
    docker-registry.io:5000/centos   7                   b5b4d78bc90c        4 weeks ago         203MB
    centos                           7                   b5b4d78bc90c        4 weeks ago         203MB
    192.168.0.99:5000/flannel        v0.12.0-s390x       57eade024bfb        2 months ago        56.9MB
    quay.io/coreos/flannel           v0.12.0-s390x       57eade024bfb        2 months ago        56.9MB
    [root@docker_registry ~]# docker push 192.168.0.99:5000/flannel:v0.12.0-s390x
    The push refers to repository [192.168.0.99:5000/flannel]
    b67de7789e55: Pushed 
    4c4bfa1b47e6: Pushed 
    3b7ae8a9c323: Pushed 
    fbd88a276dca: Pushed 
    271ca11ef489: Pushed 
    1f106b41b4d6: Pushed 
    v0.12.0-s390x: digest: sha256:3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6 size: 1579
    [root@docker_registry ~]# docker push docker-registry.io:5000/centos:7
    The push refers to repository [docker-registry.io:5000/centos]
    edf3aa290fb3: Pushed 
    7: digest: sha256:c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941 size: 529
    [root@docker_registry ~]# 
    

      提示:可以看到我們上傳的兩個鏡像都完成了上傳沒有報錯,接下來我們去/var/lib/registry/這個目錄,看看是否有這兩個鏡像相關目錄?

    [root@docker_registry ~]# tree /var/lib/registry/
    /var/lib/registry/
    └── docker
        └── registry
            └── v2
                ├── blobs
                │   └── sha256
                │       ├── 13
                │       │   └── 13b80a37370b57f558a2e06092c39224e5d1ebac50e48df0afdeb43cf2303e60
                │       │       └── data
                │       ├── 17
                │       │   └── 176bad61a3a435da03ec603d2bd8f7a69286d92f21f447b17f21f0bc4e085bde
                │       │       └── data
                │       ├── 1b
                │       │   └── 1b56fbc8a8e10830867455c0794a70f5469c154cdc013554daf501aeda3f30fe
                │       │       └── data
                │       ├── 26
                │       │   └── 266247e2e603e1c840f97cb4d97a08b9184344e9802966cb42c93d21c407839f
                │       │       └── data
                │       ├── 3c
                │       │   └── 3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6
                │       │       └── data
                │       ├── 42
                │       │   └── 42d8e66fa893de4beb5d136b787cf182b24b7f4972c4212b9493b661ad1d7e85
                │       │       └── data
                │       ├── 52
                │       │   └── 524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
                │       │       └── data
                │       ├── 57
                │       │   └── 57eade024bfbd48c45ef2bad996c4d6a0fa41b692247294745265af738066813
                │       │       └── data
                │       ├── 85
                │       │   └── 85ecb68de4693bb4093d923f6d1062766e4fa7cbb3bf456a2bc19dd3e6c5e6c6
                │       │       └── data
                │       ├── b5
                │       │   └── b5b4d78bc90ccd15806443fb881e35b5ddba924e2f475c1071a38a3094c3081d
                │       │       └── data
                │       └── c2
                │           └── c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941
                │               └── data
                └── repositories
                    ├── centos
                    │   ├── _layers
                    │   │   └── sha256
                    │   │       ├── 524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
                    │   │       │   └── link
                    │   │       └── b5b4d78bc90ccd15806443fb881e35b5ddba924e2f475c1071a38a3094c3081d
                    │   │           └── link
                    │   ├── _manifests
                    │   │   ├── revisions
                    │   │   │   └── sha256
                    │   │   │       └── c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941
                    │   │   │           └── link
                    │   │   └── tags
                    │   │       └── 7
                    │   │           ├── current
                    │   │           │   └── link
                    │   │           └── index
                    │   │               └── sha256
                    │   │                   └── c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941
                    │   │                       └── link
                    │   └── _uploads
                    └── flannel
                        ├── _layers
                        │   └── sha256
                        │       ├── 13b80a37370b57f558a2e06092c39224e5d1ebac50e48df0afdeb43cf2303e60
                        │       │   └── link
                        │       ├── 176bad61a3a435da03ec603d2bd8f7a69286d92f21f447b17f21f0bc4e085bde
                        │       │   └── link
                        │       ├── 1b56fbc8a8e10830867455c0794a70f5469c154cdc013554daf501aeda3f30fe
                        │       │   └── link
                        │       ├── 266247e2e603e1c840f97cb4d97a08b9184344e9802966cb42c93d21c407839f
                        │       │   └── link
                        │       ├── 42d8e66fa893de4beb5d136b787cf182b24b7f4972c4212b9493b661ad1d7e85
                        │       │   └── link
                        │       ├── 57eade024bfbd48c45ef2bad996c4d6a0fa41b692247294745265af738066813
                        │       │   └── link
                        │       └── 85ecb68de4693bb4093d923f6d1062766e4fa7cbb3bf456a2bc19dd3e6c5e6c6
                        │           └── link
                        ├── _manifests
                        │   ├── revisions
                        │   │   └── sha256
                        │   │       └── 3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6
                        │   │           └── link
                        │   └── tags
                        │       └── v0.12.0-s390x
                        │           ├── current
                        │           │   └── link
                        │           └── index
                        │               └── sha256
                        │                   └── 3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6
                        │                       └── link
                        └── _uploads
    
    65 directories, 26 files
    [root@docker_registry ~]# 
    

      提示:可以看到對應目錄下有兩個子目錄就是以我們上傳的鏡像名稱命名的;

      示例:查看私有倉庫中存在的進行列表

    [root@docker_registry ~]# curl docker-registry.io:5000/v2/_catalog
    {"repositories":["centos","flannel"]}
    [root@docker_registry ~]# 

      示例:下載私有倉庫中的鏡像到本地

    [root@docker_node01 ~]# ip a l ens33
    2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
        link/ether 00:0c:29:22:36:7f brd ff:ff:ff:ff:ff:ff
        inet 192.168.0.22/24 brd 192.168.0.255 scope global ens33
           valid_lft forever preferred_lft forever
        inet6 fe80::20c:29ff:fe22:367f/64 scope link 
           valid_lft forever preferred_lft forever
    [root@docker_node01 ~]# docker images
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    linux1874/myimg     v0.1                e408b1c6e04f        2 weeks ago         1.22MB
    wordpress           latest              c3fa1c8546fb        5 weeks ago         540MB
    mysql               5.7                 f965319e89de        5 weeks ago         448MB
    alpine              v3                  f70734b6a266        6 weeks ago         5.61MB
    nginx               1.14-alpine         8a2fb25a19f5        14 months ago       16MB
    httpd               2.4.37-alpine       dfd436f9a5d8        17 months ago       91.8MB
    [root@docker_node01 ~]# docker pull 192.168.0.99:5000/flannel:v0.12.0-s390x
    v0.12.0-s390x: Pulling from flannel
    176bad61a3a4: Pull complete 
    13b80a37370b: Pull complete 
    42d8e66fa893: Pull complete 
    266247e2e603: Pull complete 
    1b56fbc8a8e1: Pull complete 
    85ecb68de469: Pull complete 
    Digest: sha256:3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6
    Status: Downloaded newer image for 192.168.0.99:5000/flannel:v0.12.0-s390x
    192.168.0.99:5000/flannel:v0.12.0-s390x
    [root@docker_node01 ~]# docker images
    REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
    linux1874/myimg             v0.1                e408b1c6e04f        2 weeks ago         1.22MB
    wordpress                   latest              c3fa1c8546fb        5 weeks ago         540MB
    mysql                       5.7                 f965319e89de        5 weeks ago         448MB
    alpine                      v3                  f70734b6a266        6 weeks ago         5.61MB
    192.168.0.99:5000/flannel   v0.12.0-s390x       57eade024bfb        2 months ago        56.9MB
    nginx                       1.14-alpine         8a2fb25a19f5        14 months ago       16MB
    httpd                       2.4.37-alpine       dfd436f9a5d8        17 months ago       91.8MB
    [root@docker_node01 ~]# 
    

      提示:下載私有倉庫中的鏡像,默認情況docker也是不支持直接訪問http協議的倉庫,需要我們手動去配置insecure-registries,然後重啟docker才可以;

      示例:刪除私有倉庫中的鏡像

      1、獲取對應鏡像的sha256的值 curl –header “Accept:application/vnd.docker.distribution.manifest.v2+json” -I -X GET http://<registry addr>/v2/<image name>/manifests/<image tag>

      2、刪除對應鏡像版本元數據 curl -I -X DELETE http://<registry addr>/v2/<image name>/manifests/<image digest>

      提示:如果響應405方法不被允許;我們需要修改私有倉庫的配置文件,將其配置為允許刪除;如下

    [root@docker_registry ~]# cat /etc/docker-distribution/registry/config.yml
    version: 0.1
    log:
      fields:
        service: registry
    storage:
        delete:
            enabled: true
        cache:
            layerinfo: inmemory
        filesystem:
            rootdirectory: /var/lib/registry
    http:
        addr: :5000
    [root@docker_registry ~]# systemctl restart docker-distribution           
    [root@docker_registry ~]# curl -IX DELETE http://docker-registry.io:5000/v2/centos/manifests/sha256:c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941
    HTTP/1.1 202 Accepted
    Docker-Distribution-Api-Version: registry/2.0
    Date: Sat, 06 Jun 2020 19:55:52 GMT
    Content-Length: 0
    Content-Type: text/plain; charset=utf-8
    
    [root@docker_registry ~]#
    

      提示:degest值包含”sha256:”

      3、垃圾回收清理

    [root@docker_registry ~]# registry garbage-collect /etc/docker-distribution/registry/config.yml 
    centos
    flannel
    flannel: marking manifest sha256:3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6 
    flannel: marking blob sha256:57eade024bfbd48c45ef2bad996c4d6a0fa41b692247294745265af738066813
    flannel: marking blob sha256:176bad61a3a435da03ec603d2bd8f7a69286d92f21f447b17f21f0bc4e085bde
    flannel: marking blob sha256:13b80a37370b57f558a2e06092c39224e5d1ebac50e48df0afdeb43cf2303e60
    flannel: marking blob sha256:42d8e66fa893de4beb5d136b787cf182b24b7f4972c4212b9493b661ad1d7e85
    flannel: marking blob sha256:266247e2e603e1c840f97cb4d97a08b9184344e9802966cb42c93d21c407839f
    flannel: marking blob sha256:1b56fbc8a8e10830867455c0794a70f5469c154cdc013554daf501aeda3f30fe
    flannel: marking blob sha256:85ecb68de4693bb4093d923f6d1062766e4fa7cbb3bf456a2bc19dd3e6c5e6c6
    myweb
    myweb: marking manifest sha256:aaf04cf567a776e36eb3b0bafaec17ed8d9e0a743bdb897dca13f251250ae493 
    myweb: marking blob sha256:4f406abeaab7f848178867409142090d1a551b22b968be6a6dae733c8403738e
    myweb: marking blob sha256:524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
    myweb: marking blob sha256:c941076f9075280c41b502283f37ab8bafef3a66f4a7ba299838dce07641a48d
    test
    test: marking manifest sha256:5ecad23ab8a52e55f93968f708d325261032dd613287aec92e7cf8ddd6426635 
    test: marking blob sha256:370e3a843c3cb12700301e3f87f929939146cd8b676260bedcd83aaa7fcc2939
    test: marking blob sha256:524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
    test: marking manifest sha256:da8b53210bf1f4dc4873bbd5589abad616663cda45205ae3a4fffb0729d2730d 
    test: marking blob sha256:461f6ceabc885e2e90b5f9ee82aefc9a30a39510c40e7cd8fb7436a4d340fe1d
    test: marking blob sha256:524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
    test: marking blob sha256:035e026f1d6b0acba3413ba616dcbabf75d20e945778c52716e601255452b7b7
    
    17 blobs marked, 2 blobs eligible for deletion
    blob eligible for deletion: sha256:b5b4d78bc90ccd15806443fb881e35b5ddba924e2f475c1071a38a3094c3081d
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/b5/b5b4d78bc90ccd15806443fb881e35b5ddba924e2f475c1071a38a3094c3081d  go.version=go1.9.4 instance.id=b3029d7f-99e8-4941-8c87-989514b584ea
    blob eligible for deletion: sha256:c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/c2/c2f1d5a9c0a81350fa0ad7e1eee99e379d75fe53823d44b5469eb2eb6092c941  go.version=go1.9.4 instance.id=b3029d7f-99e8-4941-8c87-989514b584ea
    [root@docker_registry ~]# 
    

      測試:下載docker-registry.io:5000/centos:7 看看是否還能下載?

    [root@docker_node01 ~]# docker pull 192.168.0.99:5000/centos:7
    Error response from daemon: manifest for 192.168.0.99:5000/centos:7 not found: manifest unknown: manifest unknown
    [root@docker_node01 ~]# 
    

      提示:以上提示告訴我們沒有對應鏡像的元數據信息;說明我們私有倉庫沒有對應鏡像;以上方法適合精準刪除某個鏡像的某個版本,如果是刪除一個倉庫,直接刪除 /var/lib/registry/docker/registry/v2/repositories/下對應倉庫的目錄,然後在用registry命令做垃圾回收;如下

    [root@docker_registry ~]# ll /var/lib/registry/docker/registry/v2/repositories/
    total 0
    drwxr-xr-x 5 root root 55 Jun  6 14:16 centos
    drwxr-xr-x 5 root root 55 Jun  6 14:15 flannel
    drwxr-xr-x 5 root root 55 Jun  6 15:25 myweb
    drwxr-xr-x 5 root root 55 Jun  6 15:24 test
    [root@docker_registry ~]# rm -rf /var/lib/registry/docker/registry/v2/repositories/test
    [root@docker_registry ~]# rm -rf /var/lib/registry/docker/registry/v2/repositories/myweb
    [root@docker_registry ~]# ll /var/lib/registry/docker/registry/v2/repositories/
    total 0
    drwxr-xr-x 5 root root 55 Jun  6 14:16 centos
    drwxr-xr-x 5 root root 55 Jun  6 14:15 flannel
    [root@docker_registry ~]# registry garbage-collect /etc/docker-distribution/registry/config.yml 
    centos
    flannel
    flannel: marking manifest sha256:3ce5b8d40451787e1166bf6b207c7834c13f7a0712b46ddbfb591d8b5906bfa6 
    flannel: marking blob sha256:57eade024bfbd48c45ef2bad996c4d6a0fa41b692247294745265af738066813
    flannel: marking blob sha256:176bad61a3a435da03ec603d2bd8f7a69286d92f21f447b17f21f0bc4e085bde
    flannel: marking blob sha256:13b80a37370b57f558a2e06092c39224e5d1ebac50e48df0afdeb43cf2303e60
    flannel: marking blob sha256:42d8e66fa893de4beb5d136b787cf182b24b7f4972c4212b9493b661ad1d7e85
    flannel: marking blob sha256:266247e2e603e1c840f97cb4d97a08b9184344e9802966cb42c93d21c407839f
    flannel: marking blob sha256:1b56fbc8a8e10830867455c0794a70f5469c154cdc013554daf501aeda3f30fe
    flannel: marking blob sha256:85ecb68de4693bb4093d923f6d1062766e4fa7cbb3bf456a2bc19dd3e6c5e6c6
    
    8 blobs marked, 9 blobs eligible for deletion
    blob eligible for deletion: sha256:370e3a843c3cb12700301e3f87f929939146cd8b676260bedcd83aaa7fcc2939
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/37/370e3a843c3cb12700301e3f87f929939146cd8b676260bedcd83aaa7fcc2939  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/52/524b0c1e57f8ee5fee01a1decba2f301c324a6513ca3551021264e3aa7341ebc  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:5ecad23ab8a52e55f93968f708d325261032dd613287aec92e7cf8ddd6426635
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/5e/5ecad23ab8a52e55f93968f708d325261032dd613287aec92e7cf8ddd6426635  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:aaf04cf567a776e36eb3b0bafaec17ed8d9e0a743bdb897dca13f251250ae493
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/aa/aaf04cf567a776e36eb3b0bafaec17ed8d9e0a743bdb897dca13f251250ae493  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:035e026f1d6b0acba3413ba616dcbabf75d20e945778c52716e601255452b7b7
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/03/035e026f1d6b0acba3413ba616dcbabf75d20e945778c52716e601255452b7b7  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:461f6ceabc885e2e90b5f9ee82aefc9a30a39510c40e7cd8fb7436a4d340fe1d
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/46/461f6ceabc885e2e90b5f9ee82aefc9a30a39510c40e7cd8fb7436a4d340fe1d  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:4f406abeaab7f848178867409142090d1a551b22b968be6a6dae733c8403738e
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/4f/4f406abeaab7f848178867409142090d1a551b22b968be6a6dae733c8403738e  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:c941076f9075280c41b502283f37ab8bafef3a66f4a7ba299838dce07641a48d
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/c9/c941076f9075280c41b502283f37ab8bafef3a66f4a7ba299838dce07641a48d  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    blob eligible for deletion: sha256:da8b53210bf1f4dc4873bbd5589abad616663cda45205ae3a4fffb0729d2730d
    INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/da/da8b53210bf1f4dc4873bbd5589abad616663cda45205ae3a4fffb0729d2730d  go.version=go1.9.4 instance.id=1a15f1e7-194f-4a82-b79d-9f437d975f6e
    [root@docker_registry ~]# 
    

      提示:這種方式比較粗暴簡單,通常是一個倉庫里只有一個版本鏡像可以使用這種方式刪除,如果一個倉庫有多個版本,那麼還是建議使用第一種方式;

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    ※回頭車貨運收費標準

    ※推薦評價好的iphone維修中心

    ※超省錢租車方案

    台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

    ※推薦台中搬家公司優質服務,可到府估價

  • 疫情下的奇景!孟買市區湧入大量紅鶴 數量估創紀錄

    摘錄自2020年5月2日自由時報綜合報導

    根據《CNN》報導,每年9月至翌年5月都會觀測到紅鶴族群遷徙至孟買覓食,然而,今(2020)年在人類活動大幅下降的狀況下,遷徙至當地的紅鶴數量預估將超過13萬4000隻,創下歷史新高。

    孟買自然歷史學會(BNHS)副主任科特(Rahul Khot)表示,在人類社交活動暫停後,當地不僅出現破紀錄數量的紅鶴,牠們選定的棲地也與往常相異,已有族群擴展至以往少見紅鶴蹤跡的濕地。

    印度境內陸續傳出野生動物受益於武漢肺炎疫情的消息,不只德里湧入大量猴群,極瀕危的恆河江豚也在多年來首度被觀測到活體行為;顯示出人類活動暫停,讓我們的地球鄰居們產生明顯變化。

    生物多樣性
    生態保育
    國際新聞
    印度
    孟買
    紅鶴
    江豚
    動物與大環境變遷
    武漢肺炎

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※回頭車貨運收費標準

  • 山地大猩猩的家園不平靜 剛果維龍加國家公園12名護管員遭殺害

    環境資訊中心綜合外電;姜唯 編譯;林大利 審校

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※超省錢租車方案

    ※教你寫出一流的銷售文案?

    網頁設計最專業,超強功能平台可客製化

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

  • 墨西哥穩定電力擺第一 新能源測試喊卡

    摘錄自2020年5月4日經濟日報報導

    在疫情蔓延下,墨西哥電力系統主管機關宣布,對乾淨能源新計畫的關鍵測試無限期喊卡,另採取措施,以提高全國電力系統的穩定性,但批評者擔心,這項措施將傷害再生能源業者。

     

    能源議題
    能源轉型
    國際新聞
    墨西哥
    乾淨能源
    武漢肺炎
    綠電
    疫情看氣候與能源
    新能源

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※教你寫出一流的銷售文案?

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※回頭車貨運收費標準

    ※別再煩惱如何寫文案,掌握八大原則!

    ※超省錢租車方案

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※推薦台中搬家公司優質服務,可到府估價

  • 法下週解除封鎖令 巴黎交通主幹道規劃給自行車

    摘錄自2020年5月5日自由時報報導

    法國為遏止武漢肺炎(新型冠狀病毒疾病,COVID-19)疫情擴散,自3月17日起實施全國封鎖,期間2度延長封鎖禁令至5月11日;面對下週即將解除的封鎖令,巴黎市長伊達戈(Anne Hidalgo)將把最繁忙的主要交通幹道規劃給自行車,以減少民眾對大眾運輸工具的依賴,進而避免群聚感染。

    伊達戈今(5日)指出,城市解封後共將保留50公里原先的汽車道給自行車使用,另外將有30條街道將被設置為行人專用道,她強調,「特別是在學校周圍,以避免人群聚集」。

    法國政府也宣布了一項2000萬歐元(約新台幣6.5億元)的自行車計畫,用以刺激民眾在封鎖解除後對自行車的使用度,其中包括每人50歐元(約新台幣1620元)的自行車維修或調整補貼。

    生活環境
    國際新聞
    法國
    檢疫封鎖
    解除
    自行車道

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※超省錢租車方案

    ※別再煩惱如何寫文案,掌握八大原則!

    ※回頭車貨運收費標準

    ※教你寫出一流的銷售文案?

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    網頁設計最專業,超強功能平台可客製化

  • 疫情下的奇景!西班牙西北部棕熊出沒 150年來首見

    摘錄自2020年5月5日自由時報報導

    根據《CNN》報導,位於西班牙西北部加利西亞(Galicia)奧倫塞的「O Invernadeiro」自然保護區,近日透過架設在園內的攝影機拍到一頭年輕公熊活動的畫面,據分析,這頭公熊年紀在3至5歲左右,健康狀況良好。

    園方表示,棕熊是西班牙原生物種,從1973年起便被列入野生動物保護名單,過往雖有文獻紀錄常出沒在加利西亞地區,但這次是150年內首度有棕熊被觀測到在加利西亞南部出現,意義非凡。

    生態保育
    生物多樣性
    國際新聞
    西班牙
    棕熊
    動物與大環境變遷
    武漢肺炎

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    ※回頭車貨運收費標準

    ※推薦評價好的iphone維修中心

    ※超省錢租車方案

    台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

    ※推薦台中搬家公司優質服務,可到府估價

  • 中德電動汽車充電項目取得新進展 第三階段正式啟動

    中德電動汽車充電項目取得新進展 第三階段正式啟動

    德國Spiegel Institut Mannheim對半公共充電樁用戶的充電和駕駛行為進行了研究,提出了提高充電效率、付費方式便捷化等建議。本研討會的另一重要議題是介紹並啟動項目第三階段的研究工作。作為項目第三階段的主要研究機構,普華永道思略特在研討會上向與會人員介紹了項目第三階段的主要課題內容和部分課題的初步研究成果。

    在中德兩國政府以及大眾汽車、寶馬、戴姆勒、北汽的大力支持下,“中德電動汽車充電項目”於2016年11月8日在北京召開了第二階段總結暨第三階段啟動會。來自相關政府部門、行業協會、電力企業、中德車企和研究機構的代表出席了會議。國家發改委產業協調司机械裝備處處長吳衛和德國聯邦環境、自然保護、建築和核安全部主管排放控制,設備安全與交通司副司長Dr. Norbert Salomon出席會議並致辭。

    “中德電動汽車充電項目”第二階段於2015年3月在北京啟動,於2015年12月底完成。項目選取北京地區住宅小區的公共停車區域、寫字樓、政府機關事業單位以及公共商業物業等(半)公共領域開展充電解決方案研究。參与項目運營的電動汽車包括奧迪 A3 e-tron、北汽EV200、大眾汽車electric up!、寶馬i3、奔馳Smart ED、華晨寶馬之諾1E 和騰勢。

    “中德電動汽車充電項目”第二階段針對(半)公共領域充電開展了三個課題的研究,即“電動汽車用戶信息研究”、“半公共區域電動汽車充電設施商業模式實證研究”和“基於電動汽車發展的北京市(半)公共區域充電地點選擇和可行性分析”。清華大學、中國汽車技術研究中心以及德國Spiegel Institut Mannheim作為課題研究機構,在會上分別彙報了研究成果。

    清華大學研究團隊以北京為例開展研究,得出的基本結論是在中國推廣半公共充電有其可行性。此外,通過對車位及充電設施使用開放度的分析,研究團隊認為,在保證合理商業運營及管理模式的前提下,對寫字樓、公共商業物業甚至是政府及事業單位中低峰時段的車位加以利用也有其可行性,值得积極探索。

    中國汽車技術研究中心研究團隊從充電基礎設施商業運營模式角度,建議政府適時出檯面向運營環節的補貼、停車費用減免等政策,使半公共充電成為私人充電的有效補充和替代。同時要鼓勵運營商积極創新、嘗試新型業務和商業模式,通過擴展業務範圍及實現與其他相關業務的協同發展來拓寬收入來源,有效縮短投資回收周期。同時,中國汽車技術研究中心研究團隊也提出在半公共區域單獨報裝充電設施、建設充電專屬車位等其他相關建議。

    德國Spiegel Institut Mannheim對半公共充電樁用戶的充電和駕駛行為進行了研究,提出了提高充電效率、付費方式便捷化等建議。

    本研討會的另一重要議題是介紹並啟動項目第三階段的研究工作。作為項目第三階段的主要研究機構,普華永道思略特在研討會上向與會人員介紹了項目第三階段的主要課題內容和部分課題的初步研究成果。

    項目第三階段的研究課題主要圍繞未來長里程電動汽車的充電需求及其與環境的相互影響。課題分為六大模塊:長里程電動車需求預測、消費者充電需求及充電行為分析、相關政策法規及技術參數分析、電動汽車發展與電力供應的相互影響、電動汽車發展對住建行業的主要影響,以及充電基礎設施發展分析等。

    其中,普華永道思略特在研討會上針對“電動汽車發展與電力供應的相互影響”模塊的一些初步成果也進行了彙報與討論。普華永道思略特分析,由於中國呈現電力過剩的特點,電動汽車不但不會對發電端造成壓力,還能消耗過剩電能。從用電負荷分析,在無序充電的情景下,2020~2025年電動汽車引起的用電負荷增加量佔全國裝機量的比例較少,在全國層面造成的影響較小;當電動汽車佔比達到較高水平時,部分省市峰值用電負荷將顯著增加,為電網帶來一定壓力。另外,隨着電動汽車的發展,部分小區將出現配電系統升級的需求。

    針對電網如何能夠更好地支持電動汽車產業的發展,普華永道思略特給出了三點建議:建立跨行業溝通平台,推動各利益相關方的合作,积極促進各方達成共識並提高資源利用效率;更好的發揮價格指導作用,激勵消費者,並加快發展智能化、信息化技術,實現有序充電;研究制定傳導機制,解決因電動汽車發展帶來的配電網備擴容成本問題,確保有效傳遞電力企業或者產權方承擔的成本增加。

    最後,中德雙方均對“中德電動汽車充電項目”第二階段研究成果表示肯定,並期待第三階段的研究成果能夠更加豐富。中德兩國政府將會繼續支持中德電動汽車充電項目的持續推進。本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

    南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※回頭車貨運收費標準

  • 溫故知新-多線程-深入剖析AQS

    溫故知新-多線程-深入剖析AQS

    目錄

    • 摘要
    • AbstractQueuedSynchronizer實現一把鎖
    • ReentrantLock
      • ReentrantLock的特點
      • Synchronized的基礎用法
      • ReentrantLock與AQS的關聯
      • AQS架構圖
      • acquire獲取鎖
        • tryAcquire
        • hasQueuedPredecessors
        • acquireQueued
        • setHead
        • shouldParkAfterFailedAcquire
        • parkAndCheckInterrupt
        • cancelAcquire
      • unlock解鎖
        • release
        • tryRelease
        • unparkSuccessor
      • 中斷恢復
    • 其它
    • 參考
    • 你的鼓勵也是我創作的動力
    • Posted by 微博@Yangsc_o
    • 原創文章,版權聲明:自由轉載-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

    摘要

    本文通過ReentrantLock來窺探AbstractQueuedSynchronizer(AQS)的實現原理,在看此文之前。你需要了解一下park、unpark的功能,請移步至上一篇《深入剖析park、unpark》;

    AbstractQueuedSynchronizer實現一把鎖

    根據AbstractQueuedSynchronizer的官方文檔,如果想實現一把鎖的,需要繼承AbstractQueuedSynchronizer,並需要重寫tryAcquire、tryRelease、可選擇重寫isHeldExclusively提供locked state、因為支持序列化,所以需要重寫readObject以便反序列化時恢復原始值、newCondition提供條件;官方提供的java代碼如下(官方文檔見參考連接);

    public class MyLock implements Lock, java.io.Serializable {
        private static class Sync extends AbstractQueuedSynchronizer {
          
            // Acquires the lock if state is zero
            @Override
            public boolean tryAcquire(int acquires) {
                assert acquires == 1; // Otherwise unused
                if (compareAndSetState(0, 1)) {
                    setExclusiveOwnerThread(Thread.currentThread());
                    return true;
                }
                return false;
            }
    
            // Releases the lock by setting state to zero
            @Override
            protected boolean tryRelease(int releases) {
                assert releases == 1; // Otherwise unused
                if (getState() == 0) {
                    throw new IllegalMonitorStateException();
                }
                setExclusiveOwnerThread(null);
                setState(0);
                return true;
            }
    
            // Provides a Condition
            Condition newCondition() {
                return new ConditionObject();
            }
    
            // Deserializes properly
            private void readObject(ObjectInputStream s)
                    throws IOException, ClassNotFoundException {
                s.defaultReadObject();
                setState(0); // reset to unlocked state
            }
          
           // Reports whether in locked state
            @Override
            protected boolean isHeldExclusively() {
                return getState() == 1;
            }
        }
    
        /**
         * The sync object does all the hard work. We just forward to it.
         */
        private final Sync sync = new Sync();
    
        @Override
        public void lock() {
            sync.acquire(1);
        }
    
        @Override
        public void lockInterruptibly() throws InterruptedException {
            sync.acquireInterruptibly(1);
        }
    
        @Override
        public boolean tryLock() {
            return sync.tryAcquire(1);
        }
    
        @Override
        public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
            return sync.tryAcquireNanos(1, unit.toNanos(timeout));
        }
    
        @Override
        public void unlock() {
            sync.release(1);
        }
    
        @Override
        public Condition newCondition() {
            return sync.newCondition();
        }
    
    
        private static volatile Integer value = 0;
    
        public static void main(String[] args) {
    
            MyLock myLock = new MyLock();
            for (int i = 0; i < 1000; i++) {
                new Thread(()->{
                    myLock.lock();
                    value ++;
                    myLock.unlock();
                }).start();
            }
            System.out.println(value);
        }
    }
    

    上面是一個不可重入的鎖,它實現了一個鎖基礎功能,目的是為了跟ReentrantLock的實現做對比;

    ReentrantLock

    ReentrantLock的特點

    ReentrantLock意思為可重入鎖,指的是一個線程能夠對一個臨界資源重複加鎖。ReentrantLock跟常用的Synchronized進行比較;

    Synchronized的基礎用法

    Synchronized的分析可以參考《深入剖析synchronized關鍵詞》,ReentrantLock可以創建公平鎖、也可以創建非公平鎖,接下來看一下ReentrantLock的簡單用法,非公平鎖實現比較簡單,今天重點是公平鎖;

    public class ReentrantLockTest {
    
        public static void main(String[] args) {
            ReentrantLock reentrantLock = new ReentrantLock(true);
            reentrantLock.lock();
            try {
                log.info("lock");
            } catch (Exception e) {
                log.error(e);
            } finally {
                reentrantLock.unlock();
                log.info("unlock");
            }
        }
    }
    

    ReentrantLock與AQS的關聯

    先看一下加鎖方法lock

    • 非公平鎖lock方法

      compareAndSetState很好理解,通過CAS加鎖,如果加鎖失敗調用acquire;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    
    • 公平鎖lock方法
    final void lock() {
        acquire(1);
    }
    
    • AQS框架的處理流程

    ​ 線程繼續等待,仍然保留獲取鎖的可能,獲取鎖流程仍在繼續,分析實現原理

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    

    總結:公平鎖的上鎖是必須判斷自己是不是需要排隊;而非公平鎖是直接進行CAS修改計數器看能不能加鎖成功;如果加鎖不成功則乖乖排隊(調用acquire);所以不管公平還是不公平;只要進到了AQS隊列當中那麼他就會排隊;

    AQS架構圖

    美團畫的AQS的架構圖,很詳細,當有自定義同步器接入時,只需重寫第一層所需要的部分方法即可,不需要關注底層具體的實現流程。當自定義同步器進行加鎖或者解鎖操作時,先經過第一層的API進入AQS內部方法,然後經過第二層進行鎖的獲取,接着對於獲取鎖失敗的流程,進入第三層和第四層的等待隊列處理,而這些處理方式均依賴於第五層的基礎數據提供層。

    AQS核心思想是,如果被請求的共享資源空閑,那麼就將當前請求資源的線程設置為有效的工作線程,將共享資源設置為鎖定狀態;如果共享資源被佔用,就需要一定的阻塞等待喚醒機制來保證鎖分配。這個機制主要用的是CLH隊列的變體實現的,將暫時獲取不到鎖的線程加入到隊列中。

    CLH:Craig、Landin and Hagersten隊列,是單向鏈表,AQS中的隊列是CLH變體的虛擬雙向隊列(FIFO),AQS是通過將每條請求共享資源的線程封裝成一個節點來實現鎖的分配。

    • 非公平鎖的加鎖流程
    • 公平鎖的加鎖流程
    • 解鎖公平鎖和非公平鎖邏輯一致

    加鎖:

    • 通過ReentrantLock的加鎖方法Lock進行加鎖操作。
    • 會調用到內部類Sync的Lock方法,由於Sync#lock是抽象方法,根據ReentrantLock初始化選擇的公平鎖和非公平鎖,執行相關內部類的Lock方法,本質上都會執行AQS的Acquire方法。
    • AQS的Acquire方法會執行tryAcquire方法,但是由於tryAcquire需要自定義同步器實現,因此執行了ReentrantLock中的tryAcquire方法,由於ReentrantLock是通過公平鎖和非公平鎖內部類實現的tryAcquire方法,因此會根據鎖類型不同,執行不同的tryAcquire。
    • tryAcquire是獲取鎖邏輯,獲取失敗后,會執行框架AQS的後續邏輯,跟ReentrantLock自定義同步器無關。
    • 流程:Lock -> acquire -> tryAcquire( or nonfairTryAcquire)

    解鎖:

    • 通過ReentrantLock的解鎖方法Unlock進行解鎖。
    • Unlock會調用內部類Sync的Release方法,該方法繼承於AQS。
    • Release中會調用tryRelease方法,tryRelease需要自定義同步器實現,tryRelease只在ReentrantLock中的Sync實現,因此可以看出,釋放鎖的過程,並不區分是否為公平鎖。
    • 釋放成功后,所有處理由AQS框架完成,與自定義同步器無關。
    • 流程:unlock -> release -> tryRelease

    acquire獲取鎖

    public final void acquire(int arg) {
        if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
            selfInterrupt();
        }
    }
    

    tryAcquire

    acquire方法首先會調tryAcquire方法,需要注意的是tryAcquire的結果做取反;根據前面分析,tryAcquire會調用子類的實現,ReentrantLock有兩個內部類,FairSync,NonfairSync,都繼承自Sync,Sync繼承AbstractQueuedSynchronizer;

    實現方式差別在是否有hasQueuedPredecessors() 的判斷條件

    • 公平鎖實現
    /**
     * Fair version of tryAcquire.  Don't grant access unless
     * recursive call or no waiters or is first.
     */
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        // 獲取lock對象的上鎖狀態,如果鎖是自由狀態則=0,如果被上鎖則為1,大於1表示重入  
        int c = getState();
        if (c == 0) {
          	// hasQueuedPredecessors,判斷自己是否需要排隊
            // 下面我會單獨介紹,如果不需要排隊則進行cas嘗試加鎖
            // 如果加鎖成功則把當前線程設置為擁有鎖的線程
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
      	// 如果C不等於0,但是當前線程等於擁有鎖的線程則表示這是一次重入,那麼直接把狀態+1表示重入次數+1
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
    

    非公平鎖

    /**
     * Performs non-fair tryLock.  tryAcquire is implemented in
     * subclasses, but both need nonfair try for trylock method.
     */
    final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
    

    hasQueuedPredecessors

    public final boolean hasQueuedPredecessors() {
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }
    
    • Node

    先來看下AQS中最基本的數據結構——Node,Node即為上面CLH變體隊列中的節點。

    static final class Node {
        static final Node SHARED = new Node(); // 表示線程以共享的模式等待鎖
        static final Node EXCLUSIVE = null; // 表示線程正在以獨佔的方式等待鎖
        static final int CANCELLED =  1; // 表示線程獲取鎖的請求已經取消了
        static final int SIGNAL    = -1; // 表示線程已經準備好了,就等資源釋放了
        static final int CONDITION = -2; // 表示節點在等待隊列中,節點線程等待喚醒
        static final int PROPAGATE = -3; // 當前線程處在SHARED情況下,該字段才會使用
        volatile int waitStatus; // 當前節點在隊列中的狀態
        volatile Node prev; // 前驅指針
        volatile Node next; // 後繼指針
        volatile Thread thread; // 表示處於該節點的線程
        Node nextWaiter; // 指向下一個處於CONDITION狀態的節點
        final boolean isShared() {
            return nextWaiter == SHARED;
        }
        // 返回前驅節點,沒有的話拋出npe
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }
        Node() {    // Used to establish initial head or SHARED marker
        }
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }
        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }
    

    再看hasQueuedPredecessors,整個方法如果最後返回false,則去加鎖,如果返回true則不加鎖,因為這個方法被取反操作;hasQueuedPredecessors是公平鎖加鎖時判斷等待隊列中是否存在有效節點的方法。如果返回False,說明當前線程可以爭取共享資源;如果返回True,說明隊列中存在有效節點,當前線程必須加入到等待隊列中。

    • h != t && ((s = h.next) == null || s.thread != Thread.currentThread());

    雙向鏈表中,第一個節點為虛節點,其實並不存儲任何信息,只是佔位。真正的第一個有數據的節點,是在第二個節點開始的。

    • 當h != t時: 如果(s = h.next) == null,等待隊列正在有線程進行初始化,但只是進行到了Tail指向Head,沒有將Head指向Tail,此時隊列中有元素,需要返回True(這塊具體見下邊代碼分析)。
    • 如果(s = h.next) != null,說明此時隊列中至少有一個有效節點。
    • 如果此時s.thread == Thread.currentThread(),說明等待隊列的第一個有效節點中的線程與當前線程相同,那麼當前線程是可以獲取資源的;
    • 如果s.thread != Thread.currentThread(),說明等待隊列的第一個有效節點線程與當前線程不同,當前線程必須加入進等待隊列。

    如果這上面沒有看懂,沒有關係,先來分析一下構建整個隊列的過程;

    • addWaiter(Node.EXCLUSIVE)
    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        // tail為對尾,賦值給pred
        Node pred = tail;
        // 判斷pred是否為空,其實就是判斷對尾是否有節點,其實只要隊列被初始化了對尾肯定不為空
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
    
    • enq
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    

    用一張圖來分析一下,整個隊列構建過程;

    • (1)通過Node(Thread thread, Node mode) 方法構建一個node節點(node2),此時的nextWaiter為空,線程不為空,是當前線程;

    • (2)如果隊尾為空,則說明隊列未建立,調用enq構建第一個虛擬節點(node1),通過compareAndSetHead方法構建一個頭節點,需要注意的是該頭節點thread是null,後續很多都是用線程是否為null來判讀是否為第一個虛擬節點;

    • (3)將node1 cas設置為head

    • (4)將頭節點賦值為tail = head

    • (5)進入下一次for循環時,會走到else分支,會將傳入的node的指向頭部節點的next,此時node2的prev指向node1(tail)

    • (6)將node2 cas設置為tail;

    • (7)將node2指向node1的next;

      經過上面的步驟,就構建了一個長度為2的隊列;

    添加第二個隊列時,走的是這段代碼,流程就簡單多了,代碼如下

    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    

    再看一下h != t && ((s = h.next) == null || s.thread != Thread.currentThread());因為整個構建過程並不是原子操作,所以這個條件判斷,現在再是不是就看明白了?

    • 當h != t時(3)步驟已經完成: 如果(s = h.next) == null 此時步驟(4)未完成,等待隊列正在有線程進行初始化,但只是進行到了Tail指向Head,沒有將Head指向Tail,此時隊列中有元素,需要返回True
    • 如果(s = h.next) != null,說明此時隊列中至少有一個有效節點。
    • 如果此時s.thread == Thread.currentThread(),說明等待隊列的第一個有效節點中的線程與當前線程相同,那麼當前線程是可以獲取資源的;
    • 如果s.thread != Thread.currentThread(),說明等待隊列的第一個有效節點線程與當前線程不同,當前線程必須加入進等待隊列。

    acquireQueued

    addWaiter方法其實就是把對應的線程以Node的數據結構形式加入到雙端隊列里,返回的是一個包含該線程的Node。而這個Node會作為參數,進入到acquireQueued方法中。acquireQueued方法可以對排隊中的線程進行“獲鎖”操作。總的來說,一個線程獲取鎖失敗了,被放入等待隊列,acquireQueued會把放入隊列中的線程不斷去獲取鎖,直到獲取成功或者不再需要獲取(中斷)。

    下面通過代碼從“何時出隊列?”和“如何出隊列?”兩個方向來分析一下acquireQueued源碼:

    final boolean acquireQueued(final Node node, int arg) {
        // 標記是否成功拿到資源
        boolean failed = true;
        try {
            // 標記等待過程中是否中斷過
            boolean interrupted = false;
            for (;;) {
                // 獲取當前節點的前驅節點,有兩種情況;1、上一個節點為頭部;2上一個節點不為頭部
                final Node p = node.predecessor();
                // 如果p是頭結點,說明當前節點在真實數據隊列的首部,就嘗試獲取鎖(頭結點是虛節點)
                // 因為第一次tryAcquire判斷是否需要排隊,如果需要排隊,那麼我就入隊,此處再重試一次
                if (p == head && tryAcquire(arg)) {
                    // 獲取鎖成功,頭指針移動到當前node
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 說明p為頭節點且當前沒有獲取到鎖(可能是非公平鎖被搶佔了)或者是p不為頭結點,這個時候就要判斷當前node是否要被阻塞(被阻塞條件:前驅節點的waitStatus為-1),防止無限循環浪費資源。具體兩個方法下面細細分析
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
           // 成功拿到資源,準備釋放
            if (failed)
                cancelAcquire(node);
        }
    }
    

    setHead

    設置當前節點為頭節點,並且將node.thread為空(剛才提到判斷是否為頭部虛擬節點的條件就是node.thread == null。waitStatus狀態併為修改,等下我們再分析;

    private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
    }
    

    shouldParkAfterFailedAcquire

    接下來看shouldParkAfterFailedAcquire代碼,需要注意的是,每一個新創建Node的節點是被下一個排隊的node設置為等待狀態為SIGNAL, 這裏比較難以理解為什麼需要去改變上一個節點的park狀態?

    每個node都有一個狀態,默認為0,表示無狀態,-1表示在park;當時不能自己把自己改成-1狀態?因為你得確定你自己park了才是能改為-1;所以只能先park;在改狀態;但是問題你自己都park了;完全釋放CPU資源了,故而沒有辦法執行任何代碼了,所以只能別人來改;故而可以看到每次都是自己的后一個節點把自己改成-1狀態;

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 獲取前驅節點的狀態 
        int ws = pred.waitStatus;
        // 說明頭結點處於喚醒狀態
        if (ws == Node.SIGNAL)
            return true;
        // static final int CANCELLED =  1; // 表示線程獲取鎖的請求已經取消了
        // static final int SIGNAL    = -1; // 表示線程已經準備好了,就等資源釋放了
        // static final int CONDITION = -2; // 表示節點在等待隊列中,節點線程等待喚醒
        // static final int PROPAGATE = -3; // 當前線程處在SHARED情況下,該字段才會使用
        if (ws > 0) {
            do {
                // 把取消節點從隊列中剔除
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 設置前任節點等待狀態為SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }
    

    parkAndCheckInterrupt

    調用LockSupport.park掛起當前線程,自己已經park,無法再修改狀態了!

    private final boolean parkAndCheckInterrupt() {
        // 調⽤用park()使線程進⼊入waiting狀態
        LockSupport.park(this);
        // 如果被喚醒,查看⾃自⼰己是不不是被中斷的,這⾥里里先清除⼀下標記位
        return Thread.interrupted(); 
    }
    

    shouldParkAfterFailedAcquire的整個流程還是比較清晰的,如果不清楚,可以參考美團畫的流程圖;

    cancelAcquire

    通過上面的分析,當failed為true時,也就意味着park結束,線程被喚醒了,for循環已經跳出,開始執行cancelAcquire,通過cancelAcquire方法,將Node的狀態標記為CANCELLED;代碼如下:

    private void cancelAcquire(Node node) {
        // 將無效節點過濾
        if (node == null)
            return;
        // 設置該節點不關聯任何線程,也就是虛節點(上面已經提到,node.thread = null是判讀是否是頭節點的條件)
        node.thread = null;
        Node pred = node.prev;
        // 通過前驅節點,處理waitStatus > 0的node
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
        // 把當前node的狀態設置為CANCELLED,當下一個node排隊結束時,自己就會被上一行代碼處理掉;
        Node predNext = pred.next;
        node.waitStatus = Node.CANCELLED;
        // 如果當前節點是尾節點,將從后往前的第一個非取消狀態的節點設置為尾節點,更新失敗的話,則進入else,如果更新成功,將tail的後繼節點設置為null
        if (node == tail && compareAndSetTail(node, pred)) {
            // 把自己設置為null
            compareAndSetNext(pred, predNext, null);
        } else {
            int ws;
            // 如果當前節點不是head的後繼節點
            // 1:判斷當前節點前驅節點的是否為SIGNAL
            // 2:如果不是,則把前驅節點設置為SINGAL看是否成功
            // 如果1和2中有一個為true,再判斷當前節點的線程是否為null
            // 如果上述條件都滿足,把當前節點的前驅節點的後繼指針指向當前節點的後繼節點 
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
                // 如果當前節點是head的後繼節點,或者上述條件不滿足,那就喚醒當前節點的後繼節點
                unparkSuccessor(node);
            }
            node.next = node; // help GC
        }
    }
    

    當前的流程:

    • 獲取當前節點的前驅節點,如果前驅節點的狀態是CANCELLED,那就一直往前遍歷,找到第一個waitStatus <= 0的節點,將找到的Pred節點和當前Node關聯,將當前Node設置為CANCELLED。

    • 根據當前節點的位置,考慮以下三種情況:

      (1) 當前節點是尾節點。

      (2) 當前節點是Head的後繼節點。

      (3) 當前節點不是Head的後繼節點,也不是尾節點。

    (1)當前節點時尾節點

    (2)當前節點是Head的後繼節點。

    這張圖描述的是這段代碼:unparkSuccessor

    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    

    (3)當前節點不是Head的後繼節點,也不是尾節點。

    這張圖描述的是這段代碼跟(2)一樣;

    通過上面的圖,你會發現所有的變化都是對Next指針進行了操作,而沒有對Prev指針進行操作,原因是執行cancelAcquire的時候,當前節點的前置節點可能已經從隊列中出去了(已經執行過Try代碼塊中的shouldParkAfterFailedAcquire方法了),也就是下圖中代碼1和代碼2直接的間隙就會出現這種情況,此時修改Prev指針,有可能會導致Prev指向另一個已經移除隊列的Node,因此這塊變化Prev指針不安全。

    unlock解鎖

    解鎖時並不區分公平和不公平,因為ReentrantLock實現了鎖的可重入,可以進一步的看一下時如何處理的,上代碼:

    public void unlock() {
        sync.release(1);
    }
    

    release

    public final boolean release(int arg) {
        // 自定義的tryRelease如果返回true,說明該鎖沒有被任何線程持有
        if (tryRelease(arg)) {
            // 獲取頭結點
            Node h = head;
            if (h != null && h.waitStatus != 0)
                // 頭結點不為空並且頭結點的waitStatus不是初始化節點情況,解除線程掛起狀態
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
    

    這裏的判斷條件為什麼是h != null && h.waitStatus != 0

    1. h == null Head還沒初始化。初始情況下,head == null,第一個節點入隊,Head會被初始化一個虛擬節點。如果還沒來得及入隊,就會出現head == null 的情況。
    2. h != null && waitStatus == 0 表明後繼節點對應的線程仍在運行中,不需要喚醒。
    3. h != null && waitStatus < 0 表明後繼節點可能被阻塞了,需要喚醒,(還記得一個node是在shouldParkAfterFailedAcquire方法中被設置為SIGNAL = -1的吧?不記得翻看一下上面吧)

    tryRelease

    protected final boolean tryRelease(int releases) {
        // 減少可重入次數,setState(c);
        int c = getState() - releases;
        // 當前線程不是持有鎖的線程,拋出異常
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        // 如果持有線程全部釋放,將當前獨佔鎖所有線程設置為null,並更新state
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);
        return free;
    }
    

    unparkSuccessor

    這個方法在cancelAcquire其實也用到了,簡單分析一下

    // 如果當前節點是head的後繼節點,或者上述條件不滿足,就喚醒當前節點的後繼節點unparkSuccessor(node);

    private void unparkSuccessor(Node node) {
        // 獲取結點waitStatus,CAS設置狀態state=0
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        // 獲取當前節點的下一個節點
        Node s = node.next;
        // 如果下個節點是null或者下個節點被cancelled,就找到隊列最開始的非cancelled的節點
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 就從尾部節點開始找,到隊首,找到隊列第一個waitStatus<0的節點。
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        // 如果當前節點的下個節點不為空,而且狀態<=0,就把當前節點unpark
        if (s != null)
            LockSupport.unpark(s.thread);
    }
    

    為什麼要從后往前找第一個非Cancelled的節點呢?

    原因1:addWaiter方法並非原子,構建鏈表結構時如下圖中 1、2間隙執行unparkSuccessor,此時鏈表是不完整的,沒辦法從前往後找了;

    原因2:還有一點原因,在產生CANCELLED狀態節點的時候,先斷開的是Next指針,Prev指針並未斷開,因此也是必須要從后往前遍歷才能夠遍歷完全部的Node;

    中斷恢復

    喚醒后,會執行return Thread.interrupted();,這個函數返回的是當前執行線程的中斷狀態,並清除。

    private final boolean parkAndCheckInterrupt() {
    	LockSupport.park(this);
    	return Thread.interrupted();
    }
    

    acquireQueued代碼,當parkAndCheckInterrupt返回True或者False的時候,interrupted的值不同,但都會執行下次循環。如果這個時候獲取鎖成功,就會把當前interrupted返回。

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    
    final boolean acquireQueued(final Node node, int arg) {
    	boolean failed = true;
    	try {
    		boolean interrupted = false;
    		for (;;) {
    			final Node p = node.predecessor();
    			if (p == head && tryAcquire(arg)) {
    				setHead(node);
    				p.next = null; // help GC
    				failed = false;
    				return interrupted;
    			}
    			if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
    				interrupted = true;
    			}
    	} finally {
    		if (failed)
    			cancelAcquire(node);
    	}
    }
    

    如果acquireQueued為True,就會執行selfInterrupt方法。

    該方法其實是為了中斷線程。但為什麼獲取了鎖以後還要中斷線程呢?這部分屬於Java提供的協作式中斷知識內容,感興趣同學可以查閱一下。這裏簡單介紹一下:

    1. 當中斷線程被喚醒時,並不知道被喚醒的原因,可能是當前線程在等待中被中斷,也可能是釋放了鎖以後被喚醒。因此我們通過Thread.interrupted()方法檢查中斷標記(該方法返回了當前線程的中斷狀態,並將當前線程的中斷標識設置為False),並記錄下來,如果發現該線程被中斷過,就再中斷一次。
    2. 線程在等待資源的過程中被喚醒,喚醒后還是會不斷地去嘗試獲取鎖,直到搶到鎖為止。也就是說,在整個流程中,並不響應中斷,只是記錄中斷記錄。最後搶到鎖返回了,那麼如果被中斷過的話,就需要補充一次中斷。

    這裏的處理方式主要是運用線程池中基本運作單元Worder中的runWorker,通過Thread.interrupted()進行額外的判斷處理,可以看下ThreadPoolExecutor源碼的判斷條件;

    其它

    AQS在JUC中有⽐比較⼴廣泛的使⽤用,以下是主要使⽤用的地⽅方:

    • ReentrantLock:使⽤用AQS保存鎖重複持有的次數。當⼀一個線程獲取鎖時, ReentrantLock記錄當
      前獲得鎖的線程標識,⽤用於檢測是否重複獲取,以及錯誤線程試圖解鎖操作時異常情況的處理理。
    • Semaphore:使⽤用AQS同步狀態來保存信號量量的當前計數。 tryRelease會增加計數,
      acquireShared會減少計數。
    • CountDownLatch:使⽤用AQS同步狀態來表示計數。計數為0時,所有的Acquire操作
      (CountDownLatch的await⽅方法)才可以通過。
    • ReentrantReadWriteLock:使⽤用AQS同步狀態中的16位保存寫鎖持有的次數,剩下的16位⽤用於保
      存讀鎖的持有次數。
    • ThreadPoolExecutor: Worker利利⽤用AQS同步狀態實現對獨佔線程變量量的設置(tryAcquire和
      tryRelease)。

    至此,通過ReentrantLock分析AQS的實現原理一家完畢,需要說明的是,此文深度參考了美團分析的ReentrantLock,是參考鏈接的第三個,有興趣可以對比差異,感謝!

    參考

    JDK API 文檔

    Java的LockSupport.park()實現分析

    [從ReentrantLock的實現看AQS的原理及應用

    [Thread的中斷機制(interrupt)

    你的鼓勵也是我創作的動力

    打賞地址

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※回頭車貨運收費標準

  • 【Spring註解驅動開發】自定義TypeFilter指定@ComponentScan註解的過濾規則

    寫在前面

    Spring的強大之處不僅僅是提供了IOC容器,能夠通過過濾規則指定排除和只包含哪些組件,它還能夠通過自定義TypeFilter來指定過濾規則。如果Spring內置的過濾規則不能夠滿足我們的需求,那麼我們就可以通過自定義TypeFilter來實現我們自己的過濾規則。

    項目工程源碼已經提交到GitHub:https://github.com/sunshinelyz/spring-annotation

    FilterType中常用的規則

    在使用@ComponentScan註解實現包掃描時,我們可以使用@Filter指定過濾規則,在@Filter中,通過type指定過濾的類型。而@Filter註解的type屬性是一個FilterType枚舉,如下所示。

    package org.springframework.context.annotation;
    
    public enum FilterType {
    	ANNOTATION,
    	ASSIGNABLE_TYPE,
    	ASPECTJ,
    	REGEX,
    	CUSTOM
    }
    

    每個枚舉值的含義如下所示。

    (1)ANNOTATION:按照註解進行過濾。

    例如,使用@ComponentScan註解進行包掃描時,按照註解只包含標註了@Controller註解的組件,如下所示。

    @ComponentScan(value = "io.mykit.spring", includeFilters = {
        @Filter(type = FilterType.ANNOTATION, classes = {Controller.class})
    }, useDefaultFilters = false)
    

    (2)ASSIGNABLE_TYPE:按照給定的類型進行過濾。

    例如,使用@ComponentScan註解進行包掃描時,按照給定的類型只包含PersonService類(接口)或其子類(實現類或子接口)的組件,如下所示。

    @ComponentScan(value = "io.mykit.spring", includeFilters = {
        @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {PersonService.class})
    }, useDefaultFilters = false)
    

    此時,只要是PersonService類型的組件,都會被加載到容器中。也就是說:當PersonService是一個Java類時,Person類及其子類都會被加載到Spring容器中;當PersonService是一個接口時,其子接口或實現類都會被加載到Spring容器中。

    (3)ASPECTJ:按照ASPECTJ表達式進行過濾

    例如,使用@ComponentScan註解進行包掃描時,按照ASPECTJ表達式進行過濾,如下所示。

    @ComponentScan(value = "io.mykit.spring", includeFilters = {
        @Filter(type = FilterType.ASPECTJ, classes = {AspectJTypeFilter.class})
    }, useDefaultFilters = false)
    

    (4)REGEX:按照正則表達式進行過濾

    例如,使用@ComponentScan註解進行包掃描時,按照正則表達式進行過濾,如下所示。

    @ComponentScan(value = "io.mykit.spring", includeFilters = {
        @Filter(type = FilterType.REGEX, classes = {RegexPatternTypeFilter.class})
    }, useDefaultFilters = false)
    

    (5)CUSTOM:按照自定義規則進行過濾。

    如果實現自定義規則進行過濾時,自定義規則的類必須是org.springframework.core.type.filter.TypeFilter接口的實現類。

    例如,按照自定義規則進行過濾,首先,我們需要創建一個org.springframework.core.type.filter.TypeFilter接口的實現類MyTypeFilter,如下所示。

    public class MyTypeFilter implements TypeFilter {
        @Override
        public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
            return false;
        }
    }
    

    當我們實現TypeFilter接口時,需要實現TypeFilter接口中的match()方法,match()方法的返回值為boolean類型。當返回true時,表示符合規則,會包含在Spring容器中;當返回false時,表示不符合規則,不會包含在Spring容器中。另外,在match()方法中存在兩個參數,分別為MetadataReader類型的參數和MetadataReaderFactory類型的參數,含義分別如下所示。

    • metadataReader:讀取到的當前正在掃描的類的信息。
    • metadataReaderFactory:可以獲取到其他任務類的信息。

    接下來,使用@ComponentScan註解進行如下配置。

    @ComponentScan(value = "io.mykit.spring", includeFilters = {
        @Filter(type = FilterType.CUSTOM, classes = {MyTypeFilter.class})
    }, useDefaultFilters = false)
    

    在FilterType枚舉中,ANNOTATION和ASSIGNABLE_TYPE是比較常用的,ASPECTJ和REGEX不太常用,如果FilterType枚舉中的類型無法滿足我們的需求時,我們也可以通過實現org.springframework.core.type.filter.TypeFilter接口來自定義過濾規則,此時,將@Filter中的type屬性設置為FilterType.CUSTOM,classes屬性設置為自定義規則的類對應的Class對象。

    實現自定義過濾規則

    在項目的io.mykit.spring.plugins.register.filter包下新建MyTypeFilter,並實現org.springframework.core.type.filter.TypeFilter接口。此時,我們先在MyTypeFilter類中打印出當前正在掃描的類名,如下所示。

    package io.mykit.spring.plugins.register.filter;
    
    import org.springframework.core.io.Resource;
    import org.springframework.core.type.AnnotationMetadata;
    import org.springframework.core.type.ClassMetadata;
    import org.springframework.core.type.classreading.MetadataReader;
    import org.springframework.core.type.classreading.MetadataReaderFactory;
    import org.springframework.core.type.filter.TypeFilter;
    
    import java.io.IOException;
    
    /**
     * @author binghe
     * @version 1.0.0
     * @description 自定義過濾規則
     */
    public class MyTypeFilter implements TypeFilter {
        /**
         * metadataReader:讀取到的當前正在掃描的類的信息
         * metadataReaderFactory:可以獲取到其他任務類的信息
         */
        @Override
        public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
            //獲取當前類註解的信息
            AnnotationMetadata annotationMetadata = metadataReader.getAnnotationMetadata();
            //獲取當前正在掃描的類的信息
            ClassMetadata classMetadata = metadataReader.getClassMetadata();
            //獲取當前類的資源信息,例如:類的路徑等信息
            Resource resource = metadataReader.getResource();
            //獲取當前正在掃描的類名
            String className = classMetadata.getClassName();
            //打印當前正在掃描的類名
            System.out.println("-----> " + className);
            return false;
        }
    }
    

    接下來,我們在PersonConfig類中配置自定義過濾規則,如下所示。

    @Configuration
    @ComponentScans(value = {
            @ComponentScan(value = "io.mykit.spring", includeFilters = {
                    @Filter(type = FilterType.CUSTOM, classes = {MyTypeFilter.class})
            }, useDefaultFilters = false)
    })
    public class PersonConfig {
    
        @Bean("person")
        public Person person01(){
            return new Person("binghe001", 18);
        }
    }
    

    接下來,我們運行SpringBeanTest類中的testComponentScanByAnnotation()方法進行測試,輸出的結果信息如下所示。

    -----> io.mykit.spring.test.SpringBeanTest
    -----> io.mykit.spring.bean.Person
    -----> io.mykit.spring.plugins.register.controller.PersonController
    -----> io.mykit.spring.plugins.register.dao.PersonDao
    -----> io.mykit.spring.plugins.register.filter.MyTypeFilter
    -----> io.mykit.spring.plugins.register.service.PersonService
    org.springframework.context.annotation.internalConfigurationAnnotationProcessor
    org.springframework.context.annotation.internalAutowiredAnnotationProcessor
    org.springframework.context.annotation.internalCommonAnnotationProcessor
    org.springframework.context.event.internalEventListenerProcessor
    org.springframework.context.event.internalEventListenerFactory
    personConfig
    person
    

    可以看到,已經輸出了當前正在掃描的類的名稱,同時,除了Spring內置的bean名稱外,只輸出了personConfig和person,沒有輸出使用@Repository、@Service、@Controller註解標註的組件名稱。這是因為當前PersonConfig上標註的@ComponentScan註解是使用自定義的規則,而在MyTypeFilter自定義規則的實現類中,直接返回了false值,將所有的bean都排除了。

    我們可以在MyTypeFilter類中簡單的實現一個規則,例如,當前掃描的類名稱中包含有字符串Person,就返回true,否則返回false。此時,MyTypeFilter類中match()方法的實現如下所示。

        @Override
        public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
            //獲取當前類註解的信息
            AnnotationMetadata annotationMetadata = metadataReader.getAnnotationMetadata();
            //獲取當前正在掃描的類的信息
            ClassMetadata classMetadata = metadataReader.getClassMetadata();
            //獲取當前類的資源信息,例如:類的路徑等信息
            Resource resource = metadataReader.getResource();
            //獲取當前正在掃描的類名
            String className = classMetadata.getClassName();
            //打印當前正在掃描的類名
            System.out.println("-----> " + className);
            return className.contains("Person");
        }
    

    此時,在io.mykit.spring包下的所有類都會通過MyTypeFilter類的match()方法,來驗證類名是否包含Person,如果包含則返回true,否則返回false。

    我們再次運行SpringBeanTest類中的testComponentScanByAnnotation()方法進行測試,輸出的結果信息如下所示。

    -----> io.mykit.spring.test.SpringBeanTest
    -----> io.mykit.spring.bean.Person
    -----> io.mykit.spring.plugins.register.controller.PersonController
    -----> io.mykit.spring.plugins.register.dao.PersonDao
    -----> io.mykit.spring.plugins.register.filter.MyTypeFilter
    -----> io.mykit.spring.plugins.register.service.PersonService
    org.springframework.context.annotation.internalConfigurationAnnotationProcessor
    org.springframework.context.annotation.internalAutowiredAnnotationProcessor
    org.springframework.context.annotation.internalCommonAnnotationProcessor
    org.springframework.context.event.internalEventListenerProcessor
    org.springframework.context.event.internalEventListenerFactory
    personConfig
    person
    personController
    personDao
    personService
    

    此時,結果信息中輸出了使用@Repository、@Service、@Controller註解標註的組件名稱,分別為:personDao、personService和personController。

    好了,咱們今天就聊到這兒吧!別忘了給個在看和轉發,讓更多的人看到,一起學習一起進步!!

    項目工程源碼已經提交到GitHub:https://github.com/sunshinelyz/spring-annotation

    寫在最後

    如果覺得文章對你有點幫助,請微信搜索並關注「 冰河技術 」微信公眾號,跟冰河學習Spring註解驅動開發。公眾號回復“spring註解”關鍵字,領取Spring註解驅動開發核心知識圖,讓Spring註解驅動開發不再迷茫。

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※超省錢租車方案

    ※教你寫出一流的銷售文案?

    網頁設計最專業,超強功能平台可客製化

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    台中搬家遵守搬運三大原則,讓您的家具不再被破壞!