2019年2月25日

Introduction to Kotlin

2011年7月,JetBrains推出 Kotlin 專案,Kotlin 是一種在 Java VM 上執行的靜態型別程式語言,它也可以被編譯成為 JavaScript 原始碼。它是由俄羅斯聖彼得堡的 JetBrains 開發團隊所發展出來的程式語言,其名稱來自於聖彼得堡附近的科特林島。雖然與Java語法並不相容,但Kotlin被設計成可以和Java程式碼相互運作,並可以重複使用如Java集合框架等的現有Java類別庫。

相容於 Java VM 的語言還有很多,語言特性只有 Scala 比較符合需求,然而 Scala 本身有編譯太慢的缺陷,因此 Kotlin 的設計目標就是希望能跟 Java 一樣,快速編譯。JetBrains 開發這語言的用意,是希望這個新語言能夠推動 IntelliJ IDEA 的銷售。自 2011年開始開發 Kotlin 以來,一直到了 2016年2月15日,Kotlin 才釋出第一個穩定的 1.0 版,而且官方認為在未來的更新版本,都會往前相容。

Oracle 針對 Android 侵權,對 Google 提出訴訟

Oracle 在 2010 年收購 Sun Microsystems 之後不久,在就對 Google 提起訴訟,認為他們在 Android 平台上使用 37 項 Java API 侵犯了他們的權益,求償 26 億美元,Sun Microsystems 公司是構建 Java 語言和平台的公司。到了 2016 年這個數字又再以大量成長的 Android 裝置數量來調整,求償金額高達 93 億美元,2016年五月,舊金山聯邦法庭陪審團認定安卓合理利用JavaAPI並未侵權,Oracle 再次敗訴。2016年10月,Oracle 再次向上訴至聯邦巡迴上訴法院。2018年3月,聯邦巡迴上訴法院再次裁定支持 Oracle,認定 Google 侵權。

為了解決這樣的問題,Google 在 2017 年 Google I/O 宣布正式把 Kotlin 納入 Android 程式的官方一級開發語言(First-class language),因為是全新的語言,語法不相容於 Java,有新的 API,可以編譯為 java class file,與既有的 Java Library 交互運作,這樣就可以迴避 Oracle 宣稱的 API 侵權問題。

Kotline 建構流程

Kotlin 原始程式碼是以 .kt 為副檔名儲存的,透過 compiler 編譯為 class,這個步驟跟 Java 一樣,到最後要執行時,再引用 Kotlin Runtime 就可以運作。

開發工具

Kotline 可搭配 Intellij IDEA、Android Studio 或是 Eclipse 這幾種 IDE 進行開發,也可以自己安裝 Command Line Compiler

Command Line Compiler 可直接在 github 下載,解壓縮後就可以用,如果在 Mac OS,可以用 Homebrew 或是 MacPorts 安裝。

sudo port install kotlin

簡單寫個 hello world 測試

vi hello.kt

fun main(args: Array<String>) {
    println("Hello, World!")
}

編譯時加上 -include-runtime 會將 Kotlin Runtime Library 直接包裝在 hello.jar 裡面,可直接用 java 執行。

$ kotlinc hello.kt -include-runtime -d hello.jar
$ java -jar hello.jar
Hello, World!

如果直接編譯,沒有引入 Runtime Library,則需要在執行時,透過 kotlin 執行。

$ kotlinc hello.kt -d hello.jar
$ kotlin -classpath hello.jar HelloKt
Hello, World!

另外有一個 kotlinc-jvm REPL 的 command line 工具

$ kotlinc-jvm
Welcome to Kotlin version 1.2.41 (JRE 1.8.0_152-b16)
Type :help for help, :quit for quit
>>> 2+2
4
>>> println("Hello")
Hello

Kotlin 也支援 scripting 形式的 code,以 .kts 為副檔名

vi files.kts

import java.io.File

val folders = File(args[0]).listFiles { file -> file.isDirectory() }
folders?.forEach { folder -> println(folder) }

透過 kotlinc 執行

$ kotlinc -script files.kts .

Kotlin Koans

Kotlin Koans 是個簡單的教學 tutorial,可用 IDE 以 Gradle project 方式 import project,然後就能透過 Unit Test 的方式,一步一步接受指令完成任務。

舉例來說,在 src/iintroduction/0HelloWorld/n00Start.kt 可看到第一個任務,只要把 task0 改成回傳 "OK",然後到 test/iintroduction/0HelloWorld/n00StartKtTest.kt ,進行單元測試,如果測試通過,就是完成這個任務。

fun task0(): String {
    return "OK"
}

如果不想使用 IDE,也可以在 try.kotlinlang.org 直接線上進行 Kotlin Koans。

Java-Kotlin 自動轉換

在 IDE 有個功能,可以自動將 Java 的 code 轉換為 Kotlin,這是因為 Kotlin 本身的語法就是從 Java 簡化而來的,所以能夠將 Java 的 code 直接一對一轉換為 Kotlin,但有個問題是,這樣的轉換,沒辦法轉換為 Kotlin 的最簡語法。

Kotlin Koans 第一號任務,就是在教我們如何使用這個自動轉換的功能。只要直接複製 Java code,在 IDE 中,貼到 kotlin source 中,IDE 就會自動將 Java code 轉換為 Kotlin。

Kotlin 發展哲學

  • Pragmatic 實用

    Kotlin 借用了 Scala 的一些語法概念,發展自己的語法,但跟 Scala 不同的是 Scala 是一種學院派的語言,他有著許多神奇的語法結構,導致要入門 Scala 需要一段很長的時間。

    Kotlin 不同,完全是為了搭配 IDE 而實作的,因此語法也僅止於簡化 Java,改良 Java 遇到的一些問題。kotlin 是以實用為主的語言。

  • Concise 簡潔

    簡單清楚的程式碼,減輕了開發者的負擔,增加了程式碼可讀性,也縮短了開發與除錯的時間,工作效率更高。

  • Safe 安全

    Kotlin 是一個靜態資料型別的語言,確保了程式的資料型別安全,但不需要在程式碼中指定資料型別,因為 compiler 可以自己判斷。

    Kotlin 改良了傳統 Java 的問題,移除了 NullPointerException,以及 ClassCastException,避免程式在運作期間,才出現這些 Exception 導致程式異常中斷。

  • Interoperable 互操作性

    Kotlin 能夠使用既有 Java 領域的所有 Library,同時提供了 Java code 轉換為 Kotlin 的工具,因為 Kotlin 會編譯為 Java 的 class file,運作在 JVM 上,這表示在運作過程中,跟以往的 Java Applcation 完全是一樣的。

References

Kotlin實戰(Kotlin in action)

Kotlin Koans學習筆記

Kotlin Wiki

Java 世紀侵權案甲骨文勝訴,向全球追討授權費台灣也遭殃

2019年2月18日

Keras 手寫阿拉伯數字辨識

keras 是 python 語言的機器學習套件,後端能使用 Google TensorFlow, Microsoft CNTKTheano 運作。其中 Theano 在 2017/9/28 就宣佈在 1.0 後就不再更新。一般在初學機器學習時,都是用手寫阿拉伯數字 MNIST 資料集進行測試,kaggle Digit Recognizer 有針對 MNIST data 的機器學習模型的評比,比較厲害的,都可以達到 100% 的預測結果。

CentOS 7 Keras, TensorFlow docker 測試環境

docker run -it --name c1 centos:latest /bin/bash

安裝一些基本工具,以及 openssh-server

#yum provides ifconfig

yum install -y net-tools telnet iptables sudo initscripts
yum install -y passwd openssl openssh-server

yum install -y wget vim

測試 sshd

/usr/sbin/sshd -D
Could not load host key: /etc/ssh/ssh_host_rsa_key
Could not load host key: /etc/ssh/ssh_host_ecdsa_key
Could not load host key: /etc/ssh/ssh_host_ed25519_key

缺少了一些 key

ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
#直接 enter 即可

ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
#直接 enter 即可

ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N ""

ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""

修改 UsePAM 設定

vi /etc/ssh/sshd_config
# UsePAM yes 改成 UsePAM no
UsePAM no

再測試看看 sshd

/usr/sbin/sshd -D&

修改 root 密碼

passwd root

離開 docker

exit

以 docker ps -l 找到剛剛那個 container 的 id

$ docker ps -l
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
107fb9c3fc0d        centos:latest       "/bin/bash"         7 minutes ago       Exited (0) 2 seconds ago                       c1

將 container 存成另一個新的 image

docker commit 107fb9c3fc0d centosssh

以新的 image 啟動另一個 docker instance

(port 10022 是 ssh,15900 是 vnc)

(--privileged=true 是避免 systemd 發生的 Failed to get D-Bus connection: Operation not permitted 問題)

docker run -d -p 10022:22 -p 15900:5900 -e "container=docker" --ulimit memlock=-1 --privileged=true -v /sys/fs/cgroup:/sys/fs/cgroup --name test centosssh /usr/sbin/init

docker exec -it test /bin/bash

現在可以直接 ssh 登入新的 docker machine

ssh root@localhost -p 10022

修改 timezone, locale

timedatectl set-timezone Asia/Taipei

把 yum.conf 的 overrideinstalllangs 註解掉

vi /etc/yum.conf

#override_install_langs=en_US.utf8
yum -y -q reinstall glibc-common
localectl list-locales|grep zh
# 會列出所有可設定的 locale
zh_CN
zh_CN.gb18030
zh_CN.gb2312
zh_CN.gbk
zh_CN.utf8
zh_HK
zh_HK.big5hkscs
zh_HK.utf8
zh_SG
zh_SG.gb2312
zh_SG.gbk
zh_SG.utf8
zh_TW
zh_TW.big5
zh_TW.euctw
zh_TW.utf8

# 將 locale 設定為 zh_TW.utf8
localectl set-locale LANG=zh_TW.utf8

安裝視窗環境及VNC

ref: https://www.jianshu.com/p/38a60776b28a

yum groupinstall -y "GNOME Desktop"

# 預設啟動圖形介面
unlink /etc/systemd/system/default.target
ln -sf /lib/systemd/system/graphical.target /etc/systemd/system/default.target

# 安裝 vnc server
yum -y install tigervnc-server tigervnc-server-module 

# vnc 預設的port tcp 5900,則組態檔複製時在檔名中加入0,如vncserver@:0.service,如果要使用其他的port,就把0改為其他號碼
cp /lib/systemd/system/vncserver@.service /etc/systemd/system/vncserver@:0.service

vi /etc/systemd/system/vncserver@:0.service
# 修改中間的部分
ExecStartPre=/bin/sh -c '/usr/bin/vncserver -kill %i > /dev/null 2>&1 || :'
ExecStart=/usr/sbin/runuser -l root -c "/usr/bin/vncserver %i -geometry 1280x1024"
PIDFile=/root/.vnc/%H%i.pid
ExecStop=/bin/sh -c '/usr/bin/vncserver -kill %i > /dev/null 2>&1 || :'


# 執行 vncpasswd 填寫 vnc 密碼
su root
vncpasswd

# 退出 container
exit

# restart docker container
docker restart test

# 進入 docker container
docker exec -it test /bin/bash

# 啟動 service
systemctl daemon-reload
systemctl start vncserver@:0.service
systemctl enable vncserver@:0.service

# 開啟防火牆允許VNC的連線,以及重新load防火牆,這邊多開放了port 5909。
firewall-cmd --permanent --add-service="vnc-server" --zone="public"
#firewall-cmd --add-port=5909/tcp --permanent
firewall-cmd --reload

vncserver -list

# 以 vnc client 連線,連接 localhost:15900

如果用 vnc 連線到 docker 機器,後面測試時,matplotlib 可直接把圖形畫在視窗上,就不用存檔。

安裝 TensorFlow, python 3.6 開發環境

yum -y install centos-release-scl
yum -y install rh-python36

python --version
# Python 2.7.5

# 目前還是 python 2.7,必須 enable 3.6
scl enable rh-python36 bash

python --version
# Python 3.6.3

但每次登入都還是 2.7

vi /etc/profile.d/rh-python36.sh

#!/bin/bash
source scl_source enable rh-python36

接下來每次登入都是 3.6

安裝 TensorFlow

pip3 install --upgrade tensorflow

# 更新 pip
pip3 install --upgrade pip

簡單測試,是否有安裝成功

# python
import tensorflow as tf
hello = tf.constant('Hello, TensorFlow!')
sess = tf.Session()
print(sess.run(hello))

# 會列印出這樣的結果
# b'Hello, TensorFlow!'

#---------

python -c "import tensorflow as tf; tf.enable_eager_execution(); print(tf.reduce_sum(tf.random_normal([1000, 1000])))"
# 會列印出這樣的結果
# tf.Tensor(12.61731, shape=(), dtype=float32)

再安裝 keras, matplotlib (需要 tk library)

pip3 install keras

yum -y install rh-python36-python-tkinter
pip3 install matplotlib

阿拉伯數字辨識

MNIST 是一個包含 60,000 training images 及 10,000 testing images 的手寫阿拉伯數字的測試資料集。資料集的每個圖片都是解析度為 28*28 (784 個 pixel) 的灰階影像, 每個像素為 0~255 之數值。

One-Hot Encoding 就是一位有效編碼,當有 N 種狀態,就使用 N 位狀態儲存器的編碼,每一個狀態都有固定的位置。

例如阿拉伯數字就是 0 ~ 9,就使用這樣的編碼方式

[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.]    代表 0
...
[0., 0., 0., 0., 0., 1., 0., 0., 0., 0.]    代表 5
...
[0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]    代表 9

程式處理的步驟如下:

  1. 取得訓練資料:目前是直接使用既有的 MNIST 資料集,利用這些既有的資料,進行機器學習。
  2. 機器訓練,取得模型:進行機器訓練,取得訓練後結果的模型,未來就可以利用這個模型,判斷新進未知的資料
  3. 評估:利用 MNIST 資料集的測試資料,評估模型判斷後的結果跟正確結果的差異,取得這個模型的準確率。
  4. 預測:未來可利用這個模型,判斷並預測新進資料的結果。當然這會因為上一個步驟的準確度,有時候會失準,不一定會完全正確。

以下這個例子是使用 Sequential 線性的模型,input layer 是 MNIST 60000 筆訓練資料,中間是一層有 256 個變數的 hidden layer,最後是 10 個變數 (0~9) 的 output layer。機器學習就是在產生 input layer 到 hidden layer,以及 hidden layer 到 output layer 中間的 weight 權重。

input layer --- W(i,j) ---> hidden layer (256 個變數) --- W(j, k) ---> output layer (0~9) 

測試程式 test.py

import numpy as np
from keras.models import Sequential
from keras.datasets import mnist
from keras.layers import Dense, Dropout, Activation, Flatten
# 用來後續將 label 標籤轉為 one-hot-encoding
from keras.utils import np_utils
from matplotlib import pyplot as plt

# 載入 MNIST 資料庫的訓練資料,並分為 training 60000 筆 及 testing 10000 筆 data
(x_train, y_train), (x_test, y_test) = mnist.load_data()


# 將 training 的 label 進行 one-hot encoding,例如數字 7 經過 One-hot encoding 轉換後是 array([0., 0., 0., 0., 0., 0., 0., 1., 0., 0.], dtype=float32),即第7個值為 1
y_train_onehot = np_utils.to_categorical(y_train)
y_test_onehot = np_utils.to_categorical(y_test)

# 將 training 的 input 資料轉為 28*28 的 2維陣列
# training 與 testing 資料數量分別是 60000 與 10000 筆
# X_train_2D 是 [60000, 28*28] 的 2維陣列
x_train_2D = x_train.reshape(60000, 28*28).astype('float32')
x_test_2D = x_test.reshape(10000, 28*28).astype('float32')

x_train_norm = x_train_2D/255
x_test_norm = x_test_2D/255


# 建立簡單的線性執行的模型
model = Sequential()
# Add Input layer, 隱藏層(hidden layer) 有 256個輸出變數
model.add(Dense(units=256, input_dim=784, kernel_initializer='normal', activation='relu'))
# Add output layer
model.add(Dense(units=10, kernel_initializer='normal', activation='softmax'))

# 編譯: 選擇損失函數、優化方法及成效衡量方式
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])


# 進行 model 訓練, 訓練過程會存在 train_history 變數中
# 將 60000 張 training set 的圖片,用 80% (48000張) 訓練模型,用 20% (12000張) 驗證結果
# epochs 10 次,就是訓練做了 10 次
# batch_size 是 number of samples per gradient update,每一次進行 gradient descent 使用幾個 samples
# verbose 是 train_history 的 log 顯示模式,2 表示每一輪訓練,列印一行 log
train_history = model.fit(x=x_train_norm, y=y_train_onehot, validation_split=0.2, epochs=10, batch_size=800, verbose=2)

# 用 10000 筆測試資料,評估訓練後 model 的成果(分數)
scores = model.evaluate(x_test_norm, y_test_onehot)
print()
print("Accuracy of testing data = {:2.1f}%".format(scores[1]*100.0))

# 預測(prediction)
X = x_test_norm[0:10,:]
predictions = model.predict_classes(X)
# get prediction result
print()
print(predictions)

# 模型訓練結果 結構存檔
from keras.models import model_from_json
json_string = model.to_json()
with open("model.config", "w") as text_file:
    text_file.write(json_string)

# 模型訓練結果 權重存檔
model.save_weights("model.weight")


# 顯示 第一筆訓練資料的圖形,確認是否正確
#plt.imshow(x_train[0])
#plt.show()
#plt.imsave('1.png', x_train[0])

plt.clf()

plt.plot(train_history.history['loss'])
plt.plot(train_history.history['val_loss'])
plt.title('Train History')
plt.ylabel('loss')
plt.xlabel('Epoch')
plt.legend(['loss', 'val_loss'], loc='upper left')
#plt.show()
plt.savefig('loss.png')

執行結果

# python test.py
Using TensorFlow backend.
Train on 48000 samples, validate on 12000 samples
Epoch 1/10
 - 2s - loss: 0.7582 - acc: 0.8134 - val_loss: 0.3195 - val_acc: 0.9117
Epoch 2/10
 - 1s - loss: 0.2974 - acc: 0.9160 - val_loss: 0.2473 - val_acc: 0.9307
Epoch 3/10
 - 2s - loss: 0.2346 - acc: 0.9350 - val_loss: 0.2060 - val_acc: 0.9425
Epoch 4/10
 - 2s - loss: 0.1930 - acc: 0.9465 - val_loss: 0.1741 - val_acc: 0.9522
Epoch 5/10
 - 2s - loss: 0.1631 - acc: 0.9539 - val_loss: 0.1529 - val_acc: 0.9581
Epoch 6/10
 - 2s - loss: 0.1410 - acc: 0.9604 - val_loss: 0.1397 - val_acc: 0.9612
Epoch 7/10
 - 1s - loss: 0.1225 - acc: 0.9662 - val_loss: 0.1301 - val_acc: 0.9639
Epoch 8/10
 - 1s - loss: 0.1075 - acc: 0.9695 - val_loss: 0.1171 - val_acc: 0.9668
Epoch 9/10
 - 1s - loss: 0.0948 - acc: 0.9744 - val_loss: 0.1123 - val_acc: 0.9681
Epoch 10/10
 - 1s - loss: 0.0855 - acc: 0.9771 - val_loss: 0.1047 - val_acc: 0.9700
10000/10000 [==============================] - 1s 57us/step

Accuracy of testing data = 97.1%

[7 2 1 0 4 1 4 9 6 9]

Model Persistence

要儲存訓練好的模型,有兩種方式

  1. 結構及權重分開儲存

    儲存模型結構,可儲存為 JSON 或 YAML

    from keras.models import model_from_json
    json_string = model.to_json()
    with open("model.config", "w") as text_file:
      text_file.write(json_string)

    儲存權重

    model.save_weights("model.weight")

    讀取結構及權重

    import numpy as np  
    from keras.models import Sequential
    from keras.models import model_from_json
    with open("model.config", "r") as text_file:
      json_string = text_file.read()
      model = Sequential()
      model = model_from_json(json_string)
      model.load_weights("model.weight", by_name=False)
  2. 合併儲存結構及權重

    合併儲存時,檔案格式為 HDF5

    from keras.models import load_model
    
    model.save('model.h5')  # creates a HDF5 file 'model.h5'

    讀取模型

    from keras.models import load_model
    
    # 載入模型
    model = load_model('model.h5')

References

【深度學習框架 Theano 慘遭淘汰】微軟數據分析師:為何曾經熱門的 Theano 18 個月就陣亡?

【Python】CentOS7 安裝 Python3

Install TensorFlow with pip

撰寫第一支 Neural Network 程式 -- 阿拉伯數字辨識

MyNeuralNetwork/0.py

改善 CNN 辨識率

mnist-cnn/mnist-CNN-datagen.ipynb

深度學習 TensorFlow

2019年2月11日

erlang 如何支援多個設定檔

通常 erlang project 會將設定的資料放在 sys.config 裡面,同樣的,很多 library 也會在說明文件裡面提到,要將該 library 相關的設定資訊,寫在 sys.config 裡面,在規模稍大的 project 就會看到一個很冗長複雜的設定檔。

但其實在 elang config 的標準文件 sys.config 說明裡面有提到,sys.config 裡面的語法是

[{Application, [{Par, Val}]} | File].

File 的部分就是附加的設定檔,erlang 會將所有設定檔裡面,對於某個 application 相關的所有參數,依照檔案順序整合在一起

首先利用 rebar 產生一個測試的 application myapp,並進行編譯

$ rebar create-app appid=myapp
==> erltest (create-app)
Writing src/myapp.app.src
Writing src/myapp_app.erl
Writing src/myapp_sup.erl

$ rebar compile
==> erltest (compile)
Compiled src/myapp_app.erl
Compiled src/myapp_sup.erl

然後依照以往的做法,將 myapp 的參數寫在 sys.config 裡面

[
    { myapp, [
        {par1,val1},
        {par2,val2}
    ]},
    { myapp, [
        {par1,newval1},
        {par3,newval3}
    ]}
].

注意,我們刻意將 myapp 的參數分成兩個部分撰寫,然後我們可以在 console 啟動 myapp,並利用 application:get_all_env(myapp). 取得 myapp 所有的參數進行測試。

$ erl -pa ebin -config sys
1> application:get_all_env(myapp).
[]
2> application:load(myapp).
ok
3> application:get_all_env(myapp).
[{par2,val2},
 {par1,newval1},
 {included_applications,[]},
 {par3,newval3}]

透過測試結果可以發現 myapp 的三個參數,是依照參數定義的順序累加並覆蓋得到的。


接下來,我們建立一個新的設定檔 conf/node1.config,內容為

[
    { myapp, [
        {par1,node1_val1},
        {par2,node1_val2},
        {parN,node1_valN}
    ]}
].

project 的目錄及檔案是這樣,也就是增加了一個 conf 的目錄

conf\
    node1.config
ebin\
src\
    myapp_app_erl
    myapp_sup.erl
    myapp_app.src
sys.config

然後修改 sys.config,把 node1.config 的設定附加在後面

[
    { myapp, [
        {par1,val1},
        {par2,val2}
    ]},
    { myapp, [
        {par1,newval1},
        {par3,newval3}
    ]}
    , "conf/node1.config"
].

這時候測試得到的結果會發現,node1.config 的設定覆蓋了原本 sys.config 裡面的設定

$ erl -pa ebin -config sys
1> application:get_all_env(myapp).
[]
2> application:load(myapp).
ok
3> application:get_all_env(myapp).
[{par2,node1_val2},
 {par1,node1_val1},
 {included_applications,[]},
 {par3,val3},
 {parN,node1_valN}]

我們可以將 sys.config 的機制,運用在兩個地方

  1. 分離 library 的設定檔 將某些 library 的設定,以獨立的設定檔方式撰寫
  2. 覆寫獨立的 node 的設定檔 將新的 node 的設定檔,也就是每一個 node 不同的設定資料,不修改既有的 sys.config 而是寫在 node 的設定檔,覆寫既有的設定

不過如果檔案的部分可以支援這樣的寫法 "conf/*.config",對於 library 設定檔的整合,會比較簡單。但如果支援了 *.config,就無法自定設定檔的引用順序,可能只能依照字母的順序排序了。

References

A little known fact about Erlang's sys.config

An easy way to handle configuration parameters in Erlang

節點啟動後自動連接其它配置節點

[erlang-questions] Multiple application configurations in multiple files