elasticsearch 入門篇
介紹
elasticsearch是一個高效的、可擴充套件的全文搜尋引擎
基本概念
- Near Realtime(NRT): es是一個接近實時查詢平臺,意味從儲存一條資料到可以索引到資料時差很小,通常在1s內
- Cluster: es是一個分散式、可擴充套件的平臺, 可由一個或多個伺服器通過定義的cluster.name(預設為elasticsearch)標識共建同一個叢集
- Node: 通常一臺伺服器上部署一臺es node,作為叢集的一部分,用於資料的儲存和提供搜尋功能,在一個叢集中節點通過node.name區分,預設在node啟動時隨機生成一個的字串做為節點名稱,可配置
- Index: 類似於關係型資料庫中的database,用於組織一類功能相似的資料,在一個叢集中可以定義任意個索引,索引的名稱只能由小寫字母組成,在資料索引,更新,搜尋,刪除時作為資料標識的一部分
- Type: 類似於關係型資料庫中的table,在Index中可以定義多個Type,原則上一個Type是由相同屬性組成的資料集合
- Document: 類似於關係型資料庫中的record,是資料的最基本儲存單元,使用json形式表示,Document在物理上儲存在Index下,但是在邏輯上會分配到具體的Type下
- Shards & Replica:
一個Index可能儲存大量的資料(超過單個節點的硬體限制),不管是資料儲存還是資料索引,為解決資料單節點儲存並提高併發,es將每一個Index物理分為多個片,從而水平擴充套件儲存容量,提高併發(可以同時對個shard進行索引和搜尋)
為防止某個儲存單元出現故障後資料不能索引的情況,es提供將shard進行復制功能,將主shard出現故障後,複製shard替代主shard進行資料索引操作,已此方式實現其高可用性,因為在搜尋時可以使用複製shard,從而提高的資料搜尋的併發性
在Index建立時可以進行分片數量和複製數量的設定,預設建立每個Index設定5個shard和1個Replica,表示該Index由5個邏輯儲存單元進行儲存,每個邏輯儲存單元具有一個複製節點進行備災,注意,shard只能在建立Index時進行設定,shard數量與document分配到哪個shard上儲存有關(通常使用hash(document _id) % shard num計算 document儲存在哪個shard上)
在es將主shard和replic分片在不同的Node上
安裝
- elasticsearch使用java語言實現,在使用時必須安裝java虛擬機器(目前es1.6和1.7版本均可選擇1.8版本java)
- 下載地址
- 解壓到安裝目錄
C:\Program Files\elasticsearch
- 執行
cd "C:\Program Files\elasticsearch\bin" && elasticsearch.bat
- 安裝到服務
service install elasticsearch
- 啟動服務
net start elasticsearch
- 停止服務
net stop elasticsearch
- 測試
訪問地址: http://localhost:9200
訪問結果:
{
status: 200,
name: "Smart Alec",
cluster_name: "elasticsearch",
version: {
number: "1.6.0",
build_hash: "cdd3ac4dde4f69524ec0a14de3828cb95bbb86d0",
build_timestamp: "2015-06-09T13:36:34Z",
build_snapshot: false,
lucene_version: "4.10.4"
},
tagline: "You Know, for Search"
}
介面
es對外提供標準RESTAPI介面,使用他進行叢集的所有操作:
- 叢集、節點、索引的狀態和統計資訊檢視
- 管理叢集、節點、索引和型別
- 執行CURD操作(建立,更新,讀取,刪除)和索引
- 執行高階搜尋功能,比如排序,分頁,篩選,聚合,js指令碼執行等
格式:curl -X<REST verb> <Node>:<Port>/<Index>/<Type>/<ID>
使用marvel外掛
- 執行
cd "C:\Program Files\elasticsearch\bin" && plugin -i elasticsearch/marvel/latest
- 訪問地址
- marvel提供sense工具呼叫es的RESTAPI藉口, 訪問地址, 以下操作使用sense或使用linux curl命令列練習
狀態查詢
- 叢集狀態查詢
輸入:GET _cat/health?v
輸出:
epoch timestamp cluster status node.total node.data shards pri relo init unassign pending_tasks
1442227489 18:44:49 elasticsearch yellow 1 1 50 50 0 0 50 0
說明:
status:表示叢集的健康狀態,值可能為green,yellow,red, green表示主shard和replica(至少一個)正常,yellow表示主shard正常但replica都不正常,red表示有的主shard和replica都有問題
node.total:表示叢集中節點的數量
- 節點狀態查詢
輸入:GET /_cat/nodes?v
輸出:
host ip heap.percent ram.percent load node.role master name
silence 192.168.1.111 30 51 d * Thunderbird
查詢所有索引
輸入: GET /_cat/indices?v
輸出:
health status index pri rep docs.count docs.deleted store.size pri.store.size
yellow open .marvel-2015.09.02 1 1 93564 0 78.4mb 78.4mb
yellow open .marvel-2015.09.01 1 1 39581 0 45.9mb 45.9mb
建立索引
輸入: PUT /test1?pretty
輸出:
{
"acknowledged" : true
}
查詢所有索引:
health status index pri rep docs.count docs.deleted store.size pri.store.size
yellow open test1 5 1 0 0 575b 575b
說明:
health:由於只執行一個節點,replica不能與主shard在同一node中,因此replica不正常,該index的狀態為yellow
index:為索引名稱
pri:表示主shard個數
rep:表示每個shard的複製個數
docs.count:表示index中document的個數
索引、讀取、刪除文件
索引文件
- 方法1:
輸入:
PUT /test1/user/1?pretty
{"name": "silence1"}
輸出:
{
"_index" : "test1
"_type" : "user",
"_id" : "1",
"_version" : 1,
"created" : true
}
- 方法2:
輸入:
POST /test1/user/2?pretty
{"name": "silence2"}
輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "2",
"_version" : 1,
"created" : true
}
- 方法3:
輸入:
POST /test1/user?pretty
{"name": "silence3"}
輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "AU_MdQoXRYiHSIs7UGBQ",
"_version" : 1,
"created" : true
}
說明: 在索引文件時若需要指定文件ID值則需要使用PUT或者POST提交資料並顯示指定ID值,若需要由es自動生成ID,則需要使用POST提交資料
讀取文件:
輸入: GET /test1/user/1?pretty
輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"_version" : 1,
"found" : true,
"_source":{"name": "silence1"}
}
說明:
_index,_type:表示文件儲存的Index和Type資訊
_id:表示文件的編號
_version:表示文件的版本號,主要用於併發處理時使用樂觀鎖防止髒資料
found:表示請求的文件是否存在
_souce:格式為json,為文件的內容
注意:在之前我們並未建立user的Type,在進行文件索引時自動建立了user,在es中可以不顯示的建立Index和Type而使用預設引數或者根據提交資料自定義,但不建議這麼使用,在不清楚可能導致什麼情況時顯示建立Index和Type並設定引數
刪除文件:
輸入: DELETE /test1/user/1?pretty
輸出:
{
"found" : true,
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"_version" : 2
}
再次讀取文件輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"found" : false
}
刪除索引
輸入: DELETE /test1?pretty
輸出:
{
"acknowledged" : true
}
修改文件
初始化文件輸入:
PUT /test1/user/1?pretty
{"name" : "silence2", "age":28}
修改文件輸入:
PUT /test1/user/1?pretty
{"name" : "silence1"}
讀取文件輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"_version" : 2,
"found" : true,
"_source":{"name" : "silence1"}
}
更新文件
更新資料輸入:
POST /test1/user/1/_update?pretty
{"doc" : {"name" : "silence3", "age":28}}
讀取資料輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"_version" : 3,
"found" : true,
"_source":{"name":"silence3","age":28}
}
更新文件輸入:
POST /test1/user/1/_update?pretty
{"script" : "ctx._source.age += 1"}
讀取文件輸出:
{
"_index" : "test1",
"_type" : "user",
"_id" : "1",
"_version" : 4,
"found" : true,
"_source":{"name":"silence3","age":29}
}
說明:需要POST使用script則必須在elasticsearch/config/elasticsearch.yml配置script.groovy.sandbox.enabled: true
修改(PUT)和更新(POST+_update)的區別在於修改使用提交的文件覆蓋es中的文件,更新使用提交的引數值覆蓋es中文件對應的引數值
根據查詢刪除文件
輸入:
DELETE /test1/user/_query?pretty
{"query" : {"match" : {"name" : "silence3"}}}
輸出:
{
"_indices" : {
"test1" : {
"_shards" : {
"total" : 5,
"successful" : 5,
"failed" : 0
}
}
}
}
獲取文件數量
輸入: GET /test1/user/_count?pretty
輸出:
{
"count" : 0,
"_shards" : {
"total" : 5,
"successful" : 5,
"failed" : 0
}
}
批量操作
輸入:
POST /test1/user/_bulk?pretty
{"index" : {"_id" : 1}}
{"name" : "silence1"}
{"index" : {"_id" : 2}}
{"name" : "silence2"}
{"index" : {}}
{"name" : "silence3"}
{"index" : {}}
{"name" : "silence4"}
輸入:
POST /test1/user/_bulk?pretty
{"update" : {"_id" : 1}}
{"doc" : {"age" : 28}}
{"delete" : {"_id" : 2}}
通過檔案匯入資料: curl -XPOST "localhost:9200/test1/account/_bulk?pretty" --data-binary @accounts.json
Query查詢
查詢可以通過兩種方式進行,一種為使用查詢字串進行提交引數查詢,一種為使用RESTAPI提交requesbody提交引數查詢
獲取所有文件輸入: GET /test1/user/_search?q=*&pretty
POST /test1/user/_search?pretty
{
"query" : {"match_all" : {}}
}
輸出:
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "test1",
"_type": "user",
"_id": "1",
"_score": 1,
"_source": {
"name": "silence1",
"age": 28
}
},
{
"_index": "test1",
"_type": "user",
"_id": "AU_M2zgwLNdQvgqQS3MP",
"_score": 1,
"_source": {
"name": "silence3"
}
},
{
"_index": "test1",
"_type": "user",
"_id": "AU_M2zgwLNdQvgqQS3MQ",
"_score": 1,
"_source": {
"name": "silence4"
}
}
]
}
}
說明:
took: 執行查詢的時間(單位為毫秒)
timed_out: 執行不能超時
_shards: 提示有多少shard參與查詢以及查詢成功和失敗shard數量
hits: 查詢結果
hits.total: 文件總數
_score, max_score: 為文件與查詢條件匹配度和最大匹配度
Query SDL
輸入:
POST /test1/account/_search?pretty
{
"query" : {"match_all":{}},
"size": 2,
"from" : 6,
"sort" : {
"age" : {"order" : "asc"}
}
}
說明:
query: 用於定義查詢條件過濾
match_all: 表示查詢所有文件
size: 表示查詢返回文件數量,若未設定預設為10
from: 表示開始位置, es使用0作為開始索引,常與size組合進行分頁查詢,若未設定預設為0
sort: 用於設定排序屬性和規則
- 使用_source設定查詢結果返回的文件屬性
輸入:
POST /test1/account/_search?pretty
{
"query": {
"match_all": {}
},
"_source":["firstname", "lastname", "age"]
}
輸出:
{
"took": 5,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1000,
"max_score": 1,
"hits": [
{
"_index": "test1",
"_type": "account",
"_id": "4",
"_score": 1,
"_source": {
"firstname": "Rodriquez",
"age": 31,
"lastname": "Flores"
}
},
{
"_index": "test1",
"_type": "account",
"_id": "9",
"_score": 1,
"_source": {
"firstname": "Opal",
"age": 39,
"lastname": "Meadows"
}
}
]
}
}
- 使用match設定查詢匹配值
輸入:
POST /test1/account/_search?pretty
{
"query": {
"match": {"address" : "986 Wyckoff Avenue"}
},
"size" : 2
}
輸出:
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 216,
"max_score": 4.1231737,
"hits": [
{
"_index": "test1",
"_type": "account",
"_id": "4",
"_score": 4.1231737,
"_source": {
"account_number": 4,
"balance": 27658,
"firstname": "Rodriquez",
"lastname": "Flores",
"age": 31,
"gender": "F",
"address": "986 Wyckoff Avenue",
"employer": "Tourmania",
"email": "rodriquezflores@tourmania.com",
"city": "Eastvale",
"state": "HI"
}
},
{
"_index": "test1",
"_type": "account",
"_id": "34",
"_score": 0.59278774,
"_source": {
"account_number": 34,
"balance": 35379,
"firstname": "Ellison",
"lastname": "Kim",
"age": 30,
"gender": "F",
"address": "986 Revere Place",
"employer": "Signity",
"email": "ellisonkim@signity.com",
"city": "Sehili",
"state": "IL"
}
}
]
}
}
說明:根據查詢結果可見在查詢結果中並非只查詢address包含"986 Wyckoff Avenue"的文件,而是包含986,wychoff,Avenue三個詞中任意一個,這就是es分詞的強大之處
可見查詢結果中_score(與查詢條件匹配度)按從大到小的順序排列
此時你可能想要值查詢address包含"986 Wyckoff Avenue"的文件,怎麼辦呢?使用match_phrase
輸入:
POST /test1/account/_search?pretty
{
"query": {
"match_phrase": {"address" : "986 Wyckoff Avenue"}
}
}
可能你已經注意到, 以上query中只有一個條件,若存在多個條件,我們必須使用bool query將多個條件進行組合
輸入:
POST /test1/account/_search?pretty
{
"query": {
"bool" : {
"must":[
{"match_phrase": {"address" : "986 Wyckoff Avenue"}},
{"match" : {"age" : 31}}
]
}
}
}
說明: 查詢所有條件都滿足的結果
輸入:
POST /test1/account/_search
{
"query": {
"bool" : {
"should":[
{"match_phrase": {"address" : "986 Wyckoff Avenue"}},
{"match_phrase": {"address" : "963 Neptune Avenue"}}
]
}
}
}
說明: 查詢有一個條件滿足的結果
輸入:
POST /test1/account/_search
{
"query": {
"bool" : {
"must_not":[
{"match": {"city" : "Eastvale"}},
{"match": {"city" : "Olney"}}
]
}
}
}
說明: 查詢有條件都不滿足的結果
在Query SDL中可以將must, must_not和should組合使用
輸入:
POST /test1/account/_search
{
"query": {
"bool" : {
"must": [{
"match" : {"age":20}
}],
"must_not":[
{"match": {"city" : "Steinhatchee"}}
]
}
}
}
Filters 查詢
在使用Query 查詢時可以看到在查詢結果中都有_score值, _score值需要進行計算, 在某些情況下我們並不需要_socre值,在es中提供了Filters查詢,它類似於Query查詢,但是效率較高,原因:
- 不需要對查詢結果進行_score值的計算
- Filters可以被快取在記憶體中,可被重複搜尋從而提高查詢效率
- range 過濾器, 用於設定條件在某個範圍內
輸入:
POST /test1/account/_search?pretty
{
"query": {
"filtered":{
"query": {
"match_all" : {}
},
"filter": {
"range" : {
"age" : {
"gte" : 20,
"lt" : 28
}
}
}
}
}
}
判斷使用filter還是使用query的最簡單方法就是是否關注_score值,若關注則使用query,若不關注則使用filter
聚合分析
es提供Aggregations支援分組和聚合查詢,類似於關係型資料庫中的GROUP BY和聚合函式,在ES呼叫聚合RESTAPI時返回結果包含文件查詢結果和聚合結果,也可以返回多個聚合結果,從而簡化API呼叫和減少網路流量使用
輸入:
POST /test1/account/_search?pretty
{
"size" : 0,
"aggs" : {
"group_by_gender" : {
"terms" : {"field":"gender"}
}
}
}
輸出:
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1000,
"max_score": 0,
"hits": []
},
"aggregations": {
"group_by_gender": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "m",
"doc_count": 507
},
{
"key": "f",
"doc_count": 493
}
]
}
}
}
說明:
size: 返回文件查詢結果數量
aggs: 用於設定聚合分類
terms: 設定group by屬性值
輸入:
POST /test1/account/_search?pretty
{
"size" : 0,
"aggs" : {
"group_by_gender" : {
"terms" : {
"field":"state",
"order" : {"avg_age":"desc"},
"size" : 3
},
"aggs" : {
"avg_age" : {
"avg" : {"field" : "age"}
},
"max_age" : {
"max" : {"field": "age"}
},
"min_age" : {
"min": {"field":"age"}
}
}
}
}
}
輸出:
{
"took": 9,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1000,
"max_score": 0,
"hits": []
},
"aggregations": {
"group_by_gender": {
"doc_count_error_upper_bound": -1,
"sum_other_doc_count": 992,
"buckets": [
{
"key": "de",
"doc_count": 1,
"max_age": {
"value": 37
},
"avg_age": {
"value": 37
},
"min_age": {
"value": 37
}
},
{
"key": "il",
"doc_count": 3,
"max_age": {
"value": 39
},
"avg_age": {
"value": 36.333333333333336
},
"min_age": {
"value": 32
}
},
{
"key": "in",
"doc_count": 4,
"max_age": {
"value": 39
},
"avg_age": {
"value": 36
},
"min_age": {
"value": 34
}
}
]
}
}
}
說明:根據state進行分類,並查詢每種分類所有人員的最大,最小,平均年齡, 查詢結果按平均年齡排序並返回前3個查詢結果
若需要按照分類總數進行排序時可以使用_count做為sort的field值
在聚合查詢時通過size設定返回的TOP數量,預設為10
在聚合查詢中可任意巢狀聚合語句進行查詢
輸入:
POST /test1/account/_search?pretty
{
"size" : 0,
"aggs" : {
"group_by_age" : {
"range" : {
"field": "age",
"ranges" : [{
"from" : 20,
"to" : 30
}, {
"from": 30,
"to" : 40
},{
"from": 40,
"to": 50
}]
},
"aggs":{
"group_by_gender" : {
"terms" : {"field": "gender"},
"aggs" : {
"group_by_balance" :{
"range" : {
"field":"balance",
"ranges" : [{
"to" : 5000
}, {
"from" : 5000
}
]
}
}
}
}
}
}
}
}
輸出:
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1000,
"max_score": 0,
"hits": []
},
"aggregations": {
"group_by_age": {
"buckets": [
{
"key": "20.0-30.0",
"from": 20,
"from_as_string": "20.0",
"to": 30,
"to_as_string": "30.0",
"doc_count": 451,
"group_by_gender": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "m",
"doc_count": 232,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 9
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 223
}
]
}
},
{
"key": "f",
"doc_count": 219,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 20
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 199
}
]
}
}
]
}
},
{
"key": "30.0-40.0",
"from": 30,
"from_as_string": "30.0",
"to": 40,
"to_as_string": "40.0",
"doc_count": 504,
"group_by_gender": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "f",
"doc_count": 253,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 26
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 227
}
]
}
},
{
"key": "m",
"doc_count": 251,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 21
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 230
}
]
}
}
]
}
},
{
"key": "40.0-50.0",
"from": 40,
"from_as_string": "40.0",
"to": 50,
"to_as_string": "50.0",
"doc_count": 45,
"group_by_gender": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "m",
"doc_count": 24,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 3
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 21
}
]
}
},
{
"key": "f",
"doc_count": 21,
"group_by_balance": {
"buckets": [
{
"key": "*-5000.0",
"to": 5000,
"to_as_string": "5000.0",
"doc_count": 0
},
{
"key": "5000.0-*",
"from": 5000,
"from_as_string": "5000.0",
"doc_count": 21
}
]
}
}
]
}
}
]
}
}
}
使用head外掛
- 執行
cd "C:\Program Files\elasticsearch\bin" && plugin -install mobz/elasticsearch-head
- 訪問地址
相關文章
- ElasticSearch 入門Elasticsearch
- 《ElasticSearch入門》一篇管夠,持續更新Elasticsearch
- Elasticsearch入門,看這一篇就夠了Elasticsearch
- Elasticsearch入門教程Elasticsearch
- Elasticsearch 入門使用Elasticsearch
- Elasticsearch核心技術(二):Elasticsearch入門Elasticsearch
- Elasticsearch 基礎入門Elasticsearch
- ElasticSearch基礎入門Elasticsearch
- ElasticSearch入門檢索Elasticsearch
- ElasticSearch 入門簡介Elasticsearch
- Elasticsearch(windows)使用入門ElasticsearchWindows
- Elasticsearch 極簡入門Elasticsearch
- ElasticSearch入門簡介Elasticsearch
- ElasticSearch實戰-入門Elasticsearch
- 我的Elasticsearch入門Elasticsearch
- Elasticsearch(1):基礎入門Elasticsearch
- Elasticsearch 7.x 之文件、索引和 REST API 【基礎入門篇】Elasticsearch索引RESTAPI
- ElasticSearch 7.8.1 從入門到精通Elasticsearch
- Elasticsearch Query DSL查詢入門Elasticsearch
- Elasticsearch入門及掌握其JavaAPIElasticsearchJavaAPI
- ElasticSearch極簡入門總結Elasticsearch
- Spark入門篇Spark
- 入門篇(一)
- Prezi(入門篇)
- Redis 入門篇Redis
- ElasticSearch7.6 入門學習(一)Elasticsearch
- Elasticsearch快速入門和環境搭建Elasticsearch
- ELK(ElasticSearch,Logstash,Kibana)入門Elasticsearch
- 五、Elasticsearch快速入門案例(1)-CRUDElasticsearch
- 全文搜尋引擎 Elasticsearch 入門教程Elasticsearch
- Elasticsearch入門學習重點筆記Elasticsearch筆記
- Elasticsearch Java High Level REST Client(入門)ElasticsearchJavaRESTclient
- ElasticSearch的Java Api基本操作入門指南ElasticsearchJavaAPI
- SqlSugar ORM 入門到精通【一】入門篇SqlSugarORM
- HBase 基本入門篇
- llvm入門篇LVM
- Flutter入門篇(一)Flutter
- Flutter入門篇(二)Flutter