kubernetes-client go基础实践

准备工作

1. 检查k8s的版本

[root@k8s-node1 ~]# kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.5", GitCommit:"aea7bbadd2fc0cd689de94a54e5b7b758869d691", GitTreeState:"clean", BuildDate:"2021-09-15T21:10:45Z", GoVersion:"go1.16.8", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.5", GitCommit:"aea7bbadd2fc0cd689de94a54e5b7b758869d691", GitTreeState:"clean", BuildDate:"2021-09-15T21:04:16Z", GoVersion:"go1.16.8", Compiler:"gc", Platform:"linux/amd64"}

2. 选择和kuberntes适配的client-go版本
client-go的源码位置:https://github.com/kubernetes/client-go.git

ps:This repository is still a mirror of k8s.io/kubernetes/staging/src/client-go, the code development is still done in the staging area.
client-go是k8s.io/kubernetes/staging/src/client-go的一个镜像,该仓库下的代码开发工作仍旧是在staging下完成的

由于我们的Kubernetes版本为v1.21.5,所以适配的client-go版本也为v0.1.25,关于如何适配,官方这样描述:

  • If you are using Kubernetes versions >= v1.17.0, use a corresponding v0.x.y tag. For example, k8s.io/client-go@v0.20.4 corresponds to Kubernetes v1.20.4:
    go get k8s.io/client-go@v0.20.4
  • If you are using Kubernetes versions < v1.17.0, use a corresponding kubernetes-1.x.y tag. For example, k8s.io/client-go@kubernetes-1.16.3 corresponds to Kubernetes v1.16.3:
    go get k8s.io/client-go@kubernetes-1.16.3

3. 到kubernets集群中拿到kubeconfig文件

关于client-go的运行方式,它可以运行在集群内的pod内,也可也运行在集群外

  • 在集群内运行时,需要将应用打成镜像,部署到集群内;client-go在调用“rest.InClusterConfig() ”将使用pod内“/var/run/secrets/kubernetes.io/serviceaccount”目录下的token文件做集群内的访问认证,如下面是某个container中该目录下的内容:

  • 在集群外部运行时,需要使用到kubeconfig文件,它包含了集群初始化一个client的信息,该文件也被kubectl命令用作访问集群时的身份认证

这里我们选择在集群之外运行

Authenticating outside the cluster

This example shows you how to configure a client with client-go to authenticate to the Kubernetes API from an application running outside the Kubernetes cluster.

You can use your kubeconfig file that contains the context information of your cluster to initialize a client. The kubeconfig file is also used by the kubectl command to authenticate to the clusters.

登录到kuberntes集群,取得/root/.kube/config文件,将文件下载到本地的“C:\Users\Administrator\.kube\”路径下。

点击查看代码
[root@k8s-node1 .kube]# cat config 
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM1ekNDQWMrZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJeU1ERXlNREF5TVRFME1Gb1hEVE15TURFeE9EQXlNVEUwTUZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTGhYCkEyY1B3T0Y0R0R0bGN3RFY3UGVOUThYOUd2MnlkRGJCNnhYd2ZJdGtTcGM1Vk50R3E1Q0FyeUtnSDcrdVJnUUkKK2VSeXNaT3Z1eGhoRGR5dmhCSnFhcm54TEJ0dmlTVTViYjlBT3NIUmsveWdHN2ZLbkl1bVp6K1hvSHFGcjAwTQo5ZzNPMXJBeVZuaFFTbmVqcEl6T09nOXlmNDZTN2ZLZi92USt0OWJmSzUySmN1VVlQZHA0b1p2ek5HWlc3MGdnCjFFbGRQMDJZYk81T1hBbjhzK2l3MG1JWm50QU1CU0pMT2x1eUkrK08remF0YTEvTStXNlc2YXRoT1Eyd0EyR3QKVm56bFpaVXZIRmJkVlFBNThBN2JmRWs3M24yVzdNZmlYMHdYKzRDdHZoNGJpaUNldkRoc3FXOHp6S0dZam5tNAp5dWZ5MTRXM3IwUFluNk5zR2k4Q0F3RUFBYU5DTUVBd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0hRWURWUjBPQkJZRUZHSE9saWdkVUdCUlhodGxsL2xqRzY3S0FSQk5NQTBHQ1NxR1NJYjMKRFFFQkN3VUFBNElCQVFDdEt5R0pkVVMxUGs0bCs0YldMSUJOaTFWejlzeHFLMURZNmJWdjRXU2JxQmUyWnc0cgo4Mm5URzVKR0hmcEg1dmZ5RlRJR3VKYzNGRnc1dG82VDQ4alhyKzZTTUIxMXZnVUs0Yjl1cWVLU1JHM3RGRVRuCllyNXorZHk4QUFuNHdPUlZpY1RsQlM3OTN3bzVqdnhTMGQ5Vy9tVVJXVEkwSGluQ0grdzBWMXVhT2ljeUxpQTIKQjBIM2dtRmZaNlhmYlZHT2k0TnZXMHdtdnRGUmdpUnJsaC9ndHZ2amtBTVpsb2pyWWhKampuZUl3S1Bla1djYQpzKzEzRnVJdTE5QU11cUdJRW9OQVJFRlZtU3ArSFE2d1RnRmt2MSthbWFJRnowcHlvVFNzQWFaNEYvVXdCT016CmFpTDArTXhqWnZ4bkdHcFVXMUxVZGVtcS9YNlZVOEtMdXRWagotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
    server: https://lb.kubesphere.local:6443  
  name: cluster.local
contexts:
- context:
    cluster: cluster.local
    user: kubernetes-admin
  name: kubernetes-admin@cluster.local
current-context: kubernetes-admin@cluster.local
kind: Config
preferences: {}
users:
- name: kubernetes-admin
  user:
    client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJVENDQWdtZ0F3SUJBZ0lJSURMWXUycmpjd0V3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TWpBeE1qQXdNakV4TkRCYUZ3MHlNekF4TWpBd01qRXhOREZhTURReApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sa3dGd1lEVlFRREV4QnJkV0psY201bGRHVnpMV0ZrCmJXbHVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXh5cTd6MHphN2U3bmhKVnEKNy95ODlza2QyWmpXTUJtdlFWaVdxbFQyT0RtNGpIV3lRbllRYXROVXZHeTZmb1Y5TWpNWnB2Y1RhZWUvZlZWNQo1RE9zSEFhWjhnRzRieWlYaW5EaXU5aGljZTVCbldnR0R0aytOUnY0SXd1aHNXd25CZEgzQVc3eTNtNTd6RmxkCnNHeG1yMzhFdERUd0lVa3ZLazROMTJLRHFoZFNMRWhmVVlTRmRVVHJSV0VCTEtCNkFURGVsYWZiUW1WYnZDbzgKM3ZMeWxSbTB6VWdFVGc3NUZ0NGFkRWVJMjY2RDJZZmYvQnd3Z2w0cDJvSFlKb1h5VSthMnlnK1JWZDJIVzZXcQpVMTJVd2ZlUEpCQ3habnJrZmFCek16ajduK1l3RlF4THFMOFBsN01kc3RDMHdJb3BmLzNOaVp1YWR2TFIwUDVaCkZEWTNHUUlEQVFBQm8xWXdWREFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUgKQXdJd0RBWURWUjBUQVFIL0JBSXdBREFmQmdOVkhTTUVHREFXZ0JSaHpwWW9IVkJnVVY0YlpaZjVZeHV1eWdFUQpUVEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBSzU1QVJ2K1crT1lYaGEzdXVhd2NrbGYzVjEwTkc4NndpRnNJCkU2ejBiamV5dVVuUGxtdTZ0ZEN3Z0VYZFl0TDV4RHp3NG43RXVWRkZuSVZZRFpwM21LYmFtU0tsYm05WmZNKzcKWmtDVFlkdnkwbUcxS2dyR0dRejA4QVVOcUw5ZkR4M1ZSYUw5cWhvd1ozWG81Ykd6MGc5U2lCZGV0NGpuMnBuZQo1eUR1SWh2TE9PbVo1Rm5JTGZGY1pQYjc0RGZqaldnSEc3OGt3KzI2YXRFSWRZVWQxMWhxNk9nSEphaVRsZnRkCnlSNWdsV3JnQWIwT2JlSXk3cFR1bVhteGd1a25JaFhxMUlOZElaZWVwa3l0a3gxZm53ZmxkcGExYTA4RFlCNzMKcVlKVWY3V0o3dERnWFAzUFpnNVNYZVlFZFhLZVNnY1lSc3EwODF4aFBRaE5qQVI0REE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
    client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBeHlxN3owemE3ZTduaEpWcTcveTg5c2tkMlpqV01CbXZRVmlXcWxUMk9EbTRqSFd5ClFuWVFhdE5Vdkd5NmZvVjlNak1acHZjVGFlZS9mVlY1NURPc0hBYVo4Z0c0YnlpWGluRGl1OWhpY2U1Qm5XZ0cKRHRrK05SdjRJd3Voc1d3bkJkSDNBVzd5M201N3pGbGRzR3htcjM4RXREVHdJVWt2S2s0TjEyS0RxaGRTTEVoZgpVWVNGZFVUclJXRUJMS0I2QVREZWxhZmJRbVZidkNvODN2THlsUm0welVnRVRnNzVGdDRhZEVlSTI2NkQyWWZmCi9Cd3dnbDRwMm9IWUpvWHlVK2EyeWcrUlZkMkhXNldxVTEyVXdmZVBKQkN4Wm5ya2ZhQnpNemo3bitZd0ZReEwKcUw4UGw3TWRzdEMwd0lvcGYvM05pWnVhZHZMUjBQNVpGRFkzR1FJREFRQUJBb0lCQUM5R1VYWVBSQmdlRVE1RAplVEtseHlTODhEenFMazBVaTZqeklqQWtJcDJOOWVSQk82TFM2MnF2NXZraXhkR3FWZUZJaDlvOTQzbkw3SVFQCmVmTlU0SkYyYjZ3bUJHVStPVm8vR1ZLRk5qamMyTzVIOXNnbmdNQ1Nkdis2anhMU0hTRWdpWVRwbFBSL1BSdHcKU3ZESmlrWTEzQ1A2UE5WcHphdzVBc2dSTmpkMURnRFc2a0ZkMTRGQ3pxaTdlVmRhLzdaVjltVXNrb1lRaEVtYwp2ZVgyTTJRaXhOQUQvTkorblZzaGlFZExqTU9RTXc3VjNMK1VueHpHNm1qQjlJbXB6RGp0VkluYmZGWW4yekZ6Cmt0c0ljdWtRZWFkd2o3d1ZUL2c2U1NrcURiaWFuR3Bma0tkaTJEK1cvT1FoNTBadzZKSHE4MWtXY2VVMG8zVHoKUlk2YkI1RUNnWUVBOTZ3ZDk2QTV1Y2dwcjZ6L0tyejI5a3pjOVo5RFB3bnFoN0JsUFdWc1RiQkUvZmE0ckNSLwpsSHBhL2RJWFZ4dmpncTdRd0tRREdFcnN1SlRzTnNMczVLUUswdnFxVEZ4d29Rd0ZodlZLbWFMdndJWDlmNTZQCk1SWDVIV1BkYUV3L0JzcnZwdGUySTFtTDIxNVJpTC9BL0VSMVFvR2Y3b2xzNU5oZ21Kcko4K3NDZ1lFQXpkMFoKQ3F5L242Y2syRVUxUFNVa0NJVk15ZWdQVGNjVGdlelFaTjZBb2JJYjIzVXdhSlFDMndFK3l5QmhmMSthdGVDRApkSkRpU0ZBWVFyemJRUFdRSXNYQThWRm5KRzNOeGp0ZkI4OGNLaW1TcUJ3NmNOVVJuYmo2YzVLYzAvMmNyWlcvClFISzJwVDNXTi9HbEdRb1FlN2VmNUFjNndCT1lXS014c0dHM05Bc0NnWUVBbGRxQkkwUEJ5YVBQZlNpNkZ6elEKWEVRemFUWWN0UGFsL2NWLzYvOEM0WnFtazljRTg3cjlxblBCdkZPeXBaVU5PaFBWNE1rYnlrWURKc2VNaUxHMgpMYjBIZzVJQkdrVFFMTkVlUXdNRlNTSXAyQjM2UEk1T2EwKzFNOUFwdGFKMGZBS3JzQkpTZE44SVhRbWJZWmRNCkNCYlBzQmJJRXNiNXFSazJrUDhPOUZNQ2dZRUFwbUtoLzcrNXJTY2huMjdvWmNBa0RJTDRtbVBtSXAzWlJYU0sKeGt3VHVSekVhUzZoYnBUYWJmbm1yN1EvT1B5amhZYXRtTVFWTUE4VVhMUlpuWG9jQWc3Rk1BWDBFRHh6U1ZucApKOTJjVFBPRzVqclNmU21vOEVwMm1ueVFKc0xmSkdsWXg1VXZ6QVJicEtHNUo3Qzd1OUtnOTJOa2Q2UWV5TjAxCnB2S0RhUnNDZ1lFQWdNVk8xa1JkTDJpU2ZLTEl3dSs4SFQydHlXMGp0Y2VDTmlJdEJqdlMwRFA4d2JjMGk3SXkKdFlLNHl1MGdEdEdNZUZ3WXpRa3pMaE1DbzNBSldUbHZjdTdJbVZoUG04bEtWdTRUYThIdFI3L3B1NUNGWnhKMQpEODE5RnlkM3Q3dm51S3grMnY1Q3VYQmlvMTlHZVRoTjB3RkxRSzRnYzlXZmNrUktLanFBQmdnPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
[root@k8s-node1 .kube]# 

**注意:如果应用是部署在ECS设备上,需要添加端口放行规则

创建开发项目

在Goland中创建go module项目“K8s-client-go”,添加依赖:go get k8s.io/client-go@v0.21.5

下面我们主要要查看的就是集群中的namespace

点击查看代码
package main

import (
    "context"
    "flag"
    "fmt"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/util/homedir"
    "k8s.io/klog"
    "path/filepath"
)

func main() {
    var kubeConfig *string
    ctx := context.Background()
    //取得kubeconfig的路径
    if home := homedir.HomeDir();home!= ""{
       kubeConfig=flag.String("kubeConfig",filepath.Join(home,".kube","config"),"absolute path to the kubeconfig file")
    }else {
        kubeConfig=flag.String("kubeConfig","","absolute path to the kubeconfig file")
    }

    flag.Parse()
    config, err := clientcmd.BuildConfigFromFlags("", *kubeConfig)
    if err!=nil{
        klog.Fatal(err)
        return
    }
    clientset, err := kubernetes.NewForConfig(config)
    if err!=nil{
        klog.Fatal(err)
        return
    }
    //获取namespaces信息
    namespaces := clientset.CoreV1().Namespaces()
    namespaceList, err := namespaces.List(ctx, metav1.ListOptions{})
    if err!=nil{
        klog.Fatal(err)
        return
    }
    for _, namespace := range namespaceList.Items {
       fmt.Println(namespace.Name)
    }

}

运行结果:

default
edu
his
his-devops98s4z
kube-node-lease
kube-public
kube-system
kubesphere-controls-system
kubesphere-devops-system
kubesphere-devops-worker
kubesphere-monitoring-federated
kubesphere-monitoring-system
kubesphere-system
mall

这里有一个需要说明的地方,我们在列举namespace时,使用的是“clientset.CoreV1().Namespaces()”,为什么是corev1不是appsv1,这是因为namespaces在api-resouces中定义的位置所决定的:

点击查看代码
[root@k8s-node1 ~]# kubectl api-resources
NAME                     SHORTNAMES   APIVERSION  NAMESPACED   KIND
bindings                              v1          true         Binding
componentstatuses        cs           v1          false        ComponentStatus
configmaps               cm           v1          true         ConfigMap
endpoints                ep           v1          true         Endpoints
events                   ev           v1          true         Event
limitranges              limits       v1          true         LimitRange
namespaces               ns           v1          false        Namespace
nodes                    no           v1          false        Node
persistentvolumeclaims   pvc          v1          true         PersistentVolumeClaim
persistentvolumes        pv           v1          false        PersistentVolume
pods                     po           v1          true         Pod
podtemplates                          v1          true         PodTemplate
replicationcontrollers   rc           v1          true         ReplicationController
resourcequotas           quota        v1          true         ResourceQuota
secrets                               v1          true         Secret
serviceaccounts          sa           v1          true         ServiceAccount
services                 svc          v1          true         Service
...

上面的这些APIVERSION为v1的都是corev1下的资源,但是有些版本中该字段显示为空;它们在Kubernetes仓库中的地址:kubernetes\staging\src\k8s.io\client-go\kubernetes\typed\core\v1

有些时候,若不确定资源在何路径下,可以打印它的日志
namespace

[root@k8s-node1 ~]# kubectl get namespace -v=7 
I0124 17:23:07.560906  590266 loader.go:372] Config loaded from file:  /root/.kube/config
I0124 17:23:07.571697  590266 round_trippers.go:432] GET https://lb.kubesphere.local:6443/api/v1/namespaces?limit=500

daemonset

[root@k8s-node1 ~]#kubectl get daemonset -v=7
I0124 17:24:44.668978  592516 loader.go:372] Config loaded from file:  /root/.kube/config
I0124 17:24:44.679221  592516 round_trippers.go:432] GET https://lb.kubesphere.local:6443/apis/apps/v1/namespaces/default/daemonsets?limit=500

如果想要直接通过API来访问集群中的namespace,需要开启代理

kubectl proxy --port=8080

访问:

curl http://127.0.0.1:8080/api/v1/namespaces

GVR和GVK

GVR:Group Version Resource的简写。它唯一确定了一个HTTP路径,如:在default的命名空间内,有一个

/apis/batch/v1/namespaces/default/jobs的GVR。下图展示jobs资源的GVR形式

不过需要注意是,有些集群范围内的资源,如节点或命名空间自身,它们的路径中就不包含namespaces着一部分

。如nodes GVR的路径可能是api/v1/nodes。另外,上面的$NAMESPACE本身也是一种资源,可以通过/api/v1/namespaces来访问

GVK:Group Version Kind的简写,

(1)每种GVK对应一种Go语言的类型,但是一种GO语言类型可以用于多个不同的GVK。

比如 apps/v1/Deployment 就关联着 K8s 源码里面 http://k8s.io/api/apps/v1 package 中的 Deployment struct

(2)源定义 YAML 文件都需要写:

  • apiVersion:这个就是 GV 。
  • kind:这个就是 K。

根据 GVK K8s 就能找到你到底要创建什么类型的资源,根据你定义的 Spec 创建好资源之后就成为了 Resource,也就是 GVR。GVK/GVR 就是 K8s 资源的坐标,是我们创建/删除/修改/读取资源的基础。

通过YAML文件来创建Deployment

yamls/nginx.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myngx
  namespace: myweb
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 1
  template:
    metadata:
      labels:
        app: nginx
    spec:
      nodeSelector:
        name: a2 #设置成自己的node名称或删除
      containers:
        - name: nginxtest
          image: nginx:1.18-alpine
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80

承接上面的代码:

package main

import (
    "context"
    "flag"
    "io/ioutil"
    v1 "k8s.io/api/apps/v1"
    coreV1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/util/json"
    "k8s.io/apimachinery/pkg/util/yaml"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/util/homedir"
    "k8s.io/klog/v2"
    "path/filepath"
)

func main() {
    var kubeConfig *string
    ctx := context.Background()
    //取得kubeconfig的路径
    if home := homedir.HomeDir();home!= ""{
      kubeConfig=flag.String("kubeConfig",filepath.Join(home,".kube","config"),"absolute path to the kubeconfig file")
    }else {
       kubeConfig=flag.String("kubeConfig","","absolute path to the kubeconfig file")
    }

    flag.Parse()
    config, err := clientcmd.BuildConfigFromFlags("", *kubeConfig)
    if err!=nil{
       klog.Fatal(err)
       return
    }
    clientset, err := kubernetes.NewForConfig(config)
    if err!=nil{
       klog.Fatal(err)
       return
    }

    createDeploymentFromYaml(clientset,ctx)

}

func createDeploymentFromYaml(clientSet *kubernetes.Clientset,ctx context.Context)  {

    //创建namespace myweb
    myweb:=&coreV1.Namespace{
        ObjectMeta:metav1.ObjectMeta{Name: "myweb"},
    }
    clientSet.CoreV1().Namespaces().Create(ctx, myweb, metav1.CreateOptions{})


    //创建deployment myngx
    deploye:=&v1.Deployment{}
    bytes, err := ioutil.ReadFile("yamls/nginx.yaml")
    if err!= nil{
        klog.Fatal(err)
    }

    toJSON, err := yaml.ToJSON(bytes)

    if err!= nil{
        klog.Fatal(err)
    }

    json.Unmarshal(toJSON,&deploye)
    _, err = clientSet.AppsV1().Deployments("myweb").Create(ctx, deploye, metav1.CreateOptions{})
    if err!= nil{
       klog.Fatal(err)
    }
}

查看:

[root@k8s-node1 ~]# kubectl get all  -n myweb
NAME                        READY   STATUS    RESTARTS   AGE
pod/myngx-d767d4644-4qn44   0/1     Running   0          2m28s

NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/myngx   0/1     1            0           2m28s

NAME                              DESIRED   CURRENT   READY   AGE
replicaset.apps/myngx-d767d4644   1         1         0       2m28s

关于kubernetes的日志级别

--v=0   Generally useful for this to ALWAYS be visible to an operator.
--v=1   A reasonable default log level if you don’t want verbosity.
--v=2   Useful steady state information about the service and important log messages that may correlate to significant changes in the system. This is the recommended default log level for most systems.
--v=3   Extended information about changes.
--v=4   Debug level verbosity.
--v=6   Display requested resources.
--v=7   Display HTTP request headers.
--v=8   Display HTTP request contents

参考连接:

  1. k8s各组件启动时, -v参数指定的日志级别
  2. kubernetes中kubeconfig的用法
  3. programming-kubernetes 作者: [Michael Hausenblas](https://book.douban.com/search/Michael Hausenblas) / [Stefan Schimanski](https://book.douban.com/search/Stefan Schimanski)
posted @ 2022-01-24 17:34  cosmoswong  阅读(2539)  评论(0编辑  收藏  举报