User Tools

Site Tools


lecture:nrepl:sources

프로젝트 구조

tools.nrepl
├── META-INF
│   └── MANIFEST.MF
├── README.md
├── doc
│   └── ops.md
├── epl.html
├── load-file-test
│   └── clojure
│       └── tools
│           └── nrepl
│               └── load_file_sample.clj
├── pom.xml
└── src
    ├── integration
    │   ├── clojure-1.3.0
    │   │   └── pom.xml
    │   ├── clojure-1.4.0
    │   │   └── pom.xml
    │   └── clojure-1.5.0
    │       └── pom.xml
    ├── main
    │   ├── clojure
    │   │   └── clojure
    │   │       └── tools
    │   │           ├── nrepl
    │   │           │   ├── #misc.clj#
    │   │           │   ├── ack.clj
    │   │           │   ├── bencode.clj
    │   │           │   ├── cmdline.clj
    │   │           │   ├── helpers.clj
    │   │           │   ├── middleware
    │   │           │   │   ├── interruptible_eval.clj
    │   │           │   │   ├── load_file.clj
    │   │           │   │   ├── pr_values.clj
    │   │           │   │   └── session.clj
    │   │           │   ├── middleware.clj
    │   │           │   ├── misc.clj
    │   │           │   ├── server.clj
    │   │           │   └── transport.clj
    │   │           └── nrepl.clj
    │   ├── java
    │   │   └── clojure
    │   │       └── tools
    │   │           └── nrepl
    │   │               ├── Connection.java
    │   │               ├── StdOutBuffer.java
    │   │               └── main.java
    │   └── resources
    │       └── clojure
    │           └── tools
    │               └── nrepl
    │                   └── version.txt
    └── test
        └── clojure
            └── clojure
                └── tools
                    ├── nrepl
                    │   ├── bencode_test.clj
                    │   ├── cmdline_test.clj
                    │   ├── describe_test.clj
                    │   ├── helpers_test.clj
                    │   ├── load_file_test.clj
                    │   ├── middleware_test.clj
                    │   ├── pprinting_test.clj
                    │   ├── response_test.clj
                    │   └── sanity_test.clj
                    └── nrepl_test.clj

프로젝트 설명

Leiningen 프로젝트가 아니라 Maven 프로젝트이다. 그래서 project.clj파일이 없다.

프로그램의 구동 시작

그래서 public static void main이 있는 main.java에서 시작된다.

package clojure.tools.nrepl;
 
import clojure.lang.RT;
import clojure.lang.Symbol;
import clojure.lang.Var;
 
/**
 * @author Chas Emerick
 */
public class main {
    public static void main (String[] args) throws Exception {
        RT.var("clojure.core", "require").invoke(Symbol.intern("clojure.tools.nrepl.cmdline")); // 1.
        RT.var("clojure.tools.nrepl.cmdline", "-main").applyTo(RT.seq(args));                   // 2.
    }
}
  1. intern은 이름 공간에 심볼을 만든다. clojure.core/require로 clojure.tools.nrepl.cmdline를 로드한다.
  2. clojure.tools.nrepl.cmdline에서 -main 을 호출한다.

Bencode

Bencode.clj는 Bencode 인코딩/디코딩 기능을 구현하고 있다. Bencode는 BitTorrent에서 사용하는 데이타 전송 및 저장을 위한 인코딩 방식으로 D. J. Bernstein1)Netstring을 확장한 것이다. 바이너리 인코딩 방식에 비해 비효율적이지만, 단순하면서도 유연하고 컴퓨터 시스템마다 사용되는 엔디안이 달라도 그에 무관하게 동작한다는 특징이 있다. 스트링, 숫자, 리스트, 맵을 지원한다.

netstring 인코딩

우선 먼저 netstring은 다음과 같다.

     13:Hello, World!,
     10:Guten Tag!,
     0:,

처음 숫자는 데이타의 길이를 나타내고, 뒤이어 구분자로서 콜론(':')이 오며, 바로 뒤에 스트링 데이타가 온다. 마지막으로 ','로 끝을 표시한다. 데이타의 길이가 먼저 나오기 때문에 어플리케이션은 미리 먼저 충분한 메모리를 확보할 수 있으며, 마지막 ','는 에러 검증에 사용된다.

bencode 인코딩

bencode는 여기에 Integer, List, Dictionary 인코딩이 추가되었다.

  • Integer는 i로 시작해서 e로 끝나는 10진 표기이다. 42는 “i42e”, 0은 “i0e”, -42는 “i-42e”로 인코딩된다.
  • List는 l로 시작해서 e로 끝난다. 그 안에는 다른 bencode가 들어간다. (“spam”, 42)는 “l4:spam,i42ee”로 인코딩된다.
  • Dictionary는 d로 시작해서 e로 끝난다. 그 안에는 다른 bencode가 들어간다. {“bar” “spam”, “foo” 42}는 “d3:bar4:spam3:fooi42ee”로 인코딩된다.

bencode의 특징은 다음과 같다.

  • 데이타와 그것의 bencoding된 데이타와 일대일 대응 관계다. 그래서 bencoding된 데이타를 디코딩하지 않고 바로 비교할 수 있다.
  • 인코딩된 데이타는 사람이 직접 디코딩할 수도 있으나, 종종 바이너리 데이타를 포함하거나, 인코딩이 아주 복잡한 경우 사람이 읽기에는 어렵다.
  • bencoding은 JSON이나 YAML처럼 플랫폼에 독립적이지만 복잡하면서도 손쉽게 구조화되는 데이타 인코딩을 위한 목적이다.

bencode 소스 분석

이제 bencode.clj 클로져 소스 파일을 분석해 보자.

상수 정의

소스에서는 우선 가장 먼저 becoding에 사용되는 구분자(delimiter)들을 상수로 정의하고 있다.

(def #^{:const true} i     105)
(def #^{:const true} l     108)
(def #^{:const true} d     100)
(def #^{:const true} comma 44)
(def #^{:const true} minus 45)

:const 메타데이타를 true로 지정하면, 해당 Var는 컴파일러에 의해 즉치값으로 대체된다. 이것은 자바의 public final static 변수를 정의하는 것과 같다.

다음은 boxed values이다.

(def e     101)
(def colon 58)

read-byte, read-bytes, read-long

(defn #^{:private true} read-byte
  #^long [#^InputStream input]
  (let [c (.read input)]
    (when (neg? c)
      (throw (EOFException. "Invalid netstring. Unexpected end of input.")))
    (if (< 127 c) (- c 256) c)))
 
(defn #^{:private true :tag "[B"} read-bytes
  #^Object [#^InputStream input n]
  (let [content (byte-array n)]
    (loop [offset (int 0)
           len    (int n)]
      (let [result (.read input content offset len)]
        (when (neg? result)
          (throw
            (EOFException.
              "Invalid netstring. Less data available than expected.")))
        (when (not= result len)
          (recur (+ offset result) (- len result)))))
    content))
 
(defn #^{:private true} read-long
  #^long [#^InputStream input delim]
  (loop [n (long 0)]
    (let [b (read-byte input)]
      (cond
        (= b minus) (- (read-long input delim))
        (= b delim) n
        :else       (recur (+ (* n (long 10)) (- (long b) (long  48))))))))

read-byte는 Input 스트림에서 한 바이트를 가져온다. 자바의 read함수는 읽은 바이트의 값을 0이상의 값으로 리턴하기 때문에, 127을 기준으로 롤링 보정을 해준다.

read-bytes는 Input 스트림에서 n 만큼의 바이트 어레이를 읽어 리턴한다. Input스트림이 특히 소켓인 경우 n 보다 적게 읽을 수도 있기 때문에 (즉 위 코드에서 result와 len이 다른 경우) 아직 읽지 못한 수의 바이트를 읽기 위해 재귀를 돌린다. (하지만 이 코드의 단점은 read가 블럭 상태에 있을 수 있다는 것과 네트웍 상태가 매우 느릴 경우 재귀 함수를 계속 돌게 된다는 것이다.)

read-long은 Input 스트림에서 delim 구분자를 만날 때까지 read-byte 하면서 10진수를 읽어들인다. delim 구분자는 스트링의 길이를 나타내는 숫자뒤의 콜론이거나, 정수를 나타내는 숫자 뒤의 e가 될 수 있다. 48은 “0”의 아스키코드이다.

(declare #^"[B" string>payload
         #^String string<payload)

string>payload와 string>payload 함수를 전방 선언한 것이다. :tag 메타데이타2)를 “[B”와 java.lang.String으로 해서 리턴값의 타입을 지정한다. “[B”는 Java Byte Array []에 대한 Type Hint이다.

read-netstring

(defn #^{:private true} read-netstring*
  [input]
  (read-bytes input (read-long input colon)))
 
(defn #^"[B" read-netstring
  "Reads a classic netstring from input—an InputStream. Returns the
  contained binary data as byte array."
  [input]
  (let [content (read-netstring* input)]
    (when (not= (read-byte input) comma)
      (throw (IOException. "Invalid netstring. ',' expected.")))
    content))

sting <-> bytes

read-netstring*은 netstring의 처음 길이를 나타내는 숫자를 읽고, 다음 그 길이만큼의 데이타를 읽어 리턴한다. read-string 은 read-netstring*에서 리턴받은 것을 다시 리턴하는데, 만약 다음 데이타가 콤마가 아니면 예외를 던진다.

(defn #^{:private true :tag "[B"} string>payload
  [#^String s]
  (.getBytes s "UTF-8"))
 
(defn #^{:private true :tag String} string<payload
  [#^"[B" b]
  (String. b "UTF-8"))

string>payload는 String을 입력받아 바이트 배열을 리턴하고, string<payload는 바이트 배열을 입력받아 String을 리턴한다.

write-netstring

(defn #^{:private true} write-netstring*
  [#^OutputStream output #^"[B" content]
  (doto output
    (.write (string>payload (str (alength content))))
    (.write (int colon))
    (.write content)))
 
(defn write-netstring
  "Write the given binary data to the output stream in form of a classic
  netstring."
  [#^OutputStream output content]
  (doto output
    (write-netstring* content)
    (.write (int comma))))

write-netstring*과 write-netstring은 위의 read-netstring*과 read-netstring에 각각 대응된다.

read-token

(defn #^{:private true} read-token
  [#^PushbackInputStream input]
  (let [ch (read-byte input)]
    (cond
      (= (long e) ch) nil
      (= i ch) :integer
      (= l ch) :list
      (= d ch) :map
      :else    (do
                 (.unread input (int ch))
                 (read-netstring* input)))))

read-token 함수는 다음 바이트가 bencode인지 식별한다. 정수이면 :integer, 리스트이면 :list, 맵이면 :map을 리턴하고, bencode의 끝을 표시하는 'e'를 만나면 nil을 리턴한다. 어떤 것도 아니면 netstring이므로 read-netstring*을 호출한다.

(declare read-integer read-list read-map)

함수 read-integer read-list read-map를 전방선언한다.

read-bencode

(defn read-bencode
  "Read bencode token from the input stream."
  [input]
  (let [token (read-token input)]
    (case token
      :integer (read-integer input)
      :list    (read-list input)
      :map     (read-map input)
      token)))

read-bencode 함수는 read-token함수로 다음 바이트의 토큰을 구분하여 토큰별로 해당 함수를 호출한다. :integer는 read-integer, :list는 read-int, :map은 read-map 함수를 호출한다. 그외의 경우는 token은 이미 netstring을 읽어낸 것으로 그대로 리턴한다.(여기서 일관성이 떨어진다. read-token에서 :netstring을 리턴하고 read-bencode에서 read-netstring*을 호출하는 것이 더 좋은 것 같다.)

(defn #^{:private true} read-integer
  [input]
  (read-long input e))
 
(declare token-seq)
 
(defn #^{:private true} read-list
  [input]
  (vec (token-seq input)))
 
;; Maps are sequences of key/value pairs. The keys are always
;; decoded into strings. The values are kept as is.
 
(defn #^{:private true} read-map
  [input]
  (->> (token-seq input)
    (partition 2)
    (map (fn [[k v]] [(string<payload k) v]))
    (into {})))
 
;; The final missing piece is `token-seq`. This a just a simple
;; sequence which reads tokens until the next `\e`.
 
(defn #^{:private true} token-seq
  [input]
  (->> #(read-bencode input)
    repeatedly
    (take-while identity)))

read-integer함수는 단지 read-long함수에 자신의 역할을 전적으로 위임하고 있다.

read-list와 read-map 함수는 token-seq 함수가 만들어 내는 bencode 시퀀스를 이용하여 각각 리스트와 맵을 만들어 낸다.

token-seq 함수는 'e'를 만날 때까지 bencode를 계속 읽어내는 Lazy-seq를 만들어 낸다. →> 스레딩 문구의 시작 문구가 Input 스트림으로부터 bencode를 읽는 함수이다. 이것을 repeatedly로 무한 반복하는 Lazy-seq을 만든 indentify로 판단하는 take-while 문구를 통해 nil이 나올 때까지 읽어 들이게 된다.

write-bencode

(defmulti write-bencode
  "Write the given thing to the output stream. “Thing” means here a
  string, map, sequence or integer. Alternatively an ByteArray may
  be provided whose contents are written as a bytestring. Similar
  the contents of a given InputStream are written as a byte string.
  Named things (symbols or keywords) are written in the form
  'namespace/name'."
  (fn [_output thing]
    (cond
      (instance? (RT/classForName "[B") thing) :bytes
      (instance? InputStream thing) :input-stream
      (integer? thing) :integer
      (string? thing)  :string
      (symbol? thing)  :named
      (keyword? thing) :named
      (map? thing)     :map
      (or (nil? thing) (coll? thing) (.isArray (class thing))) :list

write-bencode는 멀티메소드이다. RT/classForName은 단지 Class.forName()을 호출할 뿐이다. “[B”는 자바에서 타입의 배열을 나타내는 방식이다. 이것에 대해서는 Java Class.getName()을 참조.

(defmethod write-bencode :map
  [#^OutputStream output m]
  (let [translation (into {} (map (juxt string>payload identity) (keys m)))
        key-strings (sort lexicographically (keys translation))
        >value      (comp m translation)]
    (.write output (int d))
    (doseq [k key-strings]
      (write-netstring* output k)
      (write-bencode output (>value k)))
    (.write output (int e))))

write-bencode는 각 타입에 따라 각각 정의되어 있는데, 거의 비슷한 로직이다. 여기서는 map만 보는 것으로 가치가 충분하다.

map의 경우 translation은 원래 맵 m 의 키와 그것을 인코딩한 것으로 된 맵이다. key-strings는 translation의 키를 정렬한 것인데, bencoding을 위한 comparator로서 lexcographically를 따로 만들어 사용하고 있다. >value는 인코딩된 키와 원래의 키를 거쳐 원래 맵 m 의 값을 가져오기 위한 함수이다.

doseq에서 정렬된 키의 요소를 돌면서 인코딩된 키는 write-netstring*으로 처리하고, 그 값은 >value로 가져와서 write-bencoding으로 처리하고 있다.

모든 write-bencoding 함수는 처음에는 접두어로 'i', 'l', 'd'를 쓰고, 접미사로 'e'를 쓴다.

트랜스포트

Transport는 nREPL의 전송단을 추상화한 인터페이스이다. 외부에는 전송에 관한 내부 구현에는 관계없이 Transport를 이용하여 데이타의 송수신을 할 수 있다.

Transport 프로토콜

Transpoort는 다음과 같이 프로토콜로 정의되어 있다.

(defprotocol Transport
  (recv [this] [this timeout]
  (send [this msg]))

recv는 다음 수신된 메세지를 읽어 리턴한다. 메세지가 없으면 대기한다. 대기중 timeout이 지나거나 전송 채널이 종료되면 nil을 리턴한다. send는 메시지를 전송 채널에 보내 송신한다. 이것은 Transport 자신을 다시 리턴한다.

FnTransport 타입

Transport 프로토콜의 구현은 FnTransport와 QueueTransport이 있다. QueueTransport는 파이프 트랜스포트로 사용되지만, FnTransport가 실제 bencode를 전송단에서 사용하고 있다.

(deftype FnTransport [recv-fn send-fn close]
  Transport
  (send [this msg] (-> msg clojure.walk/stringify-keys send-fn) this)
  (recv [this] (.recv this Long/MAX_VALUE))
  (recv [this timeout] (clojure.walk/keywordize-keys (recv-fn timeout)))
  java.io.Closeable
  (close [this] (close)))

FnTransport는 송신/수신/종료 함수를 인자로 받아 각각 send/recv/close 메소드에서 사용한다. close는 Transport 프로토콜에서는 없던 메소드이다. send는 자기 자신을 리턴해서 이후 함수에서 다시 사용할 수 있도록 한다.

stringify-keys와 keywordize-key는 맵의 키를 각각 keyword → string, string → kwyword 로 바꾸어 준다.

(clojure.walk/stringify-keys {:a 1})
>> {"a" 1}
(clojure.walk/keywordize-keys {"a" 1})
>> {:a 1}

fn-transport 함수

fn-transport는 실질적으로 Transport 구현을 만들어내는 함수이다.

(defn fn-transport
  ([read write] (fn-transport read write nil))
  ([read write close]
    (let [read-queue (SynchronousQueue.)]
      (future (try
                (while true
                  (.put read-queue (read)))
                (catch Throwable t
                  (.put read-queue t))))
      (FnTransport.
        (let [failure (atom nil)]
          #(if @failure
             (throw @failure)
             (let [msg (.poll read-queue % TimeUnit/MILLISECONDS)]
               (if (instance? Throwable msg)
                 (do (reset! failure msg) (throw msg))
                 msg))))
        write
        close))))

fn-transport 함수도 외부로부터 read/write/close 함수를 인자로 받는다.

이 함수가 가장 먼저 하는 일은 무한 루프를 도는 future를 만들는 것이다. 이 무한 루프에서 SynchronousQueue에 read 함수를 통해 읽은 메시지를 넣는다.

fn-transport 함수는 FnTransport를 생성하는데, write와 close 함수는 그대로 전달하지만 read 함수는 SynchronousQueue의 poll 메소드를 통해 메시지를 읽어들이는 함수 리터럴이다.

E poll(long timeout, TimeUnit unit) 

read 함수는 읽어들인 메시지가 예외인 경우 failure 아톰을 nil → 예외로 변경하고 예외를 던진다. 이후 다른 스레드에서 읽기 시도를 하면 다시 예외를 던지도록 한다.

bencode 함수

bencode 함수는 전송단에 실제의 스트림을 연결하여 전송단을 완성한다.

(defn bencode
  ([^Socket s] (bencode s s s))
  ([in out & [^Socket s]]
    (let [in (PushbackInputStream. (io/input-stream in))
          out (io/output-stream out)]
      (fn-transport
        #(let [payload (rethrow-on-disconnection s (be/read-bencode in))
               unencoded (<bytes (payload "-unencoded"))
               to-decode (apply dissoc payload "-unencoded" unencoded)]
           (merge (dissoc payload "-unencoded")
                  (when unencoded {"-unencoded" unencoded})
                  (<bytes to-decode)))
        #(rethrow-on-disconnection s
           (locking out
             (doto out
               (be/write-bencode %)
               .flush)))
        (fn []
          (.close in)
          (.close out)
          (when s (.close s)))))))

bencode 함수는 내부적으로 fn-transport 함수를 호출하는데, 주요하게 하는 일은 이 함수에 실질적인 작업을 수행하는read/write/close 함수를 제공하는 것이다. 이를 위해서는 스트림이 필요한데, nREPL에서는 스트림으로 소켓을 전달한다. 이 소켓의 InputStream과 OutputStream이 내부적으로 사용되는데 InputStream은 PushbackInputStream으로 필터링한다(이렇게 하는 이유는 bencode.clj의 read-token 함수가 input으로 PushbackInputStream을 받기 때문이다). io/input-stream 과 io/output-stream은 각각 Socket의 getInputStream과 getOutputStream를 호출한다.

이 소켓으로 데이타가 송수신될 때 bencode로 인코딩/디코딩되게 하기 위해 bencode.clj의 read-bencode와 write-bencode 함수가 사용된다. 이 함수들이 사용될 때 예외가 발생되면 처리하기 위하여 rethrow-on-disconnection 매크로가 사용된다. 이를 통해 소켓단에서 발생한 예외를 한 번 더 처리하는데, rethrow-on-disconnection 매크로에서는 SocketException에 설명을 더 붙여 처리하고 있다.

read 함수는 소켓에서 bencode를 읽은 후 “-unencoded” …

write 함수는 out을 locking으로 붙잡고 write-bencode 함수로 메시지를 out에 쓰고 flush를 호출하여 송신을 수행한다.

close 함수는 in과 out 스트림을 닫는다. 소켓 s는 참인 경우에만 닫는다.

클라이언트

nREPL의 클라이언트는 nrepl.clj 소스에서 구현되어 있다. 클라이언트는 서버와 마찮가지로 전송단인 소켓기반 Transport를 사용한다.

클라이언트의 구동

클라이언트는 다음과 같이 구동한다.

=> (require '[clojure.tools.nrepl :as repl])
nil
=> (with-open [conn (repl/connect :port 59258)]                          ; 1.
     (-> (repl/client conn 1000)    ; message receive timeout required   ; 2.
       (repl/message {:op "eval" :code "(+ 2 3)"})                       ; 3.
       repl/response-values))                                            ; 4.
[5]

위 코드는 다음과 같이 작동한다.

  1. 서버 포트 59258에 nrepl/connect 하는 소켓기반 Transport가 conn에 바인딩된다. with-open 매크로 바인딩되었기 때문에 이 매크로 블럭을 벗어나면 conn에 대해 close가 호출된다.
  2. 스레딩 폼을 구성하고 있다. nrepl/client는 첫 파라미터로 Transport를 1. 에서 생성한 conn으로 받고, 두번째 파라미터로 Timeout 값 1000을 받아, 메시지를 전송하는 함수를 리턴한다. 이 함수는 전송된 메시지에 대한 서버의 응답 시퀀스를 리턴한다.
  3. 메시지를 실제로 보낸다. 메시지는 맵으로 구성된다. :op 키워드는 수행할 커맨드를 나타내는데 여기서는 “eval”로 지정했다. :code는 :eval 커맨드로 수행될 실제 코드가 스트링으로 전달된다.
  4. ㄹㄹㄹ

connect 함수 분석

이제 소스를 분석해 보자. 우선 nrepl/connect 함수이다.

(defn connect
  [& {:keys [port host transport-fn] :or {transport-fn transport/bencode
                                          host "localhost"}}]                ; 1.
  {:pre [transport-fn port]}                                                 ; 2.
  (transport-fn (java.net.Socket. ^String host (int port))))                 ; 3.
  1. 파라미터를 맵으로 받은 후 인수분해하면서, 파라미터중 :port, :host, :transport-fn 키워드의 값을 취한다. 만약 :transport-fn 키워드가 없으면 transport/bencode를, :host 키워드가 없으면 “localhost”를 대신 사용한다.
  2. 함수에 대한 선조건문을 지정하고 있다. transport-fn과 port 파라미터는 nil이 아닌 어떤 값이어야 한다.
  3. transport-fn은 소켓을 인자로 받는 함수이다. 소켓은 자바의 소켓 인터롭으로 생성한다.

connect 함수의 trasport-fn 파라미터를 위해 transport 함수를 따로 만들어 주지 않는 한, 보통은 transport/bencode를 사용하게 된다. transport-fn는 Transport 프로토콜을 구현한 Transport 객체를 리턴하는 함수이다. transport/bencode는 Transport 프로토콜을 구현한 FnTransport 객체를 리턴한다. connect 함수는 결국은 전송단을 리턴하는 것이다.

client 함수 분석

다음으로 client 함수의 소스를 보자. 좀 길다.

(defn client
  [transport response-timeout]
  (let [latest-head (atom nil)
        update #(swap! latest-head
                       (fn [[timestamp seq :as head] now]
                         (if (< timestamp now)
                           [now %]
                           head))
                       (System/nanoTime))
        tracking-seq (fn tracking-seq [responses]
                       (lazy-seq
                         (if (seq responses)
                           (let [rst (tracking-seq (rest responses))]
                             (update rst)
                             (cons (first responses) rst))
                           (do (update nil) nil))))
        restart #(let [head (-> transport                              ; 5.
                              (response-seq response-timeout)
                              tracking-seq)]
                   (reset! latest-head [0 head])
                   head)]
    ^{::transport transport ::timeout response-timeout}
    (fn this                                                           ; 1.
      ([] (or (second @latest-head)                                    ; 4.
              (restart)))                                              ; 
      ([msg]                                                           ; 2.
        (transport/send transport msg)                                 ; 
        (this)))))                                                     ; 3.

client 함수는 transport 파라미터로 전송단을 받고, response-time 파라미터로 응답시간을 받는다. 상당히 긴 let 바인딩에서 함수의 대부분의 기능을 담당하고 있는 내부 함수들이 정의되고 있다.

  1. client 함수는 함수를 리턴하는 고계함수이다. client 함수는 파라미터가 없는 경우와, msg만 받는 경우가 있다.
  2. msg 파라미터를 받는 arity의 경우에는 단순히 전송단 transport를 이용해 msg를 전송한 후,
  3. 파라미터가 없는 client를 호출한다.
  4. latest-head는 7. 에서 정의된 아톰으로, 서버 응답에 대한 시퀀스이다. latest-head의 second가 없으면 let 바인딩에서 정의된 restart가 호출된다.
  5. restart는 latest-head를 초기화하고, 서버로 받은 응답을 시퀀스로 만든다.

client 함수는 함수를 리턴하는데, 이 함수가 하는 일은 메시지를 받아 서버로 전송한 후, 서버로부터 메시지를 받아 메시지의 시퀀스를 리턴하는 것이다. 이 함수가 메시지를 받아 호출되면 메시지를 전송하고 바로, 메시지를 받을 준비를 한다. 메시지를 받을 준비를 하는 것은 파라미터가 없는 함수이다.

이제 메시지를 받는 과정을 알아보자.

reset 내부 함수

이것은 client함수의 내부 함수 restart 함수에서 시작된다.

reset 내부 함수 :

#(let [head (-> transport                           ; 1.
                (response-seq response-timeout)
                tracking-seq)]
    (reset! latest-head [0 head])                   ; 2.
    head)

reset 함수가 하는 일은 2 가지이다.

  1. transport 전송단을 이용해 서버로부터 메시지를 받는 함수 response-seq를 호출하여 시퀀스로 만든 후, 다시 그 시퀀스를 처리하기 위해 tracking-seq를 호출하고, 그 결과를 head 지역변수로 받는다.
  2. latest-head를 초기화하고 head를 리턴한다. 초기값은 최초를 의미하는 0과 서버로부터 받은 메시지 시퀀스의 벡터이다.

다음은 reset 함수를 지원하는 함수 response-seq이다.

(defn response-seq
  ([transport] (response-seq transport Long/MAX_VALUE))
  ([transport timeout]
    (take-while identity (repeatedly #(transport/recv transport timeout)))))

이 함수는 transport 파라미터를 전달된 FnTransport 객체의 Transport 프로토콜의 recv함수를 호출하는 lazy 시퀀스를 repeatedly 함수로 만든 후, take-while로 유효한 범위까지의 시퀀스만 받아들인다. identity는 자기자신을 리턴하는 함수여서 recv함수가 nil을 리턴하면 거기까지가 유효한 범위가 된다. 결국 response-seq는 서버로부터 받은 메시지의 시퀀스를 리턴하게 된다. 이것은 lazy 시퀀스가 아니다.

tracking-seq 내부 함수

다음은 reset 함수를 지원하는 함수 tracking-seq이다. 이 함수는 client 함수의 내부 함수이다.

tracking-seq :

(fn tracking-seq [responses]
  (lazy-seq
    (if (seq responses)                              ; 1.
        (let [rst (tracking-seq (rest responses))]   ; 3.
            (update rst)                             ; 4.
            (cons (first responses) rst))            ; 5.
        (do (update nil) nil))))                     ; 2.

tracking-seq 함수는 전송단에서 받은 메시지의 시퀀스를 인자로 받아 특별한 처리를 하는 lazy 시퀀스를 만든다. 이 함수가 lazy-seq를 사용한 방식은 특이하다. 하나씩 살펴보자.

  1. responses 시퀀스가 비어 있는지 검사한다. seq는 컬렉션이 비어있으면 nil을 반환한다.
  2. 비어 있으면, nil을 인자로 update 함수를 호출하고, nil을 반환한다.
  3. 비어 있지 않으면, (rest responses)로 다시 tracking-seq를 호출한다. 이것은 LazySeq 객체를 리턴하여 rst로 받는다.
  4. 이후 rst로 update 함수를 호출하고,
  5. cons로 정상적인 lazy 시퀀스를 리턴한다.

tracking-seq 함수는 lazy-seq 매크로를 바로 호출하여 LazySeq 객체를 리턴한다. 이 객체는 최초 접근시에만 폼을 평가하고, 그 결과를 캐쉬한 후, 이후 접근시에는 캐쉬한 결과를 리턴한다. tracking-seq 함수가 최초 호출되어 리턴된 LazySeq 객체가 처음 평가될 때, 다시 tracking-seq가 호출되어 리턴된 LazySeq 객체를 rst로 받는다. 이후 rst는 update 함수와 cons 함수에서 사용된다. 특히 cons는 responses의 첫 요소와 rst로 된 Sequable 객체를 리턴한다. 이 과정은 rst의 LazySeq를 접근하게 될 때 다시 반복되는데 responses가 빌 때가지 계속된다.

결국 tracking-seq 함수가 리턴하는 것은 responses 시퀀스로된 lazy 시퀀스이다. 즉 다음과 같은 것이다.

(defn silly-tracking-seq [lst]
  (if (seq lst)
    (cons (first lst) (lazy-seq (tracking-seq (rest lst))))
    nil))

하지만 이 함수는 부수적으로 update 함수를 통해 다른 일을 하는 것이 진짜 목적이다.

update 내부 함수

이제 client 함수의 내부 함수인 update 함수를 보자.

update:

 #(swap! latest-head
    (fn [[timestamp seq :as head] now]
      (if (< timestamp now)
        [now %]
        head))
    (System/nanoTime))

update 함수는 swap!을 통해 latest-head를 수정한다. 수정하는 함수의 첫 인자는 latest-head인데 벡터 인수분해 되고 있고, 두번째 파라미터 now는 (System/nanoTime)을 받은 시스템 타이머 값이다. latest-head는 처음 요소가 timestamp로 두번째 요소가 seq로, 그리고 전체를 head로, 각각 벡터 인수분해되고 있다. 이 함수는 현재 시간을 비교해서 latest-head 시간이 앞서 있으면, latest-head를 현재 시간과 update 함수의 인자로 받은 LazySeq으로 된 벡터로 수정한다.

update 함수는 atom은 latest-head를 수정하는 것인데, 이를 통해 responses 시퀀스의 각 원소를 순서대로 접근하게 될 때 동기적-비조율적 접근이 가능하게 된다.

client 함수가 하는 일

결국 client 함수가 리턴하는 함수가 리턴하는 것은 서버로부터 받은 메시지에 대한 동기적-비조율적인 원소 접근(접근시의 시각 포함해서)이 가능한 lazy 시퀀스이다.

message 함수 분석

다음은 message 함수 소스이다.

(defn message
  [client {:keys [id] :as msg :or {id (uuid)}}]                  ; 1.
  (let [f (delimited-transport-seq client #{"done"} {:id id})]   ; 3. 
    (f (assoc msg :id id))))                                     ; 2.
  1. 첫 인자 client는 repl/client함수가 리턴한 함수가 되고, 나머지 인자는 맵으로 받아서 맵 인수분해를 하고 있다. 맵 인수분해에서는 id만 관심을 갖는데, :id가 없는 경우 uuid를 호출하고 있다. uuid는 misc.clj에 있는 함수이다.
  2. 두번재 인자로 받은 msg에 uuid를 추가한 후 전송한다.
  3. delimited-transport-seq 함수는 서버로부터의 응답 메시지중 필요한 것만 뽑아내는 기능을 client 함수에 추가한다.

message 함수의 첫 인자는 repl/client 함수에서 만든 함수로서 이미 설명한 바 있다. 이 함수는 서버로부터 받은 메시지에 대해 동기적-비조율적 접근 가능한 lazy 시퀀스를 만들어 낸다. message 함수는 이 시퀀스를 한 번 더 필터링하기 위해 delimited-transport-seq 함수를 사용한다.

다음은 delimited-transport-seq 함수의 소스이다.

(defn- delimited-transport-seq
  [client termination-statuses delimited-slots]
  (with-meta
    (comp (partial take-until (comp #(seq (clojure.set/intersection            ; 4.
                                               % termination-statuses))   
                                    set
                                    :status)) 
          (let [keys (keys delimited-slots)]
            (partial filter #(= delimited-slots (select-keys % keys))))        ; 3.
          client                                                               ; 2.
          #(merge % delimited-slots))                                          ; 1.
    (-> (meta client)
      (update-in [::termination-statuses] (fnil into #{}) termination-statuses)
      (update-in [::taking-until] merge delimited-slots))))

delimited-transport-seq 함수도 comp를 사용하여 함수를 만들어 리턴하는 고계 함수이다. comp를 사용하고 있기 때문에 뒤에서부터 살펴보자.

  1. comp가 만드는 함수에 전달되는 인자는 메시지이다. 메시지는 맵 형태인데, delimited-slots와 병합하고 있다.
  2. 위의 결과, 즉 delimited-slots와 병합한 메시지를 인자로 해서 client 함수 호출.
  3. client 함수 호출 결과인 lazy 시퀀스들 중에서 delimited-slots과 같은 메시지만 필터링한다. select-keys는 첫 인자로 주어진 맵에서 두번째 인자로 주어진 키만으로 된 맵을 리턴한다. delimited-slots이 {:id uuid}였기 때문에, 결국 이것은 클라이언트가 보낸 메시지의 uuid와 같은 uuid를 지닌 서버 응답 메시지만 골라낸다.
  4. 위의 결과에서 :status가 termination-statuses인 요소를 만날 때까지 가져온다. take-until 함수는 take-while과 같은데, 판단함수를 만족할 때까지의 시퀀스를 리턴한다. 여기서는 “done”을 만날때까지가 되겠다.

message 함수에서 한가지 이상한 것은 with-meta를 사용하고 있다는 점이다. comp로 만들어진 함수에 client의 메타데이타에 termination-statuses와 delimited-slots를 갱신한 메타데이타를 설정하고 있다. 하지만 이 메타데이타가 사용되는 곳을 찾지 못했다.

response-values 함수 분석

다음은 response-values 함수이다.

(defn response-values
  [responses]
  (->> responses
    (map read-response-value)
    combine-responses
    :value))
(defn read-response-value
  [{:keys [value] :as msg}]
  (if-not (string? value)
    msg
    (try
      (assoc msg :value (read-string value))
      (catch Exception e
        (throw (IllegalStateException. (str "Could not read response value: " value) e))))))
(defn combine-responses
  [responses]
  (reduce
    (fn [m [k v]]
      (case k
        (:id :ns) (assoc m k v)
        :value (update-in m [k] (fnil conj []) v)
        :status (update-in m [k] (fnil into #{}) v)
        :session (update-in m [k] (fnil conj #{}) v)
        (if (string? v)
          (update-in m [k] #(str % v))
          (assoc m k v))))            
    {} (apply concat responses)))

서버

nREPL의 서버는 server.clj 에서 구현되었다.

서버의 구동

서버를 구동시키기 위해서는 start-server 함수를 호출해야 한다.

(defn start-server
  [& {:keys [port bind transport-fn handler ack-port greeting-fn] :or {port 0}}]     ; 1.
  (let [bind-addr (if bind (InetSocketAddress. bind port) (InetSocketAddress. port)) ; 2.
        ss (ServerSocket. port 0 (.getAddress bind-addr))
        server (assoc                                                                ; 3.
                 (Server. ss
                          (.getLocalPort ss)
                          (atom #{})
                          (or transport-fn t/bencode)
                          greeting-fn
                          (or handler (default-handler)))
                 ;; TODO here for backward compat with 0.2.x; drop eventually
                 :ss ss)]
    (future (accept-connection server))                                              ; 4.
    (when ack-port
      (ack/send-ack (:port server) ack-port))                                        ; 5.
    server))
  1. 이 함수는 인자들을 맵으로 받아 맵 인수분해를 하고 있다. 다음은 함수의 파라미터들에 대한 설명이다.
    • port : 서버 리스닝 포트. 기본으로 0으로 설정되어 있어, 시스템에서 빈 포트로 자동 할당.
    • bind : 서버 리스닝 IP 주소. 넷트웍 인터페이스가 여러 개일 경우 지정. 기본은 0.0.0.0.
    • handler : 클라이언트 메시지를 처리하기 위한 핸들러. 기본은 defualt-handler.
    • ack-port : 어떤 포트값이 주어진다면, 새로 구동되는 서버의 포트를 알려주기 위한 다른 서버 포트. 클로져 도구 구현시에만 유용.
  2. InetSocketAddress 자바 클래스를 사용하여 bind-address를 만들고 있다. bind가 참일 경우는 해당 ip주소를 사용하고 아닐경우에는 localhost가 된다.
  3. ServerSocket 자바 클래스를 서버 소켓을 생성하여 ss로 받는다.
  4. Server는 server.clj에서 정의된 defrecord이다. Server 레코드 생성후 :ss 슬롯 추가.
  5. future 함수로 accept-client 함수를 스레드 구동.
  6. ack-port 서버에 신규 서버의 포트를 알림.

start-server는 ServerSocket 자바 클래스를 활용하여 리스닝 포트를 열고 Server 레코드를 만든 후, accept-connection 함수에 나머지 기능 이관하고 있다.

연결 접속

(defn- accept-connection
  [{:keys [^ServerSocket server-socket open-transports transport greeting handler]   ; 1.
    :as server}]
  (when-not (.isClosed server-socket)
    (let [sock (.accept server-socket)]                                              ; 2.
      (future (let [transport (transport sock)]                                      ; 3.
                (try
                  (swap! open-transports conj transport)                             ; 4.
                  (when greeting (greeting transport))                               ; 5.
                  (handle handler transport)                                         ; 6.
                  (finally
                    (swap! open-transports disj transport)                           ; 7
                    (.close transport)))))
      (future (accept-connection server)))))                                         ; 8
  1. 함수는 Server 레코드를 인자로 받는다. 이 인자는 맵 인수분해되어 다음의 파라미터가 설정된다.
    • server-socket : start-server에서 생성한 ServerSocket 개체.
    • open-transports : 지금까지 전달된 transport의 누적. 처음엔 #{}.
    • transport : 메시지 송수신을 위한 전송단. start-server에 전달된 것이 없으면 기본으로 transport/bencode를 받음.
    • greeting : 처음 접속시 인삿말 전송하는 함수.
    • handler : 메시지를 처리하는 핸들러. start-server에 전달된 것이 없으면 기본으로 default-handler를 받은.
  2. 서버 소켓으로부터 클라이언트의 접속을 받는 코드이다. 접속이 없으면 무한 대기 상태. 서버측 접속 소켓은 sock으로 받음.
  3. 새로운 스레드 생성. 서버측 접속 소켓으로 transport/bencode 함수를 호출하여 FnTransprt 개체 생성하여 transport 지역 변수에 지정.
  4. open-transports 에 새로 생긴 transport 추가.
  5. 환영 인사. 현재는 greering 함수는 nil.
  6. hadler로 transport의 메시지를 처리하기 위해 handle 함수 호출.
  7. finally 처리 : open-transports에서 현재의 transport를 제거하고 종료 처리.
  8. 새로운 스레드 생성. accept-connection 함수 호출하여 다음 클라이언트 접속을 대기한다.

accept-connection 함수는 접속이 들어오면 스레드를 생성하여 접속을 처리하고, 바로 또 스레드를 생성하여 자기 자신을 호출하고 끝난다. 자기자신을 호출하는 새로운 스레드에 의해 무한 루프를 돌게 된다.

접속이 들어오면 환영 인사를 하고 본격적으로 메시지 처리를 하기 위해 handle 함수를 호출하는데, 이 함수는 메시지가 없을 때까지 계속 Transport에서 메시지를 읽어들여 메시지를 처리한다.

접속이 들어올 때마다 해당 접속의 Transport를 open-transports 에 추가한다. 이렇게 하는 이유는 Transport가 쓰레기 수집 처리되지 않게 하기 위해서 인 듯… 그래서 finally 처리시 open-transports 에서 해당 Transport 제거한다.

메시지 수신 및 처리

다음은 handle 함수이다.

(defn handle
  [handler transport]
  (when-let [msg (t/recv transport)]                                        ; 1.
    (repl/myprn msg "server print")
    (future (handle* msg handler transport))                                ; 2.
    (recur handler transport)))                                             
 
(defn handle*
  [msg handler transport]
  (try
    (handler (assoc msg :transport transport))                              ; 3.
    (catch Throwable t
      (log t "Unhandled REPL handler exception processing message" msg))))
  1. Transport 개체의 recv 함수 호출하여 메시지를 받는다. 메시지가 없으면, 즉 nil이면, 종료.
  2. 새로운 스레드 생성하여 받은 메시지를 처리. 이때 handle* 함수 호출.
  3. 받은 메시지에 전송단 추가하여 handler 호출. 본격적인 메시지 처리 수행.

handle 함수는 메시지를 받을 때마다 새로운 스레드를 생성하여 처리하고 있다. 좀 비효율적이지만 이렇게 하면 해당 메시지 처리시 예외가 발생해도, 다음 메시지 처리에 전혀 지장을 주지 않는다는 장점이 있을 듯…

handle* 함수의 handler 파라미터의 인자는 default-handler인데, 이것은 default-middlewares로 스택을 구성하고 unknown-op 함수를 기본 핸들러로 하여 핸들러를 구성하는 함수를 리턴한다.

(defn unknown-op
  [{:keys [op transport] :as msg}]
  (t/send transport (response-for msg :status #{:error :unknown-op :done} :op op)))
 
(def default-middlewares
  [#'clojure.tools.nrepl.middleware/wrap-describe
   #'clojure.tools.nrepl.middleware.interruptible-eval/interruptible-eval
   #'clojure.tools.nrepl.middleware.load-file/wrap-load-file
   #'clojure.tools.nrepl.middleware.session/add-stdin
   #'clojure.tools.nrepl.middleware.session/session])
 
(defn default-handler
  [& additional-middlewares]
  (let [stack (middleware/linearize-middleware-stack (concat default-middlewares          ; 1. 
                                                             additional-middlewares))]
    ((apply comp (reverse stack)) unknown-op)))                                           ; 2.
  1. default-middlewares 와 파라미터 additional-middlewares로 미들웨어 스택을 구성한다.
  2. unknown-op를 기본 핸들러로 하여 미들웨어 스택을 적용하는 함수 리턴.

미들웨어는 핸들러 함수를 인자로 받아서 새로운 기능을 추가한 신규 핸들러 함수를 만들어서 리턴한다. 이 신규 핸들러 함수는 다시 다른 미들웨어가 받아서 또 새로운 기능을 추가한 신규 핸들러를 만들어 낼 수 있다. 이것을 다음과 같이 도식으로 설명할 수 있다.

a-handler -> [b-middleware] -> ab-handler -> [c-middleware] -> abc-handler

a 기능을 하는 핸들러 a-handler가 b 기능을 갖는 미들웨어에 인자로 들어가면 a와 b 기능을 하는 ab-handler가 리턴된다. 다시 ab-handler가 c 기능을 하는 미들웨어 c-middleware에 인자로 들어가면 a와 b와 c 기능을 하는 abc-handler가 리턴되는 식이다.

핸들러와 미들웨어

핸들러는 메시지 인자 하나만을 받는 함수이고, 미들웨어는 핸들러 인자 하나만을 받는 인자이다. 그리고 메시지는 맵이다. 따라서 미들웨어는 다음과 같은 형태를 갖는다.

(defn b-middleware
 [handler-fn]
 (fn [{:keys [key1 key2 ...] :as msg}]
    ...
    (let [msg (assoc msg :key3 value3)]
      (if ...
        (transport/send transport (response-for msg ....))
        (h msg)))))

미들웨어 함수는 기존 핸들러 함수를 인자로 받아 신규 핸들러 함수를 만들어 내므로 fn 함수를 사용하고 있다. 이 함수는 신규 핸들어 함수이므로 msg를 인자로 받는데, msg가 맵이라서 기본적으로 맵 인수분해를 하여, 해당 미들웨어가 관심을 갖는 키워드를 추출한다. 그리고 신규 핸들러 함수가 받는 인자인 msg에 새로운 키워드를 추가하거나 아니면 값을 변경하거나 해서 msg에 새로운 맵으로 만든후 기존 핸들러 함수에 인자로 해서 호출한다.

최초의 핸들러 함수를 기본 핸들러 함수라고 한다. nREPL에서는 기본 핸들러 함수가 unknown-op 이다. 이 함수는 클라이언트가 보낸 메시지가 알 수 없는 코드가 있을 경우에 동작되는 함수이다.

response-for 함수는 클라이언트로부터 받은 msg의 :id와 :session으로 response-data를 구성해서 클라이언트에 응답하는 misc.clj에 정의된 함수이다.

미들웨어는 자기 스스로 기존 핸들러 함수를 무시하고 response-for 함수로 클라이언트에 응답을 보내거나 아니면 단지 메시지를 변경한 다음 기존 핸들어 함수를 호출할 수도 있다.

nREPL에서는 다음 5 개의 미들웨어가 default-middlewares로 지정되어 사용되고 있다.

  • wrap-describe : “describe” op 처리.
  • interruptible-eval : “eval”, “interrupt” op 처리.
  • wrap-load-file : “load-file” op 처리.
  • add-stdin : “eval”, “stdin” op 처리.
  • session : “ls-sessions”, “close”, “clone” op 처리.
  • pr-values : 메시지의 value 값을 프린트.

linearize-middleware-stack 함수는 middleware.clj에서 정의된 함수로 미들웨어의 의존성에 따라 미들웨어들을 재배열시킨다.

미들웨어

nREPL 서버는 작은 단위의 기능들을 조합해서 서버의 기능을 구성해 낸다. 미들웨어는 이러한 작은 단위의 기능을 구현한다. 하지만 미들웨어가 서로 연결되는 순서가 중요해진다. 예를 들어 세션 미들웨어는 사용자의 세션을 메시지에 추가하는데, 이런 후에야 평가 미들웨어가 세션 데이타를 참조할 수 있다. 미들웨어의 순서를 결정하기 위해 각 미들웨어에 미들웨어 설명자를 메타데이타로 정의한다.

미들웨어 설명자

미들웨어 재배치

linearize-middleware-stack

linearize-middleware-stack 함수는 미들웨어 리스트를 입력받아서, 각 미들웨어 설명자에 기술된 미들웨어 의존성 정보에 맞추어서 미들웨어의 순서를 정한다.

(defn linearize-middleware-stack
  [middlewares]
  (->> middlewares
    extend-deps
    (sort-by (comp count (partial apply concat) (juxt :expects :requires)))
    reverse
    (reduce #(conj-sorted % comparator %2) [])
    (map :implemented-by)))

이 함수는 크게 extend-deps 함수에 의존하고 있다.

extend-deps

이 함수는 각 미들웨어 설명자에 기술된 의존성을 확장한다.

(defn- extend-deps
  [middlewares]
  (let [descriptor #(-> % meta ::descriptor)
        middlewares (concat middlewares
                            (->> (map descriptor middlewares)
                              (mapcat (juxt :expects :requires))
                              (mapcat identity)
                              (filter var?)))]
    (doseq [m (remove descriptor middlewares)]
      (binding [*out* *err*]
        (printf "[WARNING] No nREPL middleware descriptor in metadata of %s, see clojure.tools.middleware/set-descriptor!" m)))
    (let [middlewares (set (for [m middlewares]
                             (-> (descriptor m)
                               ; only conj'ing m here to support direct reference to
                               ; middleware dependencies in :expects and :requires,
                               ; e.g. interruptable-eval's dep on
                               ; clojure.tools.nrepl.middleware.pr-values/pr-values
                               (update-in [:handles] (comp set #(conj % m) keys))
                               (assoc :implemented-by m))))]
      (set (for [m middlewares]
             (reduce
               #(update-in % [%2] into (dependencies middlewares % %2))
               m #{:expects :requires}))))))

dependencies

(defn- dependencies
  [set start dir]
  (let [ops (start dir)
        deps (set/select
               (comp seq (partial set/intersection ops) :handles)
               set)]
    (when (deps start)
      (throw (IllegalArgumentException.
               (format "Middleware %s depends upon itself via %s"
                       (:implemented-by start)
                       dir))))
    (concat ops
            (mapcat #(dependencies set % dir) deps))))

comparator

(defn- comparator
  [{a-requires :requires a-expects :expects a-handles :handles}
   {b-requires :requires b-expects :expects b-handles :handles}]
  (or (->> (into {} [[[a-requires b-handles] -1]
                     [[a-expects b-handles] 1]
                     [[b-requires a-handles] 1]
                     [[b-expects a-handles] -1]])
        (map (fn [[sets ret]]
               (and (seq (apply set/intersection sets)) ret)))
        (some #{-1 1}))
      0))

미들웨어 함수들

session

add-stdin

wrap-describe

interruptible-eval

wrap-load-file

pr-values

2)
클로져 1.0은 ^리더 매크로를 meta의 축약형으로 제공했지만, 클로져 1.1에서는 사용이 비승인되었다가, 클로져 1.2에서부터 #^가 사용이 비승인되면서 대신 사용하게 되었다. 하지만 #^은 사용은 아직 가능하다.
lecture/nrepl/sources.txt · Last modified: 2019/02/04 14:26 (external edit)