配置檔案 優缺點比較 Jsonnet YAML JSON INI XML

各種用於配置檔案的格式比較

編寫服務端程式通常都需要一組配置檔案來設定程式各種細節,本喵經歷了 INI XML JSON YAML 以及目前使用Jsonnet,本文分析了本喵在使用這些格式遇到的問題以及它的優點。

目前本喵將這些格式分爲三組,同一組內的格式在本喵看來實用性差不多

  • S -> Jsonnet -> 可以同時應付複雜和簡單的情況
  • A -> YAML INI -> 能夠很好的處理簡單情況
  • B -> JSON XML -> 不應該使用

JSON XML

秉着從次到好的順序首先來看下 B組 的 JSON 和 XML

作爲配置檔案 JSON 和 XML 有同樣的問題,編寫麻煩可讀性差XML問題尤其嚴重,對於XML你要不斷的寫開始tag和結束tag,特別是在配置一個數組元素時正常貓咪都應該會崩潰。

<server>
        <listen>:8080</listen>
        <host>
                <value>xxx.com</value>
                <value>www.xxx.com</value>
                <value>code.xxx.com</value>
        </host>
</server>

所有的屬性你需要書寫兩次,而且需要保證結束tag和開始tag對應,同時在書寫數組時尤其愚蠢。故XML是本喵深惡痛絕極力反對的一種格式。

JSON 相對 XML 有一點進步 類似的配置可能長這樣:

{
    "server":":8080",
    "host":[
        "xxx.com",
        "www.xxx.com",
        "code.xxx.com"
    ]
}

和 XML 比起來簡單許多,但問題依然很多,需要寫大量的引號所有屬性名需要用引號擴起來,同時Array和Object結尾不能有逗號,當然還有最爲被人詬病的 JSON 不支持註釋只憑這一條就應該放棄 JSON 作爲配置檔案。

通常你不應該選擇 JSON 和 XML 作爲自己的配置格式,因爲即使簡單的配置 JSON XML 也需要書寫許多額外負擔,對於複雜的情況更是只會讓事情變得一團糟。

YAML INI

INI 對於簡單的情況應付起來遊刃有餘但表現力不足無法應付複雜的情況,以上文的配置例子 INI 可能長這樣:

[server]
listen=:8080
host=xxx.com www.xxx.com code.xxx.com

清爽很多,或許對於簡單配置這足以應付了,但問題在於INI是列表形式(section 列表)如果要表現樹形結構就很彆扭,比如你還有一個數據庫配置的section並且在裏面詳細配置緩存。

[server]
listen=:8080
host=xxx.com www.xxx.com code.xxx.com

[db]
driver=mysql
connect=xxx
# 配置連接池
pool.max=10
pool.idle=1
# 配置緩存
cache.backend=memory
cache.max=100
cache.algorithm=lru

你不得不使用字符串替代樹結構,但這樣每次書寫配置時都要重複寫前綴,同時INI也不支持數組所以需要使用字符串用分隔符隔開而在程式中自己切分字符串。這些問題導致只在情況特別簡單時本喵才推薦使用INI。

YAML 是本喵覺得尚可的一種配置格式,但網路上似乎對它過於誇大了,先來看下對於上面INI的配置YAML可以這樣寫:

server:
  listen: :8080
  host: [xxx.com, www.xxx.com, code.xxx.com]
db:
  driver: mysql
  connect: xxx
  pool:
    max: 10
    idle: 1
  cache:
    backend: memory
    max: 100
    algorithm: lru

初看起來可讀性很好編輯容易而且表達能力豐富,對於簡單的配置確實如此,故而如果沒有更好的方案(比如 JSonnet )一些簡單的配置使用 YAML 本喵認爲是合適的。但是 YAML 並不像網路傳說中那麼美好,當配置變得複雜時一堆問題會讓你崩潰。

首先 YAML 使用縮進 當你樹結構很深時可讀性就是笑話看下面這個 k8s 官方示例給出的配置你能只憑藉 YAML 的可讀性很容易識別出來樹形結構誰是誰的屬性嗎:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  selector:
    matchLabels:
      app: mysql
  serviceName: mysql
  replicas: 3
  template:
    metadata:
      labels:
        app: mysql
    spec:
      initContainers:
      - name: init-mysql
        image: mysql:5.7
        command:
        - bash
        - "-c"
        - |
          set -ex
          # Generate mysql server-id from pod ordinal index.
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          echo [mysqld] > /mnt/conf.d/server-id.cnf
          # Add an offset to avoid reserved server-id=0 value.
          echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
          # Copy appropriate conf.d files from config-map to emptyDir.
          if [[ $ordinal -eq 0 ]]; then
            cp /mnt/config-map/master.cnf /mnt/conf.d/
          else
            cp /mnt/config-map/slave.cnf /mnt/conf.d/
          fi          
        volumeMounts:
        - name: conf
          mountPath: /mnt/conf.d
        - name: config-map
          mountPath: /mnt/config-map
      - name: clone-mysql
        image: gcr.io/google-samples/xtrabackup:1.0
        command:
        - bash
        - "-c"
        - |
          set -ex
          # Skip the clone if data already exists.
          [[ -d /var/lib/mysql/mysql ]] && exit 0
          # Skip the clone on master (ordinal index 0).
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          [[ $ordinal -eq 0 ]] && exit 0
          # Clone data from previous peer.
          ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
          # Prepare the backup.
          xtrabackup --prepare --target-dir=/var/lib/mysql          
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d
      containers:
      - name: mysql
        image: mysql:5.7
        env:
        - name: MYSQL_ALLOW_EMPTY_PASSWORD
          value: "1"
        ports:
        - name: mysql
          containerPort: 3306
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
        livenessProbe:
          exec:
            command: ["mysqladmin", "ping"]
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 5
        readinessProbe:
          exec:
            # Check we can execute queries over TCP (skip-networking is off).
            command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
          initialDelaySeconds: 5
          periodSeconds: 2
          timeoutSeconds: 1
      - name: xtrabackup
        image: gcr.io/google-samples/xtrabackup:1.0
        ports:
        - name: xtrabackup
          containerPort: 3307
        command:
        - bash
        - "-c"
        - |
          set -ex
          cd /var/lib/mysql

          # Determine binlog position of cloned data, if any.
          if [[ -f xtrabackup_slave_info && "x$(<xtrabackup_slave_info)" != "x" ]]; then
            # XtraBackup already generated a partial "CHANGE MASTER TO" query
            # because we're cloning from an existing slave. (Need to remove the tailing semicolon!)
            cat xtrabackup_slave_info | sed -E 's/;$//g' > change_master_to.sql.in
            # Ignore xtrabackup_binlog_info in this case (it's useless).
            rm -f xtrabackup_slave_info xtrabackup_binlog_info
          elif [[ -f xtrabackup_binlog_info ]]; then
            # We're cloning directly from master. Parse binlog position.
            [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
            rm -f xtrabackup_binlog_info xtrabackup_slave_info
            echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
                  MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
          fi

          # Check if we need to complete a clone by starting replication.
          if [[ -f change_master_to.sql.in ]]; then
            echo "Waiting for mysqld to be ready (accepting connections)"
            until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done

            echo "Initializing replication from clone position"
            mysql -h 127.0.0.1 \
                  -e "$(<change_master_to.sql.in), \
                          MASTER_HOST='mysql-0.mysql', \
                          MASTER_USER='root', \
                          MASTER_PASSWORD='', \
                          MASTER_CONNECT_RETRY=10; \
                        START SLAVE;" || exit 1
            # In case of container restart, attempt this at-most-once.
            mv change_master_to.sql.in change_master_to.sql.orig
          fi

          # Start a server to send backups when requested by peers.
          exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
            "xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"          
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
      volumes:
      - name: conf
        emptyDir: {}
      - name: config-map
        configMap:
          name: mysql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 10Gi

你能明確看出來 volumeClaimTemplates 是誰的屬性嗎?或者在 volumeClaimTemplates 不存在時清楚知道要縮進多少空格來添加 volumeClaimTemplates?

故而本喵認爲只在簡單情況下使用 INI 和 YAML。

k8s 使用了 YAML 但情況很複雜,所以 google 和第三方才推出了各種模板插件來替代直接書寫原始 YAML 如果你打算爲自己的配置檔案再開發一套模板插件來生成 YAML 請無視本喵的忠告。

Jsonnet

在 Jsonnet 前本喵有使用過一段時間另外一種思路,使用腳本當作配置,足夠靈活可讀性也很高,但會產生另外一些問題最終放棄主要問題如下:

  • 需要嵌入腳本引擎 太重量級
  • 使用a腳本作爲配置 但需要寫 b腳本實現程式 很彆扭
  • 腳本太過自由必須限制可訪問範圍 這又會增加很多成本

後來發現了 Jsonnet 和這個相法不謀而合,Jsonnet 是一個單獨的腳本語言,但其功能被限定在只能生成配置檔案,google 提供了很多語言的支持嵌入很容易,常用腳本語言也有 Jsonnet 的解析器。更多詳細介紹請看 google 文檔

Jsonnet 兼容 JSON 語法,同時最終生成JSON供程式使用,Jsonnet 人類友好輸出的 JSON 機器友好,簡直完美。

首先來看下 上述 YAML 簡單情況下的同等 Jsonnet 配置

{
    server: {
        listen: ':8080',
        host: ['xxx.com', 'www.xxx.com' 'code.xxx.com'],
    },
    db: {
        driver: 'mysql',
        connect: 'xxx',
        pool: {
            max: 10,
            idle: 1,
        },
        cache: {
            backend: 'memory',
            max: 100,
            algorithm: 'lru',
        },
    },
}

在情況簡單時,可讀性很高,但與 YAML 相比屬性會多幾個 引號,但是引號明確了數據類型是字符串本喵是反而更欣賞這點。

簡單問題怎樣都好因爲處理起來容易,關鍵在於處理複雜問題的能力,Jsonnet 就強大多了,其提供了多種特性這裏只簡單演示下繼承,假如我們要配置多個web服務器,其配置基本一樣只個別屬性不同可以這樣寫:

// 定義一個基礎類 設置默認屬性
local Base = {
    // 配置 https 證書
    certificate: 'fullchain.pem',
    key: 'privkey.pem',
    // 其它很多 屬性
    gzip: true,
    keepalive_timeout: 65,
    client_max_body_size:'5M',
};

{
    server:[
        // 服務器 一 全部默認  Base 配置
        Base{
            Listen: ':8080',
        },
        // 服務器 二 關閉 gzip
        Base{
            Listen: ':8081',
            gzip: false
        },
        // 服務器 三 使用 單獨的證書 並開啓 quic
        Base{
            Listen: ':8082',
            certificate: 'fullchain1.pem',
            key: 'privkey1.pem',
            quic: true
        },
    ],
}

最終只寫了一份默認的 Base 然後很容易的派生了三個獨特的服務器,生成的 JSON 如下:

{
   "server": [
      {
         "Listen": ":8080",
         "certificate": "fullchain.pem",
         "client_max_body_size": "5M",
         "gzip": true,
         "keepalive_timeout": 65,
         "key": "privkey.pem"
      },
      {
         "Listen": ":8081",
         "certificate": "fullchain.pem",
         "client_max_body_size": "5M",
         "gzip": false,
         "keepalive_timeout": 65,
         "key": "privkey.pem"
      },
      {
         "Listen": ":8082",
         "certificate": "fullchain1.pem",
         "client_max_body_size": "5M",
         "gzip": true,
         "keepalive_timeout": 65,
         "key": "privkey1.pem",
         "quic": true
      }
   ]
}

最後來看下上面那個難看的 k8s 的 YAML 配置 如果用 Jsonnet 會長什麼樣子:

{
    apiVersion: 'apps/v1',
    kind: 'StatefulSet',
    metadata: {
        name: 'mysql',
    },
    spec: {
        serviceName: "mysql",
        replicas: 3,
        template: import 'template.libsonnet',
        selector: import 'selector.libsonnet',
        volumeClaimTemplates: import 'volumeClaimTemplates.libsonnet',
    },  
}

主配置檔案就這麼簡單,template.libsonnet selector.libsonnet volumeClaimTemplates.libsonnet 三個檔案作爲模塊被加載進入主配置,現在因爲配置被拆分了無論主配置還是子配置都變得簡單容易人類閱讀和編輯。

1 則留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *