The Flyweight Pattern

플라이급 패턴은 반복적이고 느리고 비효율적으로 데이터를 공유하는 코드를 최적화하기위한 고전적인 구조 솔루션입니다. 가능한 한 많은 데이터를 관련 객체 (예 : 응용 프로그램 구성, 상태 등)와 공유하여 응용 프로그램에서 메모리 사용을 최소화하는 것을 목표로합니다.

이 패턴은 1990 년 폴 칼더 (Paul Calder)와 마크 린튼 (Mark Linton)에 의해 처음으로 잉태되었으며 112 파운드 이하의 전투기를 포함하는 권투 체중 클래스의 이름을 따서 명명되었습니다. 플라이급이라는 이름 자체는 패턴이 우리가 달성하는 데 도움이되는 작은 무게 (메모리 발자국)를 의미하기 때문에이 가중치 분류에서 파생됩니다.

실제로 플라이급 데이터 공유에는 여러 개체에서 사용되는 유사한 개체 또는 데이터 구조를 여러 개 가져 와서이 데이터를 단일 외부 개체에 배치하는 작업이 포함될 수 있습니다. 우리는이 데이터를 다른 데이터에 저장하는 대신이 데이터를 사용하여이 객체를 전달할 수 있습니다.

Using Flyweights

플라이급 패턴을 적용 할 수있는 두 가지 방법이 있습니다. 첫 번째는 데이터 영역입니다. 여기서는 메모리에 저장된 많은 양의 유사한 객체간에 데이터를 공유하는 개념을 다룹니다.

두 번째는 플라이급을 중앙 이벤트 관리자로 사용하여 부모 컨테이너에있는 모든 자식 요소에 이벤트 핸들러를 연결하는 것을 피하기 위해 유사한 레이어를 사용하는 DOM 레이어에 있습니다.

플라이급 패턴이 전통적으로 가장 많이 사용되는 곳은 데이터 레이어이므로 먼저 살펴 보겠습니다.

Flyweights and sharing data

이 응용 프로그램에는 기존 플라이 웨이트 패턴에 대해 알아야 할 몇 가지 개념이 있습니다. 플라이급 패턴에는 내장 및 외부의 두 가지 상태가 있습니다. 내재적 인 정보는 우리의 목적에있어서 내부적 인 방법에 의해 요구 될 수 있으며, 그들은 절대적으로 기능 할 수 없다. 그러나 외부 정보는 제거하고 외부에 저장할 수 있습니다.

동일한 내장 데이터가있는 객체는 팩토리 메소드로 작성된 단일 공유 객체로 바꿀 수 있습니다. 이를 통해 우리는 암시적인 데이터의 전반적인 양을 상당히 줄일 수 있습니다.

이것의 이점은 이미 인스턴스화 된 객체를 주시하여 고유 한 상태가 우리가 이미 가지고있는 객체와 다른 경우에만 새로운 사본을 생성 할 수 있다는 것입니다.

우리는 관리자를 사용하여 외부 상태를 처리합니다. 이것이 어떻게 구현되는지는 다양하지만, 관리자 객체가 외적 상태와 속한 플라이 웨이트 객체의 중앙 데이터베이스를 포함하도록하는 하나의 접근 방식입니다.

클래식 Flyweights 구현

Flyweight 패턴은 최근 몇 년 동안 JavaScript에서 많이 사용되지 않았으므로 영감을 얻기 위해 사용할 수있는 구현 중 많은 부분이 Java 및 C ++ 환경에서 비롯된 것입니다.

코드에서 Flyweights에 대한 첫 번째 리뷰는 Wikipedia (http://en.wikipedia.org/wiki/Flyweight_pattern)의 Flyweight 패턴 자바 샘플을 구현 한 JavaScript입니다.

우리는이 구현에서 다음과 같은 세 가지 유형의 Flyweight 구성 요소를 사용할 것입니다.

  • 플라이급 (flyweight)은 플라이 웨이트가 외부 상태에서 수신 및 작용할 수있는 인터페이스에 해당합니다.
  • 콘크리트 플라이급은 실제로 플라이급 인터페이스를 구현하고 본질적인 상태를 저장합니다. 콘크리트 Flyweights는 공유 가능해야하며 외부의 상태를 조작 할 수 있어야합니다.
  • Flyweight Factory는 플라이급 오브젝트를 관리하고 플라이 웨이트 오브젝트도 생성합니다. 우리의 가중치를 공유하고 개별 인스턴스가 필요할 경우 조회 할 수있는 객체 그룹으로 관리합니다. 객체가 그룹에 이미 생성 된 경우 객체를 반환하고 그렇지 않으면 객체를 풀에 추가하고 객체를 반환합니다.

이는 구현시 다음 정의에 해당합니다.

  • CoffeeOrder : Flyweight
  • CoffeeFlavor : Concrete Flyweight
  • CoffeeOrderContext : Helper
  • CoffeeFlavorFactory : Flyweight Factory
  • testFlyweight : 우리의 Flyweight 테스트

Duck punching "implements"

Duck Punching은 런타임 소스를 수정할 필요없이 언어 또는 솔루션의 기능을 확장 할 수있게 해줍니다. 이 다음 솔루션은 인터페이스 구현을위한 Java 키워드 (implements)를 사용해야하며 기본적으로 JavaScript에서는 찾을 수 없기 때문에 먼저 펀치를 뚫습니다.

Function.prototype.implementsFor는 객체 생성자에서 작동하며 부모 클래스 (함수) 또는 객체를 수락하고 일반 상속 (함수의 경우) 또는 가상 상속 (객체의 경우)을 사용하여 상속합니다.

// Simulate pure virtual inheritance/"implement" keyword for JS
Function.prototype.implementsFor = function( parentClassOrObject ){
    if ( parentClassOrObject.constructor === Function )
    {
        // Normal Inheritance
        this.prototype = new parentClassOrObject();
        this.prototype.constructor = this;
        this.prototype.parent = parentClassOrObject.prototype;
    }
    else
    {
        // Pure Virtual Inheritance
        this.prototype = parentClassOrObject;
        this.prototype.constructor = this;
        this.prototype.parent = parentClassOrObject;
    }
    return this;
};

인터페이스를 명시 적으로 상속받는 함수를 사용하여 implements 키워드 부족을 패치 할 수 있습니다. 아래에서, CoffeeFlavorCoffeeOrder 인터페이스를 구현하고 우리가이 구현에 힘을 실어주는 기능을 객체에 할당하기 위해 인터페이스 메소드를 포함해야합니다.

// Flyweight object
var CoffeeOrder = {

  // Interfaces
  serveCoffee:function(context){},
    getFlavor:function(){}

};


// ConcreteFlyweight object that creates ConcreteFlyweight
// Implements CoffeeOrder
function CoffeeFlavor( newFlavor ){

    var flavor = newFlavor;

    // If an interface has been defined for a feature
    // implement the feature
    if( typeof this.getFlavor === "function" ){
      this.getFlavor = function() {
          return flavor;
      };
    }

    if( typeof this.serveCoffee === "function" ){
      this.serveCoffee = function( context ) {
        console.log("Serving Coffee flavor "
          + flavor
          + " to table number "
          + context.getTable());
      };
    }

}


// Implement interface for CoffeeOrder
CoffeeFlavor.implementsFor( CoffeeOrder );


// Handle table numbers for a coffee order
function CoffeeOrderContext( tableNumber ) {
   return{
      getTable: function() {
         return tableNumber;
     }
   };
}


function CoffeeFlavorFactory() {
    var flavors = {},
    length = 0;

    return {
        getCoffeeFlavor: function (flavorName) {

            var flavor = flavors[flavorName];
            if (typeof flavor === "undefined") {
                flavor = new CoffeeFlavor(flavorName);
                flavors[flavorName] = flavor;
                length++;
            }
            return flavor;
        },

        getTotalCoffeeFlavorsMade: function () {
            return length;
        }
    };
}

// Sample usage:
// testFlyweight()

function testFlyweight(){


  // The flavors ordered.
  var flavors = [],

  // The tables for the orders.
    tables = [],

  // Number of orders made
    ordersMade = 0,

  // The CoffeeFlavorFactory instance
    flavorFactory = new CoffeeFlavorFactory();

  function takeOrders( flavorIn, table) {
     flavors.push( flavorFactory.getCoffeeFlavor( flavorIn ) );
     tables.push( new CoffeeOrderContext( table ) );
     ordersMade++;
  }

   takeOrders("Cappuccino", 2);
   takeOrders("Cappuccino", 2);
   takeOrders("Frappe", 1);
   takeOrders("Frappe", 1);
   takeOrders("Xpresso", 1);
   takeOrders("Frappe", 897);
   takeOrders("Cappuccino", 97);
   takeOrders("Cappuccino", 97);
   takeOrders("Frappe", 3);
   takeOrders("Xpresso", 3);
   takeOrders("Cappuccino", 3);
   takeOrders("Xpresso", 96);
   takeOrders("Frappe", 552);
   takeOrders("Cappuccino", 121);
   takeOrders("Xpresso", 121);

   for (var i = 0; i < ordersMade; ++i) {
       flavors[i].serveCoffee(tables[i]);
   }
   console.log(" ");
   console.log("total CoffeeFlavor objects made: " + flavorFactory.getTotalCoffeeFlavorsMade());
}

Converting code to use the Flyweight pattern

다음으로 라이브러리의 모든 책을 관리하는 시스템을 구현하여 Flyweights를 계속 살펴 보겠습니다. 각 도서의 중요한 메타 데이터는 다음과 같이 분류 할 수 있습니다.

  • ID
  • Title
  • Author
  • Genre
  • Page count
  • Publisher ID
  • ISBN

또한 특정 도서를 체크 아웃 한 회원, 체크 아웃 한 날짜 및 예상 반환 날짜를 추적하기 위해 다음 속성을 요구합니다.

  • checkoutDate
  • checkoutMember
  • dueReturnDate
  • availability

따라서 Flyweight 패턴을 사용하여 최적화하기 전에 각 책은 다음과 같이 표현됩니다.

var Book = function( id, title, author, genre, pageCount,publisherID, ISBN, checkoutDate, checkoutMember, dueReturnDate,availability ){

   this.id = id;
   this.title = title;
   this.author = author;
   this.genre = genre;
   this.pageCount = pageCount;
   this.publisherID = publisherID;
   this.ISBN = ISBN;
   this.checkoutDate = checkoutDate;
   this.checkoutMember = checkoutMember;
   this.dueReturnDate = dueReturnDate;
   this.availability = availability;

};

Book.prototype = {

  getTitle: function () {
     return this.title;
  },

  getAuthor: function () {
     return this.author;
  },

  getISBN: function (){
     return this.ISBN;
  },

  // For brevity, other getters are not shown
  updateCheckoutStatus: function( bookID, newStatus, checkoutDate, checkoutMember, newReturnDate ){

     this.id = bookID;
     this.availability = newStatus;
     this.checkoutDate = checkoutDate;
     this.checkoutMember = checkoutMember;
     this.dueReturnDate = newReturnDate;

  },

  extendCheckoutPeriod: function( bookID, newReturnDate ){

      this.id = bookID;
      this.dueReturnDate = newReturnDate;

  },

  isPastDue: function(bookID){

     var currentDate = new Date();
     return currentDate.getTime() > Date.parse( this.dueReturnDate );

   }
};

처음에는 소량의 소장품을 구입할 때 문제가 없을 수 있지만 라이브러리가 확장되어 여러 버전의 도서 및 사본을 제공 할 수있게되면서 관리 시스템이 느려지고 시간이 지남에 따라 느려질 수 있습니다. 수천 개의 책 객체를 사용하면 사용 가능한 메모리를 압도 할 수 있지만이를 향상시키기 위해 Flyweight 패턴을 사용하여 시스템을 최적화 할 수 있습니다.

다음과 같이 데이터를 본질 및 외부 상태로 분리 할 수 있습니다. 도서 개체 (title, author 등)와 관련된 데이터는 본질적이지만 체크 아웃 데이터 (checkoutMember, dueReturnDate 등)는 외부 항목으로 간주됩니다. 사실 이것은 책 속성의 각 조합에 대해 하나의 Book 객체 만 필요하다는 것을 의미합니다. 그것은 여전히 상당한 양의 물체이지만 우리가 이전에 가지고 있던 것보다 훨씬 적습니다.

책 메타 데이터 조합의 다음 단일 인스턴스는 특정 제목이있는 책의 모든 사본간에 공유됩니다.

// Flyweight optimized version
var Book = function ( title, author, genre, pageCount, publisherID, ISBN ) {

    this.title = title;
    this.author = author;
    this.genre = genre;
    this.pageCount = pageCount;
    this.publisherID = publisherID;
    this.ISBN = ISBN;

};

우리가 볼 수 있듯이, 외부 상태는 제거되었습니다. 라이브러리 체크 아웃과 관련된 모든 작업은 관리자에게 전달되며 객체 데이터가 세그먼트 화되면서 팩토리를 인스턴스화에 사용할 수 있습니다.

A Basic Factory

이제 아주 기본적인 공장을 정의합시다. 우리가해야 할 일은 이전에 특정 제목의 책이 시스템 내부에 만들어 졌는지 확인하는 것입니다. 만약 있다면, 우리는 그것을 돌려 줄 것입니다 - 그렇지 않다면 새로운 책이 생성되어 나중에 액세스 할 수 있도록 저장 될 것입니다. 이렇게하면 고유 한 고유 데이터 각각에 대해 하나의 복사본 만 만들 수 있습니다.

// Book Factory singleton
var BookFactory = (function () {
  var existingBooks = {}, existingBook;

  return {
    createBook: function ( title, author, genre, pageCount, publisherID, ISBN ) {

      // Find out if a particular book meta-data combination has been created before
      // !! or (bang bang) forces a boolean to be returned
      existingBook = existingBooks[ISBN];
      if ( !!existingBook ) {
        return existingBook;
      } else {

        // if not, let's create a new instance of the book and store it
        var book = new Book( title, author, genre, pageCount, publisherID, ISBN );
        existingBooks[ISBN] = book;
        return book;

      }
    }
  };

})();

외부 상태 관리

그런 다음 Book 객체에서 제거 된 상태를 어딘가에 저장해야합니다. 다행히 관리자 (우리는 Singleton으로 정의 할 것입니다)를 사용하여 캡슐화 할 수 있습니다. 체크 된 Book 객체와 라이브러리 멤버의 조합을 Book 레코드라고합니다. 우리 매니저는 두 가지를 모두 저장할 것이고 Book 클래스의 플라이급 최적화 중에 제거한 체크 아웃 관련 로직도 포함시킬 것입니다.

// BookRecordManager singleton
var BookRecordManager = (function () {

  var bookRecordDatabase = {};

  return {
    // add a new book into the library system
    addBookRecord: function ( id, title, author, genre, pageCount, publisherID, ISBN, checkoutDate, checkoutMember, dueReturnDate, availability ) {

      var book = BookFactory.createBook( title, author, genre, pageCount, publisherID, ISBN );

      bookRecordDatabase[id] = {
        checkoutMember: checkoutMember,
        checkoutDate: checkoutDate,
        dueReturnDate: dueReturnDate,
        availability: availability,
        book: book
      };
    },
    updateCheckoutStatus: function ( bookID, newStatus, checkoutDate, checkoutMember, newReturnDate ) {

      var record = bookRecordDatabase[bookID];
      record.availability = newStatus;
      record.checkoutDate = checkoutDate;
      record.checkoutMember = checkoutMember;
      record.dueReturnDate = newReturnDate;
    },

    extendCheckoutPeriod: function ( bookID, newReturnDate ) {
      bookRecordDatabase[bookID].dueReturnDate = newReturnDate;
    },

    isPastDue: function ( bookID ) {
      var currentDate = new Date();
      return currentDate.getTime() > Date.parse( bookRecordDatabase[bookID].dueReturnDate );
    }
  };

})();

이러한 변화의 결과로 Book 클래스에서 추출 된 모든 데이터가 이제 BookManager 싱글 톤 (BookDatabase)의 속성에 저장됩니다. 이전에 사용했던 많은 수의 객체보다 훨씬 효율적입니다. 도서 체크 아웃과 관련된 메소드는 본질적으로가 아닌 외부 데이터를 처리하기 때문에 여기에 기반합니다.

이 프로세스는 최종 솔루션에 약간의 복잡성을 추가하지만 성능 문제와 비교할 때 약간의 문제입니다. 데이터가 현명합니다. 같은 책을 30 권 갖고 있다면 이제는 한 번만 저장합니다. 또한 모든 함수는 메모리를 사용합니다. 플라이급 패턴을 사용하면 이러한 기능이 모든 객체가 아닌 한 곳 (관리자)에 존재하므로 메모리 사용량을 절약 할 수 있습니다. 위에서 언급 한 플라이급 최적화되지 않은 버전의 경우 우리는 Book 생성자의 프로토 타입을 사용함에 따라 함수 객체에 대한 링크 만 저장하지만 다른 방법으로 구현 된 경우 모든 책 인스턴스에 대해 함수가 만들어집니다.

The Flyweight pattern and the DOM

DOM (Document Object Model)은 객체가 이벤트를 감지 할 수있게 해주는 두 가지 접근 방식을 지원합니다 - 위에서 아래로 (이벤트 캡처) 또는 아래로 위로 (이벤트 버블 링).

이벤트 캡처에서 이벤트는 가장 바깥 쪽 요소에 의해 처음 캡처되고 가장 안쪽 요소로 전파됩니다. 이벤트 버블 링에서는 이벤트가 캡처되어 가장 안쪽 요소에 전달 된 다음 외부 요소에 전파됩니다.

이런 맥락에서 플라이 웨이트를 설명하는 데 가장 좋은 은유 중 하나는 Gary Chisholm이 작성했으며 다음과 같이 약간 씩 간다.

연못의 관점에서 플라이급을 생각해보십시오. 물고기가 입을 열면 (이벤트) 거품이 표면으로 올라가고 (버블 링) 거품이 표면에 도달하면 (동작) 상단에 앉아있는 파리가 멀리 날아갑니다. 이 예제에서 우리는 버튼을 클릭하여 입을 여는 물고기를 쉽게 바꿀 수 있으며, 버블 링 효과로 거품을 내고 일부 기능을 실행하는 데 날아가는 파리를 실행할 수 있습니다

버블 링은 DOM 계층의 여러 수준에서 정의 된 여러 이벤트 핸들러에서 단일 이벤트 (예 : 클릭)를 처리 할 수있는 상황을 처리하기 위해 도입되었습니다. 이 경우 이벤트 버블 링은 가능한 가장 낮은 레벨에서 특정 요소에 대해 정의 된 이벤트 핸들러를 실행합니다. 거기에서부터 이벤트는 위로 올라 가기 전에 요소를 포함하기까지 거품을냅니다.

Flyweights는 이벤트 버블 링 프로세스를 추가로 조정할 때 사용할 수 있습니다.

예제 1 : 중앙 집중식 이벤트 처리

우리의 첫 번째 실용적인 예로서, 사용자 행동 (예 : 클릭, 마우스 오버)이 실행될 때 비슷한 행동을하는 유사한 요소가 문서에 여러 개 있다고 상상해보십시오.

일반적으로 우리 자신의 아코디언 구성 요소, 메뉴 또는 기타 목록 기반 위젯을 구성 할 때 클릭 이벤트를 부모 컨테이너의 각 링크 요소에 바인딩합니다 (예 : $('ul li a').on(..). 클릭을 여러 요소에 바인딩하기 위해 우리는 아래에서 오는 이벤트를 수신 할 수있는 컨테이너 상단에 Flyweight를 쉽게 연결할 수 있습니다. 그런 다음 필요에 따라 간단하거나 복잡한 로직을 사용하여 처리 할 수 ​​있습니다.

언급 된 구성 요소의 유형이 각 섹션 (예 : 아코디언의 각 섹션)에 대해 동일한 반복 마크 업을 갖는 경우가 많으므로 클릭 할 수있는 각 요소의 동작이 유사하고 유사한 유사 클래스와 관련 될 가능성이 큽니다. 이 정보를 사용하여 아래 기본 플라이급을 사용하여 매우 기본적인 아코디언을 구성합니다.

stateManager 네임 스페이스는 플라이 웨이트 논리를 캡슐화하는 데 사용되며 jQuery는 초기 클릭을 컨테이너 div에 바인딩하는 데 사용됩니다. 페이지의 다른 로직이 유사한 핸들을 컨테이너에 연결하지 않도록 보장하기 위해 언 바인드 이벤트가 먼저 적용됩니다.

이제는 컨테이너의 하위 요소를 정확히 클릭하기 위해 부모를 막론하고 클릭 한 요소에 대한 참조를 제공하는 target 검사를 사용합니다. 그런 다음이 정보를 사용하여 페이지가로드 될 때 실제로 특정 어린이에게 이벤트를 바인딩 할 필요없이 클릭 이벤트를 처리합니다.

HTML
<div id="container">
   <div class="toggle" href="#">More Info (Address)
       <span class="info">
           This is more information
       </span></div>
   <div class="toggle" href="#">Even More Info (Map)
       <span class="info">
          <iframe src="http://www.map-generator.net/extmap.php?name=London&amp;address=london%2C%20england&amp;width=500...gt;"</iframe>
       </span>
   </div>
</div>
JavaScript
var stateManager = {

  fly: function () {

    var self = this;

    $( "#container" )
          .unbind()
          .on( "click", "div.toggle", function ( e ) {
            self.handleClick( e.target );
          });
  },

  handleClick: function ( elem ) {
    $( elem ).find( "span" ).toggle( "slow" );
  }
};

이점은 많은 독립 액션을 공유 액션 (메모리 절약 가능성)으로 변환한다는 것입니다.

예제 2 : 성능 최적화를 위해 Flyweight 사용

두 번째 예제에서는 Flyweights with jQuery를 사용하여 얻을 수있는 성능 향상을 참조 할 것입니다.

James Padolsey는 이전에 빠른 jQuery를 위해 76 바이트라는 기사를 작성했습니다.이 기사에서는 유형 (필터, 각 이벤트 핸들러)에 관계없이 jQuery가 콜백을 해제 할 때마다 함수의 컨텍스트 (DOM 요소 this 키워드와 관련하여).

불행히도 우리 중 많은 사람들이 $() 또는 jQuery()에서 this를 래핑하는 데 익숙해 져 있습니다. 즉, jQuery의 새로운 인스턴스가 단순히 불필요하게 매번 생성되는 것을 의미합니다.

$("div").on( "click", function () {
  console.log( "You clicked: " + $( this ).attr( "id" ));
});

// we should avoid using the DOM element to create a
// jQuery object (with the overhead that comes with it)
// and just use the DOM element itself like this:

$( "div" ).on( "click", function () {
  console.log( "You clicked:"  + this.id );
});

James는 다음과 같은 맥락에서 jQuery의 jQuery.text를 사용하고 싶었지만, 각 반복에서 새로운 jQuery 객체를 만들어야한다는 견해에 동의하지 않았습니다.

$( "a" ).map( function () {
  return $( this ).text();
});

이제 중복 포장과 관련하여 가능한 경우 jQuery의 유틸리티 메소드를 사용하여 jQuery.methodName (예 : jQuery.txt) 대신 jQuery.methodName (예 : jQuery.text)을 사용하는 것이 좋습니다. 여기서 jQuery.fn.text는 methodName이 each() 또는 text. 따라서 jQuery.methodName은 저수준에서 jQuery.fn.methodName을 작동시키는 데 사용되는jQuery.methodName과 같이 함수가 호출 될 때마다 새로운 추상화 수준을 호출하거나 새 jQuery 객체를 생성 할 필요가 없습니다.

그러나 모든 jQuery의 메소드가 대응하는 단일 노드 함수를 가지고 있기 때문에 Padolsey는 jQuery.single 유틸리티를 고안했습니다.

여기에있는 아이디어는 하나의 jQuery 객체가 생성되어 jQuery.single을 호출 할 때마다 사용된다는 것입니다. 사실상 하나의 jQuery 객체 만 생성됩니다. 이것에 대한 구현은 아래에서 확인할 수 있으며 여러 가능한 객체에 대한 데이터를보다 중앙의 단일 구조로 통합 할 때 기술적으로 플라이급이기도합니다.

jQuery.single = (function( o ){

   var collection = jQuery([1]);
   return function( element ) {

       // Give collection the element:
       collection[0] = element;

        // Return the collection:
       return collection;

   };
})();

체인을 사용한 동작의 예는 다음과 같습니다.

$( "div" ).on( "click", function () {

   var html = jQuery.single( this ).next().html();
   console.log( html );

});

참고 : PadQuery는 jQuery 코드를 단순히 캐싱해도 성능이 동일하게 향상 될 수 있다고 생각할 수도 있지만 Padlsey는 $.single()가 여전히 가치가 있으며 더 우수한 실적을 올릴 수 있다고 주장합니다. 캐싱을 전혀 적용하지 말아야하는 것은 아니며,이 방법이 도움이 될 수 있음을 명심하십시오. $ .single에 대한 자세한 내용은 Padolsey의 전체 게시물을 읽는 것이 좋습니다.

results matching ""

    No results matching ""