2014年4月30日 星期三

.Net WebSite/WebApi 限制連線IP

開發人員最怕的就是未經授權或非正常管道使用網站/網路服務

小弟目前專案都是透過WebApi傳遞資料,都是一關卡一關

避免有人不小心知道使用後端服務的方法,所以後端的服務都必須限制連接IP


首先先在Web.config(會跟IIS限制IP and DomainName同步)設定禁止所有IP連接
<configuration>
  <system.webserver>
    <security>
      <ipsecurity allowunlisted="flase">
    </ipsecurity>
    </security>
  </system>
</configuration>

這樣就禁止了所有IP連線,接下來為了方便本機測試功能是否正常,以及允許連線的IP,所以我們要在ipSecurity裡面去設定開放條件
<configuration>
  <system.webserver>
    <security>
      <ipsecurity allowunlisted="flase">
        <add allowed="true" ipaddress="127.0.0.1" />
        <add allowed="true" ipaddress="192.168.1.1" />
      </ipsecurity>
    </security>
  </system>
</configuration>


這時候基本上已經完成了9成

剩下的一成就是要看IIS有沒有開啟IP 位址及網域限制(IP Address and Domain Restrictions)功能

前幾天因為Server沒有安裝所以卡在這裡(Windows Azure 遠端桌面設定)

沒有開啟的話,請到開啟或關閉Windows功能去設定

以上2個動作就完成限制IP連線了


參考:
Web.config ipSecurity
IP Security <ipsecurity>

ASP.NET MVC 4 WebApi 與 Extjs 的結合 -- 換頁重新載入資料

在 Web Form 做 Grid 換頁時,通常會先將資料把所有資料先建入 DataTable,再使用控制項接收這些資料,換頁時,就將這些資料經過處理再顯示。然後再做資料搜尋,會再去要一次資料,如此一來,在每次產生 GridView 時,都會使用同一份資料篩選,等於是網頁上有一份資料在做處理,如果資料量一大,吃的資源也大,處理與顯示時相對的就慢。

使用 Extjs 做 Grid.Panel 分頁,一般來說會先將所有資料載到頁面,再做分頁,只是 Extjs 接收資料格式必須要在最根層加上總數量,這樣才能知道該頁顯示幾頁,總共有幾頁,所以格式必須符合如下:
{
    "totalCount": "6679",
    "topics": [
    {
        // ....
    },
    {
        // ....
    },
    {
        // ....
    },
    {
        // ....
    },
    {
        // ....
    }
    // ...
    ]
}

而符合這種格式必須在 Web Api 段做些許調整,要多寫一個屬性讓 API 能使用,可以參考:Web API, OData, $inlinecount and testing,程式碼如下

public class InlineCountQueryableAttribute : QueryableAttribute
{
    private static MethodInfo _createPageResult =
        typeof(InlineCountQueryableAttribute)
        .GetMethods(BindingFlags.Static | BindingFlags.NonPublic)
        .Single(m => m.Name == "CreatePageResult");

    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        base.OnActionExecuted(actionExecutedContext);

        HttpRequestMessage request = actionExecutedContext.Request;
        HttpResponseMessage response = actionExecutedContext.Response;

        IQueryable result;
        if (response.IsSuccessStatusCode
            && response.TryGetContentValue<IQueryable>(out result))
        {
            long? inlineCount = request.GetInlineCount();
            if (inlineCount != null)
            {
                actionExecutedContext.Response = _createPageResult.MakeGenericMethod(result.ElementType).Invoke(
                    null, new object[] { request, request.GetInlineCount(), request.GetNextPageLink(), result }) as HttpResponseMessage;
            }
        }
    }

    internal static HttpResponseMessage CreatePageResult<T>(HttpRequestMessage request, long? count, Uri nextpageLink, IEnumerable<T> results)
    {
        return request.CreateResponse(HttpStatusCode.OK, new PageResult<T>(results, nextpageLink, count));
    }
}

在 API 加上此屬性:
[InlineCountQueryable]
public IEnumerable GetEmployees()
{
    IEnumerable employees = db.Employees.ToList();

    foreach (Employees employee in employees)
    {
        employee.Employees1 = null;
        employee.Employees2 = null;
    }

    return employees;
}

執行結果如下:
{ 
    Count: 15,
    Items: [
        { ... }, 
        { ... }, 
        ...
    ],
    NextPageLink: XXX,
}

這樣輸出結果與 Extjs Grid.Panel 做分頁要接收的格式已經 95% 相似了,而換頁時通常自動會帶 page 、 start 和 limit,page 感覺只是顯示用,而 start 和 limit 的部分可以使用 API 的 Queryable 來解決,它等同於 Queryable 的 $skip 和 $top:

首先我們先看如何在 grid.Panel 加上分頁,加上一個屬性即可,其中 store 為資料來源:
bbar: Ext.create('Ext.PagingToolbar', {
    store: store,
    displayInfo: true,
    displayMsg: 'Displaying topics {0} - {1} of {2}',
    emptyMsg: "No topics to display",
}),

因為每一次換頁就會與 store 要一次資料,所以必須針對來源做修改:
var store = Ext.create('Ext.data.JsonStore', {
    storeId: 'store',
    model: 'Model',
    pageSize: 3,
    
    proxy: {
        type: 'ajax',
        url: 'http://localhost:8090/api/Control?$inlinecount=allpages',
        reader: {
            type: 'json',
            root: 'Items',
            totalProperty: 'Count',

        },
        startParam: '$skip',
        limitParam: '$top'
    },
    autoLoad: true,
    listeners: {
        load: function (store, records, options) {
        }
    },

});

其中 PageSize 為每頁顯示數量,而 startParam、limitParam 則是改變它換頁帶的參數,最後執行就可以看到每換一次頁就會重新與 API 要一次資料,這樣一來頁面負擔不會太重,同時又做到速度與流量兼顧的結果。


2014年4月29日 星期二

Windows Azure 遠端桌面設定+遠端掛載本機磁碟

相信大家都遇過本機(或測試Server)Run的程式一切正常(完美),但是一上到正式(或其他)Server就一堆問題,絕大多數是因為有些東西沒有設定好(這邊指的是Server上的一些設定)

最近使用Windows Azure建立雲端服務(Cloud Service),為了限制IP使用,在Web.config設定允許IP列表,本機、同事的電腦...等等測試一切正常,但是一上到Azure就完全無效

因為也是最近才接觸Azure,只用過入口網站跟VS進行設定跟上傳,一直有個疑惑,微軟不可能鎖死Server設定(一定被罵翻),因為太多狀況了,也不可能全部開放,但是遠端桌面及一些Server設定應該是可以才對,花了一些時間終於找到了方法

首先要先設定連線的帳號密碼


然後會開出設定遠端桌面,這時候敲入使用者名稱、密碼、到期日

接著將Azure設定好的遠端連線(rdp)檔下載下來

帳號已經幫你預設好了,敲入密碼就可以連線進去了


參考:
微軟Azure測試心得分享(四) :啟用Virtual Machine (中)
遠端連線至 Windows Azure 雲端服務



補充:
透過VS去上傳專案,真的是有夠慢的,只更新DLL也要傳個老半天,雲端服務又不支援FTP,也不想安裝(或自己開發)檔案傳輸工具,透過David Kuo的協助,原來可以透過遠端桌面掛載本機磁碟的方式達到網路硬碟的功效

利用Azure下載的遠端桌面檔,設定要分享的本機磁碟

進入後就可以在檔案總管看到我們分享進來的本機磁碟

這樣傳檔案就真的方便多了

參考:
遠端桌面本機與遠端檔案互傳 (MS AZURE 適用)

2014年4月21日 星期一

GData APIs ( Google Data APIs ) - Calendar

個人通常記錄未來工作事項都是習慣使用紙本記錄,因為好處是隨身、任意塗改、做不止文字的記錄,但我相信還是有更多的人較喜歡使用 Google 日曆,除了可以記錄工作事項外,且可將事項分類、重要性、附檔、結合 Gmail....等好處多多 ( 請參閱: Google日曆 最佳化:在忙碌瑣事中有效率掌握最重要行程)。

此篇文章只提供重要程式碼,去使用 C# Windows Form 來建置一個視窗程式與 Google 日曆互動。

未下載 GData APIs 且未安裝請參考:GData APIs ( Google Data APIs ) 介紹與安裝

引用參考

using Google.GData.Calendar;
using Google.GData.Client;
using Google.GData.Extensions;

建立 CalendarService

service = new CalendarService(projectid);
service.setUserCredentials(username, password);

若無 projectid,請到 Google Developers Console 申請。

取得 使用者 所有日曆

public void GetUserCalendars()
{
    FeedQuery query = new FeedQuery();
    query.Uri = new Uri("http://www.google.com/calendar/feeds/default");

    AtomFeed calFeed = service.Query(query);

    for (int i = 0; i < calFeed.Entries.Count; i++)
    {
        // Console.WriteLine(calFeed.Entries[i].Title.Text);
        // do something ...
    }
}

取得所有日曆事件

public AtomEntryCollection GetAllEvents()
{
    EventQuery myQuery = new EventQuery(calendarURI);
    EventFeed myResultsFeed = service.Query(myQuery) as EventFeed;

    entries = myResultsFeed.Entries;

    return entries;
}
calendarURI 為 https://www.google.com/calendar/feeds/default/private/full。

取得日曆 日期區間 事件

public AtomEntryCollection GetDateRangeEvents(DateTime startTime, DateTime endTime)
{
    EventQuery myQuery = new EventQuery(calendarURI);
    myQuery.StartTime = startTime;
    myQuery.EndTime = endTime;

    EventFeed myResultsFeed = service.Query(myQuery) as EventFeed;

    entries = myResultsFeed.Entries;

    return entries;
}

建立事件

public void CreateEvent(string title, string content, DateTime dtStart, DateTime dtEnd)
{
    EventEntry entry = new EventEntry();

    entry.Title.Text = title;
    entry.Content.Content = content;

    When eventTime = new When(dtStart, dtEnd);
    entry.Times.Add(eventTime);

    Uri postUri = new Uri(calendarURI);

    AtomEntry insertedEntry = service.Insert(postUri, entry);

}

更新事件

public void UpdateEvent(EventEntry entry)
{
    Uri postUri = new Uri(calendarURI);

    AtomEntry insertedEntry = service.Update(entry);

}

實作

我用以上幾個函式寫了一個視窗程式,下圖為成品




2014年4月13日 星期日

GData APIs ( Google Data APIs ) - Blogger

現在大部分的部落格都已經開放 API 讓開發人員能使用 API 對部落格做 CRUD 的動作,痞克邦、隨意窩、Blogger,甚至包括之前關站的無名小站皆有提供,接下來就開始使用 C# 來撰寫使用 Blogger 的程式。

有許多人都使用 webBrowser 來模擬瀏覽器的動作發布文章,但是 blogger 無法讓你這樣做,你可以試試看,保證你抓不到 tag。

未下載 GData APIs 且未安裝請參考:GData APIs ( Google Data APIs ) 介紹與安裝

引用參考

使用 Google.GData.Client.dll 。
using Google.GData.Client;

建立 Service

username 為帳號 ( Email ),password 為密碼,若帳號有設置兩步驗證的話,請到 應用程式專用密碼 去取得一組密碼,要不然原本帳號密碼是無法使用。
service = new Service("blogger", "blogger-example");
service.Credentials = new GDataCredentials(username, password);

取得文章列表

max-results 為參數,讓回傳的文章數最大為 500 篇。
/* 列出使用者的部落格 */
public Dictionary<string, Uri> GetListUserBlogs()
{
    Dictionary<string, Uri> dicBlogs = new Dictionary<string,Uri>();

    FeedQuery query = new FeedQuery();
    query.Uri = new Uri("http://www.blogger.com/feeds/default/blogs");
    AtomFeed feed = service.Query(query);

    // 發佈文章
    Uri blogPostUri = null;


    if (feed != null)
    {
        foreach (AtomEntry entry in feed.Entries)
        {
            
            for (int i = 0; i < entry.Links.Count; i++)
            {
                if (entry.Links[i].Rel.Equals("http://schemas.google.com/g/2005#post"))
                {
                    blogPostUri = new Uri(entry.Links[i].HRef.ToString() + "?max-results=500");
                    dicBlogs.Add(entry.Title.Text, blogPostUri);
                    
                }
            }
            // return blogPostUri;
            
        }
    }
    return dicBlogs;
}

新增文章

其中 tags 變數為逗點分隔的字串,isDraft 為是否為草稿。
/* 建立新文章且將它送到 blogPostUri */
public AtomEntry CreatePost(Uri blogPostUri, string title, string content, string tags, DateTime dt, bool isDraft)
{
    AtomEntry createdEntry = null;
    if (blogPostUri != null)
    {
        // construct the new entry
        AtomEntry newPost = new AtomEntry();
        newPost.Title.Text = title;
        newPost.Content = new AtomContent();
        newPost.Content.Content = content;

        foreach (string term in tags.Split(new string[] { "," }, StringSplitOptions.RemoveEmptyEntries))
            newPost.Categories.Add(new AtomCategory(term, new AtomUri("http://www.blogger.com/atom/ns#")));

        newPost.Published = dt;
        newPost.IsDraft = isDraft;

        createdEntry = service.Insert(blogPostUri, newPost);
        if (createdEntry != null)
        {
            Console.WriteLine("  New blog post created with title: " + createdEntry.Title.Text);
        }
    }
    return createdEntry;
}

更新文章


/* 編輯文章 */
public void EditEntry(AtomEntry toEdit)
{
    if (toEdit != null)
    {
        // toEdit.Title.Text = "Marriage Woes!";
        toEdit = toEdit.Update();
    }
}

刪除文章


/* 刪除文章 */
public void DeleteEntry(AtomEntry toDelete)
{
    if (toDelete != null)
    {
        toDelete.Delete();
    }
}

實作

我用以上幾個函式寫了一個視窗程式,下圖為成品




2014年4月10日 星期四

GData APIs ( Google Data APIs ) 介紹與安裝

GData 是一種簡單的標準協議,用於網路資料的讀寫。它結合了常見的基於 xml 的數據聚合格式( Atom 與 RSS)以及基於 Atom 發布協議的 Feed 發布體系,並擴展了部分功能用於處理查詢功能。有時,我們需要發送一個查詢請求給服務器,並得到服務器返回的相符的查詢結果,而目前的 Atom 和 RSS 標準都不具備這一功能。GData 讓使用者可以使用聚合 ( syndication ) 的機制來發送請求並接收結果,它使你可以發送數據給 Google,更新那些 Google 已經擁有的資料。

GData 擴展了原有的 RSS 和 Atom 協議,使其從一種單向的聚合變成了雙向的互動,這似乎是大家都在探索的 feed 的未來發展方向,比如微軟的 SSE。

Joe Gregorio 認為,GData 是將RSS、Atom,尤其是 Atom 發布協議 ( Atom Publishing Protocol )與 Amazon 的 Openserch 標準相結合;Maurice Codik 認為 GData 標準使 Google 的數據更加開放,各種應用之間可以更方便地利用這一標準來使用數據;甚至有人認為這使得基於 Google 各種應用的企業門戶雛形開始顯現。

引用:什么是GData(Google Data API)

安裝

首先到 google-gdata 下載並安裝,安裝後路徑在 「 C:\Program Files (x86)\Google\Google Data API SDK\Redist 」,可以找到所有的 dll,往後可以找此路徑下的 dll 引用,而 「 C:\Program Files (x86)\Google\Google Data API SDK\Documentation\Google.GData.Documentation.chm 」 就是使用文件檔。


此篇文章只是稍微介紹與安裝 GData APIs,往後會有系列文章介紹如何使用 dll。

2014年4月7日 星期一

ASP.NET MVC 4 Controller 使用委派 ( delegate ) 做更進階的篩選

委派是事件的基礎。

將委派與具名方法或匿名方法建立關聯,即可具現化 (Instantiated) 委派。 如需詳細資訊,請參閱具名方法匿名方法
委派必須以具有相容傳回型別和輸入參數的方法或 Lambda 運算式具現化。 如需方法簽章中可允許之變異數等級的詳細資訊,請參閱委派中的變異數 (C# 和 Visual Basic)。 若要搭配匿名方法使用,就要同時宣告委派以及其相關聯的程式碼。

以上引用:delegate (C# 參考)

方便處在於可以在類別上宣告後,即可在各個方法函數內使用,可以依照方法不同,而產生結果也不同,但是只用同一委派 ( delegate ) 實現。

這裡使用北風資料庫來實作委派,沒有資料庫的話可以參考:Visual Studio 2012 安裝 Northwind 資料庫並建立 Entity Framework Database First ( .edmx )

在 Controller 中撰寫,傳入訂單編號來搜尋訂單下的訂單細項之產品的名稱,單位價格區間。

先看看資料庫關聯表:

先宣告委派,並且決定傳入傳出參數,關鍵字、最小價格、最大價格:
delegate bool SearchProduct(Order_Details order_detail, string keyword, decimal mixprice, decimal minprice);

類別內宣告委派:
SearchProduct sp;

最後在 Controller 實作並且使用委派:
[HttpGet]
public IEnumerable<Order_Details> SearchProduct(int orderid, string keyword, decimal minprice, decimal maxprice)
{
    sp = (o, k, min, max) =>
    {

        return o.Products.ProductName.Contains(k) ||
               o.Products.UnitPrice >= min &&
               o.Products.UnitPrice <= max;
    };

    var Order_Details = db.Order_Details
        .Include(x => x.Products)
        .Where(x => x.Orders.OrderID == orderid)
        .ToList()
        .Where(x => sp(x, keyword, minprice, maxprice));

    foreach (Order_Details _order_detail in Order_Details)
    {
        _order_detail.Products.Order_Details = null;
    }

    return Order_Details; 
}

執行後呼叫 API - http://localhost:8090/api/Product/SearchProduct?orderid=10285&keyword=Ch&maxprice=18&minprice=17,得到結果:
[
    {
        "OrderID": 10285,
        "ProductID": 1,
        "UnitPrice": 14.4,
        "Quantity": 45,
        "Discount": 0.2,
        "Orders": null,
        "Products": {
            "ProductID": 1,
            "ProductName": "Chai",
            "SupplierID": 1,
            "CategoryID": 1,
            "QuantityPerUnit": "10 boxes x 20 bags",
            "UnitPrice": 18,
            "UnitsInStock": 39,
            "UnitsOnOrder": 0,
            "ReorderLevel": 10,
            "Discontinued": false,
            "Categories": null,
            "Order_Details": null,
            "Suppliers": null
        }
    }
]

為何不在 LINQ 內使用條件判斷? 其實我已經實作過了,會出現「運算式樹狀架構可能不含指派運算子」錯誤,且必須要實例化出來才可以做,以下錯誤程式碼:
[HttpGet]
public IEnumerable<Order_Details> SearchProduct(int orderid, string keyword, decimal minprice, decimal maxprice)
{

    Products _product;

    var Order_Details = db.Order_Details
        .Include(x => x.Products)
        .Where(x => x.Orders.OrderID == orderid &&
            (
                (_product = x.Products) != null &&
                _product.ProductName.Contains(keyword) ||
                _product.UnitPrice >= minprice ||
                _product.UnitPrice <= maxprice
            )

        ).ToList();

    return Order_Details; 
}


2014年4月4日 星期五

ASP.NET MVC 4 Model 中 Virtual 的作用

在 View 端專寫時,顯示某一張表的資料,若未使用到相對應的關聯表,則不載入此表資料;若使用,則載入。這個作用就是延遲載入 ( Lazy Load ),使用再載入,很類似非同步的概念,而在 Model 關聯成員加上 Virtual 關鍵字,就可以做到延遲載入的作用。

寫個例子來表示一下差別,資料庫使用北風資料庫利用 Database first 建置,可以參考 Visual Studio 2012 安裝 Northwind 資料庫並建立 Entity Framework Database First ( .edmx )

接著建置 CRUD 的介面,我是使用 Products 這張資料表建置,可以參考:

建置完成後,顯示頁面如下:

Products Model 內容如下,建置出來就已經有 virtual :
public partial class Products
{

    public Products()
    {
        this.Order_Details = new HashSet<Order_Details>();

    }

    public int ProductID { get; set; }
    public string ProductName { get; set; }
    public Nullable<int> SupplierID { get; set; }
    public Nullable<int> CategoryID { get; set; }
    public string QuantityPerUnit { get; set; }
    public Nullable<decimal> UnitPrice { get; set; }
    public Nullable<short> UnitsInStock { get; set; }
    public Nullable<short> UnitsOnOrder { get; set; }
    public Nullable<short> ReorderLevel { get; set; }
    public bool Discontinued { get; set; }

    public virtual Categories Categories { get; set; }
    public virtual ICollection<Order_Details> Order_Details { get; set; }
    public virtual Suppliers Suppliers { get; set; }

}

在 ProductsController 內 Get 程式碼如下:
public ActionResult Index()
{
    var products = db.Products;
    return View(products.ToList());
}

接著將 Products Model 內的關聯成員的 virtual 關鍵字拿掉後執行畫面如下:

看出差別了嗎? CategoryName 不會顯示出來,我們回到 View 端看一下它的程式碼:
@Html.DisplayNameFor(model => model.Categories.CategoryName)

這就是所謂的延遲載入的差異,如果將 virtual 關鍵字拿掉,就使用到此關聯成員時,就不會載入;而若有 virtual 關鍵字,則會載入。在 Controller 端其實都沒有變,而是 virtual 使它變得更靈活。

如果真的不加也有另外的做法,就是在資料讀取時,就關聯表的資料 Include 進來:
public ActionResult Index()
{
    var products = db.Products.Include(x => x.Categories);
    return View(products.ToList());
}


2014年4月1日 星期二

ASP.NET MVC 4 遇到「參數 @objname 模稜兩可或是所宣告的 @objtype (COLUMN) 有誤。」 之解決方案

今天 MVC 在做 code first 自動移轉時遇到「參數 @objname 模稜兩可或是所宣告的 @objtype (COLUMN) 有誤。」,如下圖


怪了,平常改動資料欄位,也不會產生錯誤,檢查了一下,我的程式碼只是將一對多關聯改成一對一 ( 以下為示意 ):
public class A
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public Guid AId { get; set; }

        public string Name { get; set; }

        // 修改前
        // public ICollection<B> B { get; set; }

        // 修改後
        public B B { get; set; }
    }

    public class B
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public Guid BId { get; set; }

        // ...
    }

後來上網搜尋了一下, EF CodeFirst: Either the parameter @objname is ambiguous or the claimed @objtype (COLUMN) is wrong 有說明這是因為欄位在 rename 時發生錯誤,這不太可能吧!我應該只是將 B 的 A_AId 轉移到 A 的 B_BId,好像沒有 rename 的動作。

接著我將它的移轉檔案叫出來,在「套件管理器主控台」使用指令 Add-Migration Test 將檔案產出,這才發現問題:
public partial class Test : DbMigration
{
    public override void Up()
    {
        DropForeignKey("dbo.B", "A_AId", "dbo.A");
        DropIndex("dbo.B", new[] { "A_AId" });
        RenameColumn(table: "dbo.A", name: "A_AId", newName: "B_BId");
        AddForeignKey("dbo.A", "B_BId", "dbo.B", "BId");
        CreateIndex("dbo.A", "B_BId");
    }
    
    public override void Down()
    {
        DropIndex("dbo.A", new[] { "B_BId" });
        DropForeignKey("dbo.A", "B_BId", "dbo.B");
        RenameColumn(table: "dbo.A", name: "B_BId", newName: "A_AId");
        CreateIndex("dbo.B", "A_AId");
        AddForeignKey("dbo.B", "A_AId", "dbo.A", "AId");
    }
}

Up 在 Migration ( 轉移 ) 時,會執行,而如果發生錯誤則會使用 Down RollBack。

問題就是發生在 Up 的第三行 - RenameColumn( ... ),在 A 資料表並無 B_BId 欄位,但居然要把 B_BId 命名成為 A_AId ? 所以這一看就是有問題,重新執行後問題依舊存在。

所以建議先把一對多關連註解掉運行 API,再將一對一關聯加入在運行 API 後就會正常,或者你也可以就移轉檔案改動:
public override void Up()
{
    DropForeignKey("dbo.B", "A_AId", "dbo.A");
    DropIndex("dbo.B", new[] { "A_AId" });
    DropColumn("dbo.B", "A_AId");

    AddColumn("dbo.A", "B_BId", c => c.Guid());
    AddForeignKey("dbo.A", "B_BId", "dbo.B", "BId");
    CreateIndex("dbo.A", "B_BId");
}

前者解決方法會比自己修改移轉檔案來得好,因為你還是要看程式碼找出錯誤在哪裡,比較花時間。