<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    Java-Android-jwebee
    Java-Android-jwebee
    對IT人來說,要成為一個優秀的技術型管理者,除了需要具備扎實的技術基礎之外,還應該培養良好的人際關系能力、談判與溝通技能、客戶關系與咨詢技能、商業頭腦和財務技能以及創新意識,此外還要有巧妙的激勵技巧和化解沖突與解決突發問題的能力.

    作者 Alex Ruiz and Jeff Bay 譯者 沙曉蘭 發布于 2008年3月12日 上午1時4分

    社區
    Java
    主題
    領域特定語言

    簡介

    領域特定語言(DSL)通常被定義為一種特別針對某類特殊問題的計算機語言,它不打算解決其領域外的問題。對于DSL的正式研究已經持續很多年,直到最近,在程序員試圖采用最易讀并且簡煉的方法來解決他們的問題的時候,內部DSL意外地被寫入程序中。近來,隨著關于Ruby和其他一些動態語言的出現,程序員對DSL的興趣越來越濃。這些結構松散的語言給DSL提供某種方法,使得DSL允許最少的語法以及對某種特殊語言最直接的表現。但是,放棄編譯器和使用類似Eclipse這樣最強大的現代集成開發環境無疑是該方式的一大缺點。然而,作者終于成功地找到了這兩個方法的折衷解決方式,并且,他們將證明該折衷方法不但可能,而且對于使用Java這樣的結構性語言從面向DSL的方式來設計API很有幫助。本文將描述怎樣使用Java語言來編寫領域特定語言,并將建議一些組建DSL語言時可采用的模式。

     

    Java適合用來創建內部領域特定語言嗎?

    在我們審視Java語言是否可以作為創建DSL的工具之前,我們首先需要引進“內部DSL”這個概念。一個內部DSL在由應用軟件的主編程語言創建,對定制編譯器和解析器的創建(和維護)都沒有任何要求。Martin Fowler曾編寫過大量各種類型的DSL,無論是內部的還是外部的,每種類型他都編寫過一些不錯的例子。但使用像Java這樣的語言來創建DSL,他卻僅僅一筆帶過。

    另外還要著重提出的很重要的一點是,在DSL和API兩者間其實很難區分。在內部DSL的例子中,他們本質上幾乎是一樣的。在聯想到DSL這個詞匯的時候,我們其實是在利用主編程語言在有限的范圍內創建易讀的API。“內部DSL”幾乎是一個特定領域內針對特定問題而創建的極具可讀性的API的代名詞。

    任何內部DSL都受它基礎語言的文法結構的限制。比如在使用Java的情況下,大括弧,小括弧和分號的使用是必須的,并且缺少閉包和元編程有可能會導致DSL比使用動態語言創建來的更冗長。

    但從光明的一面來看,通過使用Java,我們同時能利用強大且成熟的類似于Eclipse和IntelliJ IDEA的集成開發環境,由于這些集成開發環境“自動完成(auto-complete)”、自動重構和debug等特性,使得DSL的創建、使用和維護來的更加簡單。另外,Java5中的一些新特性(比如generic、varargs 和static imports)可以幫助我們創建比以往任何版本任何語言都簡潔的API。

    一般來說,使用Java編寫的DSL不會造就一門業務用戶可以上手的語言,而會是一種業務用戶也會覺得易讀的語言,同時,從程序員的角度,它也會是一種閱讀和編寫都很直接的語言。和外部DSL或由動態語言編寫的DSL相比有優勢,那就是編譯器可以增強糾錯能力并標識不合適的使用,而Ruby或Pearl會“愉快接受”荒謬的input并在運行時失敗。這可以大大減少冗長的測試,并極大地提高應用程序的質量。然而,以這樣的方式利用編譯器來提高質量是一門藝術,目前,很多程序員都在為盡力滿足編譯器而非利用它來創建一種使用語法來增強語義的語言。

    利用Java來創建DSL有利有弊。最終,你的業務需求和你所工作的環境將決定這個選擇正確與否。

    將Java作為內部DSL的平臺

    動態構建SQL是一個很好的例子,其建造了一個DSL以適合SQL領域,獲得了引人注意的優勢。

    傳統的使用SQL的Java代碼一般類似于:

    String sql = "select id, name " +
    "from customers c, order o " +
    "where " +
    "c.since >= sysdate - 30 and " +
    "sum(o.total) > " + significantTotal + " and " +
    "c.id = o.customer_id and " +
    "nvl(c.status, 'DROPPED') != 'DROPPED'";

    從作者最近工作的系統中摘錄的另一個表達方式是:

    Table c = CUSTOMER.alias();
    Table o = ORDER.alias();
    Clause recent = c.SINCE.laterThan(daysEarlier(30));
    Clause hasSignificantOrders = o.TOTAT.sum().isAbove(significantTotal);
    Clause ordersMatch = c.ID.matches(o.CUSTOMER_ID);
    Clause activeCustomer = c.STATUS.isNotNullOr("DROPPED");
    String sql = CUSTOMERS.where(recent.and(hasSignificantOrders)
    .and(ordersMatch)
    .and(activeCustomer)
    .select(c.ID, c.NAME)
    .sql();

    這個DSL版本有幾項優點。后者能夠透明地適應轉換到使用PreparedStatement的方法——用String拼寫SQL的版本則需要大量的修改才能適應轉換到使用捆綁變量的方法。如果引用不正確或者一個integer變量被傳遞到date column作比較的話,后者版本根本無法通過編譯。代碼“nvl(foo, 'X') != 'X'”是Oracle SQL中的一種特殊形式,這個句型對于非Oracle SQL程序員或不熟悉SQL的人來說很難讀懂。例如在SQL Server方言中,該代碼應該這樣表達“(foo is null or foo != 'X')”。但通過使用更易理解、更像人類語言的“isNotNullOr(rejectedValue)”來替代這段代碼的話,顯然會更具閱讀性,并且系統也能夠受到保護,從而避免將來為了利用另一個數據庫供應商的設施而不得不修改最初的代碼實現。

    使用Java創建內部DSL

    創建DSL最好的方法是,首先將所需的API原型化,然后在基礎語言的約束下將它實現。DSL的實現將會牽涉到連續不斷的測試來肯定我們的開發確實瞄準了正確的方向。該“原型-測試”方法正是測試驅動開發模式(TDD-Test-Driven Development)所提倡的。

    在使用Java來創建DSL的時候,我們可能想通過一個連貫接口(fluent interface)來創建DSL。連貫接口可以對我們所想要建模的領域問題提供一個簡介但易讀的表示。連貫接口的實現采用方法鏈接(method chaining)。但有一點很重要,方法鏈接本身不足以創建DSL。一個很好的例子是Java的StringBuilder,它的方法“append”總是返回一個同樣的StringBuilder的實例。這里有一個例子:

    StringBuilder b = new StringBuilder();
    b.append("Hello. My name is ")
    .append(name)
    .append(" and my age is ")
    .append(age);

    該范例并不解決任何領域特定問題。

    除了方法鏈接外,靜態工廠方法(static factory method)和import對于創建簡潔易讀的DSL來說是不錯的助手。在下面的章節中,我們將更詳細地講到這些技術。

    1.方法鏈接(Method Chaining)

    使用方法鏈接來創建DSL有兩種方式,這兩種方式都涉及到鏈接中方法的返回值。我們的選擇是返回this或者返回一個中間對象,這決定于我們試圖要所達到的目的。

    1.1 返回this

    在可以以下列方式來調用鏈接中方法的時候,我們通常返回this

    • 可選擇的
    • 以任何次序調用
    • 可以調用任何次數

    我們發現運用這個方法的兩個用例:

    1. 相關對象行為鏈接
    2. 一個對象的簡單構造/配置

    1.1.1 相關對象行為鏈接

    很多次,我們只在企圖減少代碼中不必要的文本時,才通過模擬分派“多信息”(或多方法調用)給同一個對象而將對象的方法進行鏈接。下面的代碼段顯示的是一個用來測試Swing GUI的API。測試所證實的是,如果一個用戶試圖不輸入她的密碼而登錄到系統中的話,系統將顯示一條錯誤提示信息。

    DialogFixture dialog = new DialogFixture(new LoginDialog());
    dialog.show();
    dialog.maximize();
    TextComponentFixture usernameTextBox = dialog.textBox("username");
    usernameTextBox.clear();
    usernameTextBox.enter("leia.organa");
    dialog.comboBox("role").select("REBEL");
    OptionPaneFixture errorDialog = dialog.optionPane();
    errorDialog.requireError();
    errorDialog.requireMessage("Enter your password");

    盡管代碼很容易讀懂,但卻很冗長,需要很多鍵入。

    下面列出的是在我們范例中所使用的TextComponentFixture的兩個方法:

    public void clear() {
    target.setText("");
    }

    public void enterText(String text) {
    robot.enterText(target, text);
    }

    我們可以僅僅通過返回this來簡化我們的測試API,從而激活方法鏈接:

    public TextComponentFixture clear() {
    target.setText("");
    return this;
    }

    public TextComponentFixture enterText(String text) {
    robot.enterText(target, text);
    return this;
    }

    在激活所有測試設施中的方法鏈接之后,我們的測試代碼現在縮減到:

    DialogFixture dialog = new DialogFixture(new LoginDialog());
    dialog.show().maximize();
    dialog.textBox("username").clear().enter("leia.organa");
    dialog.comboBox("role").select("REBEL");
    dialog.optionPane().requireError().requireMessage("Enter your password");

    這個結果代碼顯然更加簡潔易讀。正如先前所提到的,方法鏈接本身并不意味著有了DSL。我們需要將解決領域特定問題的對象的所有相關行為相對應的方法鏈接起來。在我們的范例中,這個領域特定問題就是Swing GUI測試。

    1.1.2 對象的簡單構造/配置

    這個案例和上文的很相似,不同是,我們不再只將一個對象的相關方法鏈接起來,取而代之的是,我們會通過連貫接口創建一個“builder”來構建和/或配置對象。

    下面這個例子采用了setter來創建“dream car”:

    DreamCar car = new DreamCar();
    car.setColor(RED);
    car.setFuelEfficient(true);
    car.setBrand("Tesla");

    DreamCar類的代碼相當簡單:

    // package declaration and imports

    public class DreamCar {

    private Color color;
    private String brand;
    private boolean leatherSeats;
    private boolean fuelEfficient;
    private int passengerCount = 2;

    // getters and setters for each field
    }

    盡管創建DreamCar非常簡單,并且代碼也十分可讀,但我們仍能夠使用car builder來創造更簡明的代碼:

    // package declaration and imports

    public class DreamCarBuilder {

    public static DreamCarBuilder car() {
    return new DreamCarBuilder();
    }

    private final DreamCar car;

    private DreamCarBuilder() {
    car = new DreamCar();
    }

    public DreamCar build() { return car; }

    public DreamCarBuilder brand(String brand) {
    car.setBrand(brand);
    return this;
    }

    public DreamCarBuilder fuelEfficient() {
    car.setFuelEfficient(true);
    return this;
    }

    // similar methods to set field values
    }

    通過builder,我們還能這樣重新編寫DreamCar的創建過程:

    DreamCar car = car().brand("Tesla")
    .color(RED)
    .fuelEfficient()
    .build();

    使用連貫接口,再一次減少了代碼噪音,所帶來的結果是更易讀的代碼。需要指出的很重要的一點是,在返回this的時候,鏈中任何方法都可以在任何時候被調用,并且可以被調用任何次數。在我們的例子中,color這個方法我們可想調用多少次就調用多少次,并且每次調用都會覆蓋上一次調用所設置的值,這在應用程序的上下文中可能是合理的。

    另一個重要的發現是,沒有編譯器檢查來強制必需的屬性值。一個可能的解決方案是,如果任何對象創建和/或配置規則沒有得到滿足的話(比如,一個必需屬性被遺忘),在運行時拋出異常。通過從鏈中方法返回中間對象有可能達到規則校驗的目的。

    1.2 返回中間對象

    從連貫接口的方法中返回中間對象和返回this的方式相比,有這樣一些優點:

    • 我們可以使用編譯器來強制業務規則(比如:必需屬性)
    • 我們可以通過限制鏈中下一個元素的可用選項,通過一個特殊途徑引導我們的連貫接口用戶
    • 在用戶可以(或必須)調用哪些方法、調用順序、用戶可以調用多少次等方面,給了API創建者更大的控制力

    下面的例子表示的是通過帶參數的構建函數來創建一個vacation對象的實例:

    Vacation vacation = new Vacation("10/09/2007", "10/17/2007",
    "Paris", "Hilton",
    "United", "UA-6886");

    這個方法的好處在于它可以迫使我們的用戶申明所有必需的參數。不幸的是,這兒有太多的參數,而且沒有表達出他們的目的。“Paris”和“Hilton”所指的分別是目的地的城市和酒店?還是我們同事的名字?:)

    第二個方法是將setter方法對每個參數進行建檔:

    Vacation vacation = new Vacation();
    vacation.setStart("10/09/2007");
    vacation.setEnd("10/17/2007");
    vacation.setCity("Paris");
    vacation.setHotel("Hilton");
    vacation.setAirline("United");
    vacation.setFlight("UA-6886");

    現在我們的代碼更易讀,但仍然很冗長。第三個方案則是創建一個連貫接口來構建vacation對象的實例,如同在前一章節提供的例子一樣:

    Vacation vacation = vacation().starting("10/09/2007")
    .ending("10/17/2007")
    .city("Paris")
    .hotel("Hilton")
    .airline("United")
    .flight("UA-6886");

    這個版本的簡明和可讀性又進了一步,但我們丟失了在第一個版本(使用構建函數的那個版本)中所擁有的關于遺忘屬性的校驗。換句話說,我們并沒有使用編譯器來校驗可能存在的錯誤。這時,對這個方法我們所能做的最好的改進是,如果某個必需屬性沒有設置的話,在運行時拋出異常。

    以下是第四個版本,連貫接口更完善的版本。這次,方法返回的是中間對象,而不是this:

    Period vacation = from("10/09/2007").to("10/17/2007");
    Booking booking = vacation.book(city("Paris").hotel("Hilton"));
    booking.add(airline("united").flight("UA-6886");

    這里,我們引進了PeriodBookingLocationBookableItemHotelFlight)、以及 Airline的概念。在這里的上下文中,airline作為Flight對象的一個工廠;LocationHotel的工廠,等等。我們所想要的booking的文法隱含了所有這些對象,幾乎可以肯定的是,這些對象在系統中會有許多其他重要的行為。采用中間對象,使得我們可以對用戶行為可否的限制進行編譯器校驗。例如,如果一個API的用戶試圖只通過提供一個開始日期而沒有明確結束日期來預定假期的話,代碼則不會被編譯。正如我們之前提到,我們可以創建一種使用文法來增強語義的語言。

    我們在上面的例子中還引入了靜態工廠方法的應用。靜態工廠方法在與靜態import同時使用的時候,可以幫助我們創建更簡潔的連貫接口。若沒有靜態import,上面的例子則需要這樣的代碼:

    Period vacation = Period.from("10/09/2007").to("10/17/2007");
    Booking booking = vacation.book(Location.city("Paris").hotel("Hilton"));
    booking.add(Flight.airline("united").flight("UA-6886");

    上面的例子不及采用了靜態import的代碼那么易讀。在下面的章節中,我們將對靜態工廠方法和import做更詳細的講解。

    這是關于使用Java編寫DSL的第二個例子。這次,我們將Java reflection的使用進行簡化:

    Person person = constructor().withParameterTypes(String.class)
    .in(Person.class)
    .newInstance("Yoda");

    method("setName").withParameterTypes(String.class)
    .in(person)
    .invoke("Luke");

    field("name").ofType(String.class)
    .in(person)
    .set("Anakin");

    在使用方法鏈接的時候,我們必須倍加注意。方法鏈接很容易會被爛用,它會導致許多調用被一起鏈接在單一行中的“火車殘骸”現象。這會引發很多問題,包括可讀性的急劇下滑以及異常發生時棧軌跡(stack trace)的含義模糊。

    2. 靜態工廠方法和Imports

    靜態工廠方法和imports可以使得API更加簡潔易讀。我們發現,靜態工廠方法是在Java中模擬命名參數的一個非常方便的方法,是許多程序員希望開發語言中所能夠包含的特性。比如,對于這樣一段代碼,它的目的在于通過模擬一個用戶在一個JTable中選擇一行來測試GUI:

    dialog.table("results").selectCell(6, 8); // row 6, column 8 

    沒有注釋“// row 6, column 8”,這段代碼想要實現的目的很容易被誤解(或者說根本沒有辦法理解)。我們則需要花一些額外的時間來檢查文檔或者閱讀更多行代碼才能理解“6”和“8”分別代表什么。我們也可以將行和列的下標作為變量來聲明,而非像上面這段代碼那樣使用常量:

    int row = 6;
    int column = 8;
    dialog.table("results").selectCell(row, column);

    我們已經改進了這段代碼的可讀性,但卻付出了增加需要維護的代碼的代價。為了將代碼盡量簡化,理想的解決方案是像這樣編寫代碼:

    dialog.table("results").selectCell(row: 6, column: 8); 

    不幸的是,我們不能這樣做,因為Java不支持命名參數。好的一面的是,我們可以通過使用靜態工廠方法和靜態imports來模擬他們,從而可以得到這樣的代碼:

    dialog.table("results").selectCell(row(6).column(8)); 

    我們可以從改變方法的簽名(signature)開始,通過包含所有參數的對象來替代所有這些參數。在我們的例子中,我們可以將方法selectCell(int, int)修改為:

    selectCell(TableCell); 

    TableCell will contain the values for the row and column indices:

    TableCell將包含行和列的下標值:

    public final class TableCell {

    public final int row;
    public final int column;

    public TableCell(int row, int column) {
    this.row = row;
    this.column = column;
    }
    }

    這時,我們只是將問題轉移到了別處:TableCell的構造函數仍然需要兩個int值。下一步則是將引入一個TableCell的工廠,這個工廠將對初始版本中selectCell的每個參數設置一個對應的方法。另外,為了迫使用戶使用工廠,我們需要將TableCell的構建函數修改為private

    public final class TableCell {

    public static class TableCellBuilder {
    private final int row;

    public TableCellBuilder(int row) {
    this.row = row;
    }

    public TableCell column(int column) {
    return new TableCell(row, column);
    }
    }

    public final int row;
    public final int column;

    private TableCell(int row, int column) {
    this.row = row;
    this.column = column;
    }
    }

    通過TableCellBuilder工廠,我們可以創建對每個參數都有一個調用方法的TableCell。工廠中的每個方法都表達了其參數的目的:

    selectCell(new TableCellBuilder(6).column(8)); 

    最后一步是引進靜態工廠方法來替代TableCellBuilder構造函數的使用,該構造函數沒有表達出6代表的是什么。如我們在之前所實現的那樣,我們需要將構造函數設置為private來迫使用戶使用工廠方法:

    public final class TableCell {

    public static class TableCellBuilder {
    public static TableCellBuilder row(int row) {
    return new TableCellBuilder(row);
    }

    private final int row;

    private TableCellBuilder(int row) {
    this.row = row;
    }

    private TableCell column(int column) {
    return new TableCell(row, column);
    }
    }

    public final int row;
    public final int column;

    private TableCell(int row, int column) {
    this.row = row;
    this.column = column;
    }
    }

    現在我們只需要selectCell的調用代碼中增加內容,包含對TableCellBuilderrow方法的靜態import。為了刷新一下我們的記憶,這是如何實現調用selectCell的代碼:

    dialog.table("results").selectCell(row(6).column(8)); 

    我們的例子說明,一點點額外的工作可以幫助我們克服主機編程語言中的一些限制。正如之前提到的,這只是我們通過使用靜態工廠方法和imports來改善代碼可讀性的很多方法中的一個。下列代碼段是以另一種不同的方法利用靜態工廠方法和imports來解決相同的table坐標問題:

    /**
    * @author Mark Alexandre
    */
    public final class TableCellIndex {

    public static final class RowIndex {
    final int row;
    RowIndex(int row) {
    this.row = row;
    }
    }

    public static final class ColumnIndex {
    final int column;
    ColumnIndex(int column) {
    this.column = column;
    }
    }

    public final int row;
    public final int column;
    private TableCellIndex(RowIndex rowIndex, ColumnIndex columnIndex) {
    this.row = rowIndex.row;
    this.column = columnIndex.column;
    }

    public static TableCellIndex cellAt(RowIndex row, ColumnIndex column) {
    return new TableCellIndex(row, column);
    }

    public static TableCellIndex cellAt(ColumnIndex column, RowIndex row) {
    return new TableCellIndex(row, column);
    }

    public static RowIndex row(int index) {
    return new RowIndex(index);
    }

    public static ColumnIndex column(int index) {
    return new ColumnIndex(index);
    }
    }

    這個方案的第二個版本比第一個版本更具靈活性,因為這個版本允許我們通過兩種途徑來聲明行和列的坐標:

    dialog.table("results").select(cellAt(row(6), column(8));
    dialog.table("results").select(cellAt(column(3), row(5));

    組織代碼

    相比返回中間對象的的方式來說,返回this的方式更加容易組織連貫接口的代碼。前面的案例中,我們的最后結果是使用更少的類來封裝連貫接口的邏輯,并且使得我們可以在組織非DSL代碼的時候使用同樣的規則或約定。

    采用中間對象作為返回類型來組織連貫接口的代碼更具技巧性,因為我們將連貫接口的邏輯遍布在一些小的類上。由于這些類結合在一起作為整體而形成我們的連貫接口,這使得將他們作為整體對待更為合理,我們可能不想將他們和DSL外的其他一些類混淆一起,那么我們有兩個選擇:

    • 將中間對象作為內嵌類創建
    • 將中間對象至于他們自己的頂級類中,將所有這些中間對象類放入同一個包中

    分解我們的系統所采用的方式取決于我們想要實現的文法的幾個因素:DSL的目的,中間對象(如果有的話)的數量和大小(以代碼的行數來計),以及DSL如何來與其它的代碼庫及其它的DSL相協調。

    對代碼建檔

    在組織代碼一章節中提到,對方法返回this的連貫接口建檔比對返回中間對象的連貫接口建檔來的簡單的多,尤其是在使用Javadoc來建檔的情況下。

    Javadoc每次顯示一個類的文檔,這對于使用中間對象的DSL來說可能不是最好的方式:因為這樣的DSL包含一組類,而不是單個的類。由于我們不能改變Javadoc顯示我們的API文檔的方式,我們發現在package.html文件中,加入一個使用連貫接口(包含所有相關類)、且對鏈中每個方法提供鏈接的例子,可以將Javadoc的限制的影響降到最低。

    我們需要注意不要創建重復文檔,因為那樣會增加API創建者的維護代價。最好的方法是盡可能依賴于像可執行文檔那樣的測試。

    結論

    Java適用于創建開發人員易讀易寫的、并且對于商業用戶用樣易讀的內部領域特定語言。用Java創建的DSL可能比那些由動態語言創建的DSL來的冗長。但好的一面是,通過使用Java,我們可以利用編譯器來增強DSL的語義。另外,我們依賴于成熟且強大的Java集成開發環境,從而使DSL的創建、使用和維護更加簡單。

    使用Java創建DSL需要API設計者做更多的工作,有更多的代碼和文檔需要創建和維護。但是,付出總有回報。使用我們API的用戶在他們的代碼庫中會看到更多的優化。他們的代碼將會更加簡潔,更易于維護,這些將使得他們的生活更加輕松。

    使用Java創建DSL有很多種不同的方式,這取決于我們試圖達到的目的是什么。盡管沒有什么通用的方法,我們還是發現結合方法鏈接和靜態工廠方法與imports的方式可以得到干凈、簡潔、易讀易寫的API。

    總而言之,在使用Java來創建DSL的時候有利有弊。這都由我們——開發人員根據項目需求去決定它是否是正確的選擇。

    另外一點題外話,Java 7可能會包含幫助我們創建不那么冗長的DSL的新語言特性(比如閉包)。如果想得到更多關于建議中所提特性的全面的列表,請訪問Alex Miller的blog

    關于作者

    Alex Ruiz是Oracle開發工具組織中的一名軟件工程師。Alex喜歡閱讀任何關于Java、測試、OOP 和AOP的信息,他最大的愛好就是編程。在加入Oracle之前,Alex曾是ThoughtWorks的咨詢顧問。Alex的blog為 http://www.jroller.com/page/alexRuiz

    Jeff Bay是紐約一家對沖基金的高級軟件工程師。他曾多次建立高質量、迅速的XP團隊工作于例如Onstar的計劃注冊系統、租賃軟件、web服務器、建筑項目管理等各種系統。他對于消除重復和防止bug方面懷有極大的熱情,以提高開發者的工作效率和減少在各種任務上所花費的時間。



    jwebee

    我的個人網站
    posted on 2008-03-17 19:03 周行 閱讀(341) 評論(0)  編輯  收藏 所屬分類: IT技術
    Java-Android-jwebee
    主站蜘蛛池模板: 国产精品亚洲二区在线| 中文字幕 亚洲 有码 在线| 美女被免费网站视频在线| 国内精品免费视频自在线| 亚洲人成色777777精品| 免费观看的毛片手机视频| 国产午夜亚洲精品| 国产男女猛烈无遮挡免费视频| 亚洲狠狠婷婷综合久久| 日本不卡视频免费| 一级特黄特色的免费大片视频| 久久久亚洲精品蜜桃臀| 野花香高清视频在线观看免费| 亚洲国产女人aaa毛片在线| 亚洲网站在线免费观看| 456亚洲人成影院在线观| 国产性生交xxxxx免费| 成人在线免费视频| 亚洲Av无码专区国产乱码DVD | 国产亚洲?V无码?V男人的天堂 | 国产专区一va亚洲v天堂| 日韩精品无码免费专区午夜| 亚洲va无码专区国产乱码| 国产成人免费在线| 亚洲国产精品精华液| 国产亚洲色婷婷久久99精品91| 嫩草在线视频www免费观看| 亚洲伊人久久大香线蕉| www国产亚洲精品久久久| 国产精品免费福利久久| 91丁香亚洲综合社区| 亚洲日本中文字幕一区二区三区 | 成人无码WWW免费视频| 33333在线亚洲| 亚洲色偷偷综合亚洲AVYP| 一本岛高清v不卡免费一三区| 亚洲妇女无套内射精| 亚洲AV无码乱码在线观看富二代 | 久久亚洲2019中文字幕| 57pao一国产成视频永久免费| 黄色免费网站在线看|