php使用ElasticSearch

ElasticSearch简介

Elasticsearch 是一个分布式的、开源的搜索分析引擎,支持各种数据类型,包括文本、数字、地理、结构化、非结构化。

Lucene与ElasticSearch


Apache Lucene是一款高性能的、可扩展的信息检索(IR)工具库,是由Java语言开发的成熟、自由开源的搜索类库,基于Apache协议授权。Lucene只是一个软件类库,如果要发挥Lucene的功能,还需要开发一个调用Lucene类库的应用程序。

ElasticSearch在底层利用Lucene完成其索引功能,因此其许多基本概念源于Lucene。ElasticSearch封装了许多lucene底层功能,提供了分布式的服务、简单易用的restful API接口和许多语言的客户端。

倒排索引


Lucene中对文档检索基于倒排索引实现,并将它发挥到了极致。

倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。

例如:

id句子
1I like apples
2I dislike apples
3I dislike apples too

如果要用单词作为索引,而句子的位置作为被索引的元素,那么索引就发生了倒置:

id单词索引
I{1,2,3}
like{1}
apples{1,2,3}
dislike{2,3}
too{3}

如果要检索I dislike apples这句话,那么就可以这么计算 : {1,2,3} ^ {2,3} ^ {1,2,3} (^是交集)

核心概念


  • 索引(Index)

    ES将数据存储于一个或多个索引中,索引是具有类似特性的文档的集合。类比传统的关系型数据库领域来说,索引相当于SQL中的一个数据库,或者一个数据存储方案(schema)。索引由其名称(必须为全小写字符)进行标识,并通过引用此名称完成文档的创建、搜索、更新及删除操作。一个ES集群中可以按需创建任意数目的索引。

  • 类型(Type)

    类型是索引内部的逻辑分区(category/partition),然而其意义完全取决于用户需求。因此,一个索引内部可定义一个或多个类型(type)。一般来说,类型就是为那些拥有相同的域的文档做的预定义。例如,在索引中,可以定义一个用于存储用户数据的类型,一个存储日志数据的类型,以及一个存储评论数据的类型。类比传统的关系型数据库领域来说,类型相当于“表”。

  • 文档(Document)

    文档是Lucene索引和搜索的原子单位,它是包含了一个或多个域的容器,基于JSON格式进行表示。文档由一个或多个域组成,每个域拥有一个名字及一个或多个值,有多个值的域通常称为“多值域”。每个文档可以存储不同的域集,但同一类型下的文档至应该有某种程度上的相似之处。

  • 映射(Mapping)

    ES中,所有的文档在存储之前都要首先进行分析。用户可根据需要定义如何将文本分割成token、哪些token应该被过滤掉,以及哪些文本需要进行额外处理等等。另外,ES还提供了额外功能,例如将域中的内容按需排序。事实上,ES也能自动根据其值确定域的类型。

  • **节点(Node) **

    一个es实例即为一个节点,也是集群的一部分,它存储你的数据,并参与集群的索引和搜索。和集群一样,节点也是通过唯一的名字去区分,默认名字是一个随机的UUID,当服务器启动的时候就会设置到节点。你也可以自定义节点的名称。名称对管理员来说十分重要,它可以帮助你辨认出集群中的各个服务器和哪个节点相对应。

  • 分片(Shard)和副本(Replica)
    ES的“分片(shard)”机制可将一个索引内部的数据分布地存储于多个节点,它通过将一个索引切分为多个底层物理的Lucene索引完成索引数据的分割存储功能,这每一个物理的Lucene索引称为一个分片(shard)。每个分片其内部都是一个全功能且独立的索引,因此可由集群中的任何主机存储。创建索引时,用户可指定其分片的数量,默认数量为5个。

  • 集群(Cluster)

    集群是一个或多个节点(服务器)的集合,它们联合起来保存所有的数据(索引以分片为单位分散到多个节点上保存)并且可以在所有的节点上进行索引和搜索操作。集群通过一个唯一的名字区分,默认的名字是“elasticsearch”。这个名字十分重要,因为一个节点仅仅可以属于一个集群,并根据集群名称加入集群。

与关系型数据库核心概念对比

Elasticsearch关系型数据库(如Mysql)
索引Index数据库Database
类型Type(8.x版本已废弃)表Table
文档Document数据行Row
字段Field数据列Column
映射Mapping约束Schema

数据类型


Elasticsearch 8.x中已经完全删除了数据类型,创建映射时也不再支持使用type字段指定数据类型,不然会报错"index" is not a valid parameter. Allowed parameters are "create", "error_trace"

这里就不继续介绍数据类型了

安装和启动


Windows

1.去官网Download Elasticsearch | Elastic下载windows版本压缩包,然后解压;

2.修改config目录下的elasticsearch.yml文件,将截图中的两个配置项true改为false,不然会报错[WARN ][o.e.h.n.Netty4HttpServerTransport] [BF-202205061541] received plaintext http traffic on an https channel, closing connection Netty4HttpChannel{localAddress=/[0:0:0:0:0:0:0:1]:9200, remoteAddress=/[0:0:0:0:0:0:0:1]:62134}

3.双击bin/elasticsearch.bat脚本启动服务

4.打开浏览器访问http://localhost:9200/,输出下面的json信息表示启动成功

{
    "name": "BF-202205061541",
    "cluster_name": "elasticsearch",
    "cluster_uuid": "0JlZuKgbSWa3DGX44DnxgQ",
    "version": {
        "number": "8.12.2",
        "build_flavor": "default",
        "build_type": "zip",
        "build_hash": "48a287ab9497e852de30327444b0809e55d46466",
        "build_date": "2024-02-19T10:04:32.774273190Z",
        "build_snapshot": false,
        "lucene_version": "9.9.2",
        "minimum_wire_compatibility_version": "7.17.0",
        "minimum_index_compatibility_version": "7.0.0"
    },
    "tagline": "You Know, for Search"
}

Docker

  • 拉取镜像

    docker pull elasticsearch:8.12.2 # 版本号自己选择
    
  • 创建并启动容器

    docker run --name elasticsearch -p 9200:9200 -p 9300:9300 -d elasticsearch:8.12.2
    
  • 设置密码

    docker exec -it elasticsearch # 进入docker
    cd /usr/share/elasticsearch/bin # 进入elasticsearch目录
    ./elasticsearch-setup-passwords interactive #要同时设置内置6个账户的密码
    
  • 访问elasticsearch

    浏览器打开https://192.168.204.128:9200即可访问,需要输入上面设置的账号和密码,注意这里是要https访问

安装分词器


elasticsearch有默认的分词器,但是对中文不太友好,我们可以单独安装适用于中文的分词器ik;根据你的es版本再Releases · infinilabs/analysis-ik (github.com)下载对应的压缩包,然后解压到es目录下的plugins/ik目录,然后重启es服务即可

插件


es-client(推荐)

elasticsearch的浏览插件,项目主页 | es-client (esion.xyz),提供各个浏览器插件,在插件市场都能搜得到,界面美观

elasticsearch-head

elasticsearch-head是一种便捷查询操作elasticsearch库的可视化工具,具备集群管理、增删查改等功能,用于监控 Elasticsearch 状态的客户端插件,包括数据可视化、执行增删改查操作等,有npm、docker和浏览器扩展版本(仅Chrome浏览器),按照下面文档选择自己喜欢的方式安装即可

mobz/elasticsearch-head: A web front end for an elastic search cluster (github.com)

php调用


由于实际开发中项目都是使用php框架开发,所以以下分别用ThinkPHP5和Laravel框架实现

ThinkPHP框架

  • 安装扩展
composer require elasticsearch/elasticsearch
composer dump-autoload
  • 索引和文档的增删改查

    <?php
    
    namespace app\index\controller;
    
    use Elasticsearch\ClientBuilder;
    
    class Elasticsearch
    {
        // 客户端
        protected $client = null;
        //索引名称
        protected $indexName = 'test';
    
        public function __construct()
        {
            try {
                $this->client = ClientBuilder::create()->setHosts(['127.0.0.1:9200'])->build();
            } catch (\Exception $e) {
                die($e->getMessage());
            }
        }
    
        /**
         * 创建索引
         * @desc 相当于mysql的数据库,索引只需要创建一次
         */
        public function createIndex()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
                'body' => [
                    'settings' => [
                        'number_of_shards' => 5,    //分片数量:一个索引库将拆分成多片分别存储不同的结点,默认5个
                        'number_of_replicas' => 0   //为每个分片分配的副本数,replica shard是primary shard的副本,负责容错,以及承担读请求负载,如果服务器只有一台,只能设置为0,不然会报错创建超时failed to process cluster event (create-index [test], cause [api]) within 30s
                    ],
    
                    //创建文档映射,就是文档存储在ES中的数据结构,这里以商城商品搜索为例,建立商品的映射
                    'mappings' => [
                        'properties' => [
                            'goods_id' => [ //商品id
                                'type' => 'keyword',
                                'index' => 'true',
                            ],
                            'goods_name' => [ //商品名称
                                'type' => 'text', //数据类型为text,支持分词;类型为keyword,不支持分词,只能精确索引;8.x以上版本不再支持string等类型
                                'index' => 'true', //字段可以被索引,也就是能用来当做查询条件来查询,只能填写true和false
                                'analyzer' => 'ik_max_word', //索引分词器,用于字符串类型,这里使用中文分词器ik,用默认分词器可以省略
                                'search_analyzer' => 'ik_smart'//搜索分词器,用于搜索关键词的分词器
                            ],
                            'goods_desc' => [ //商品描述
                                'type' => 'keyword',
                                'index' => 'false', //字段不可以被索引,不能用来当做查询条件来查询
                            ],
                            'stock' => [ //商品库存
                                'type' => 'keyword',
                                'index' => 'true',
                            ],
                            'created_at' => [ //创建时间
                                'type' => 'keyword',
                                'index' => 'true',
                            ],
                            'status' => [ //上架状态
                                'type' => 'keyword',
                                'index' => 'true',
                            ],
                        ]
                    ]
                ]
            ];
    
            try {
                return $this->client->indices()->create($params);
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 删除索引
         */
        public function deleteIndex()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
            ];
    
            try {
                return $this->client->indices()->delete($params);
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 查看映射
         */
        public function getMapping()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
            ];
    
            try {
                return $this->client->indices()->getMapping($params);
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 新增文档
         */
        public function addDoc()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
                'id' => 1, //文档id,可省略,默认生成随机id
                'body' => [
                    'goods_id' => 1, //商品id
                    'goods_name' => '爆款煎饼(传统双蛋煎饼+肉松+优质火腿片+配菜+薄脆)', //商品名称
                    'goods_desc' => '煎饼果子', //商品描述
                    'stock' => 100, //库存
                    'created_at' => '2019-06-01 00:00:00', //创建时间
                    'status' => 1, //上架状态
                ]
            ];
    
            try {
                return json_encode($this->client->index($params));
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 获取文档
         */
        public function getDoc()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
                'id' => 1, //文档id
            ];
    
            try {
                return json_encode($this->client->get($params));
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 更新文档
         */
        public function updateDoc()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
                'id' => 1, //文档id
                'body' => [
                    'doc' => [
                        'goods_name' => '爆款煎饼(传统双蛋煎饼+肉松+优质火腿片+配菜+薄脆)', //更新商品名称
                    ]
                ]
            ];
    
            try {
                return json_encode($this->client->update($params));
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 删除文档
         */
        public function deleteDoc()
        {
            $params = [
                'index' => $this->indexName, // 索引名称
                'id' => 1, //文档id
            ];
    
            try {
                return json_encode($this->client->delete($params));
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
        /**
         * 查询文档
         *
         * 查询条件
         * must(且):数组里面的条件都要满足,该条数据才被选择,所有的条件为且的关系
         * must_not(或,然后取反):数组里面的条件满足其中一个,该条数据则不被选择
         * should(或):数组里面的条件满足其中一个,该条数据被选择
         */
        public function searchDoc()
        {
            $keywords = '火腿煎饼'; // 查询关键词
            $params = [
                'index' => $this->indexName, // 索引名称
                'body' => [
                    'query' => [
                        'bool' => [
                            'should' => [ //should:模糊查询(or);must:精确查询(and);must_not:or取反;
                                'match' => ['goods_name' => $keywords], //match:匹配字段;range:范围查询
                            ]
                        ],
                    ],
                    'sort' => ['stock' => ['order' => 'desc']], // 排序
                    'from' => 0,  // 分页起始位置
                    'size' => 10  // 分页记录数量
                ]
            ];
    
            try {
                return json_encode($this->client->search($params),JSON_UNESCAPED_UNICODE);
            } catch (\Exception $e) {
                return $e->getMessage();
            }
        }
    
    
        //test
        public function test()
        {
    //        $res = 123;
    //        $res = $this->deleteIndex();
    //        $res = $this->createIndex();
    //        $res = $this->getMapping();
    //        $res = $this->addDoc();
    //        $res = $this->getDoc();
    //        $res = $this->updateDoc();
    //        $res = $this->deleteDoc();
            $res = $this->searchDoc();
            var_dump($res);
        }
    }
    

Laravel框架

方式一

参考上面的ThinkPHP框架调用

方式二

Laravel框架除了能像tp框架一样自己写es的功能,它还有专门的es扩展包,并且支持在model中使用es及导入数据

  • 安装扩展包

    composer require elasticsearch/elasticsearch
    composer require tamayo/laravel-scout-elastic
    composer require laravel/scout
    
  • 发布配置

    php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
    
  • 修改配置

    修改config/scout.php文件的配置,将驱动'driver' => env('SCOUT_DRIVER', 'algolia')改为'driver' => env('SCOUT_DRIVER', 'elastic')

    然后最文件增加elasticsearch配置

    /*
    |--------------------------------------------------------------------------
    | Elasticsearch Configuration
    |--------------------------------------------------------------------------
    |
    | Here you may configure your Elasticsearch settings.
    |
    */
    'elasticsearch' => [
            'index' => env('ELASTICSEARCH_INDEX', 'laravel'),   //laravel就是索引的名字,可以根据你的需求随便起
            'hosts' => [
                env('ELASTICSEARCH_HOST', 'http://127.0.0.1:9200'),
            ],
        ],
    

    当然,你可以在.env环境配置文件中覆盖配置

    ELASTICSEARCH_HOST=127.0.0.1:9200
    
  • 使用es

    public function handle()
        {
        $host = config('scout.elasticsearch.hosts');
        $index = config('scout.elasticsearch.index');
        $client = ClientBuilder::create()->setHosts($host)->build();
    
        if ($client->indices()->exists(['index' => $index])) {
            $this->warn("Index {$index} exists, deleting...");
            $client->indices()->delete(['index' => $index]);
        }
    
        $this->info("Creating index: {$index}");
    
        return $client->indices()->create([
            'index' => $index,
            'body' => [
                'settings' => [
                    'number_of_shards' => 1,
                    'number_of_replicas' => 0
                ],
                'mappings' => [
                    '_source' => [
                        'enabled' => true
                    ],
                    'properties' => [
                        'id' => [
                            'type' => 'long'
                        ],
                        'title' => [
                            'type' => 'text',
                            'analyzer' => 'ik_max_word',
                            'search_analyzer' => 'ik_smart'
                        ],
                        'subtitle' => [
                            'type' => 'text',
                            'analyzer' => 'ik_max_word',
                            'search_analyzer' => 'ik_smart'
                        ],
                        'content' => [
                            'type' => 'text',
                            'analyzer' => 'ik_max_word',
                            'search_analyzer' => 'ik_smart'
                        ]
                    ],
                ]
            ]
        ]);
    }
    
  • Model中使用

    参考官方文档Scout 全文搜索 | 官方扩展包 |《Laravel 6 中文文档 6.x》| Laravel China 社区 (learnku.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/441795.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【企业发展战略】某环境管理集团公司发展战略与规划项目纪实

在集团公司高速发展、业务范围不断扩大时&#xff0c;组织往往对公司未来的发展方向感到迷茫&#xff0c;不知道如何进行更好的规划&#xff0c;找到合适的发展战略&#xff0c;为企业提供更长远的发展空间&#xff0c;带来更多是利益。面对这个问题&#xff0c;华恒智信认为企…

StarRocks实战——欢聚集团极速的数据分析能力

目录 一、大数据平台架构 二、OLAP选型及改进 三、StarRocks 经验沉淀 3.1 资源隔离&#xff0c;助力业务推广 3.1.1 面临的挑战 3.1.2 整体效果 3.2 稳定优先&#xff0c;监控先行&#xff0c;优化运维 3.3降低门槛&#xff0c;不折腾用户 3.3.1 与现有的平台做打通 …

Springboot+vue的物业管理系统(有报告)。Javaee项目,springboot vue前后端分离项目。

演示视频&#xff1a; Springbootvue的物业管理系统&#xff08;有报告&#xff09;。Javaee项目&#xff0c;springboot vue前后端分离项目。 项目介绍&#xff1a; 本文设计了一个基于Springbootvue的物业管理系统&#xff0c;采用M&#xff08;model&#xff09;V&#xff…

C++自创题目——几点了 very hard ver.

题目难度 普及 题目描述 一个老外用一口不流利的中文问你&#xff1a;“Xian zai ji dian le?”你看了一眼表&#xff0c;知道了现在是&#xff0c;你准备用这样的形式写在纸上&#xff1a; Now is m past/to h. 如果你看不懂&#xff0c;举个例子&#xff1a; 当h10&#…

运维知识点-Apache HTTP Server

Apache 介绍 介绍 Apache是一个开源的Web服务器软件&#xff0c;全称为Apache HTTP Server&#xff0c;由Apache软件基金会开发和维护。它是目前全球使用最广泛的Web服务器软件之一&#xff0c;占全球所有网络服务器的很大比例。Apache服务器具有跨平台的特性&#xff0c;可以…

【Hadoop大数据技术】——Hadoop概述与搭建环境(学习笔记)

&#x1f4d6; 前言&#xff1a;随着大数据时代的到来&#xff0c;大数据已经在金融、交通、物流等各个行业领域得到广泛应用。而Hadoop就是一个用于处理海量数据的框架&#xff0c;它既可以为海量数据提供可靠的存储&#xff1b;也可以为海量数据提供高效的处理。 目录 &#…

STM32 通过Modbus协议更改内部Flash(模仿EEPROM)的运行参数

main.c测试 uint8_t uart1RxBuf[64]{0};uint8_t Adc1ConvEnd0; uint8_t Adc2ConvEnd0;int main(void) {/* USER CODE BEGIN 1 *//* USER CODE END 1 *//* MCU Configuration--------------------------------------------------------*//* Reset of all peripherals, Initial…

docker学习入门

1、docker简介 docker官网&#xff1a; www.docker.com dockerhub官网&#xff1a; hub.docker.com docker文档官网&#xff1a;docs.docker.com Docker是基于Go语言实现的云开源项目。 Docker的主要目标是&#xff1a;Build, Ship and Run Any App, Anywhere(构建&…

Java面试——Netty

优质博文&#xff1a;IT-BLOG-CN 一、BIO、NIO 和 AIO 【1】阻塞 IO(Blocking I/O)&#xff1a; 同步阻塞I/O模式&#xff0c;当一条线程执行 read() 或者 write() 方法时&#xff0c;这条线程会一直阻塞直到读取一些数据或者写出去的数据已经全部写出&#xff0c;在这期间这条…

iOS——【自动引用计数】ARC规则及实现

1.3.3所有权修饰符 所有权修饰符一共有四种&#xff1a; __strong 修饰符__weak 修饰符__undafe_unretained 修饰符__autoreleasing 修饰符 __strong修饰符 _strong修饰符表示对对象的强引用&#xff0c;持有强引用的变量在超出其作用域的时候会被废弃&#xff0c;随着强引…

Kafka入门及生产者详解

1. Kafka定义 传统定义&#xff1a;分布式的、基于发布/订阅模式的消息队列&#xff0c;主要用于大数据实时处理领域。发布/订阅模式中&#xff0c;发布者不会直接将消息发送给特定的订阅者&#xff0c;而是将发布的消息分为不同的类别&#xff0c;订阅者只接受感兴趣的消息。…

html--心动

代码 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>html</title><style>*{padding: 0;margin: 0;}body{background-color: pink;}#frame{position: relative;width: 400px;height: 300…

【项目】Boost 搜索引擎

文章目录 1.背景2.宏观原理3.相关技术与开发环境4. 实现原理1.下载2.加载与解析文件2.1获取指定目录下的所有网页文件2.2. 获取网页文件中的关键信息2.3. 对读取文件进行保存 3.索引3.1正排与倒排3.2获取正排和倒排索引3.3建立索引3.3.1正排索引3.3.2倒排索引 4.搜索4.1 初始化…

练习3-softmax分类(李沐函数简要解析)与d2l.train_ch3缺失的简单解决方式

环境为:练习1的环境 网址为:https://www.bilibili.com/video/BV1K64y1Q7wu/?spm_id_from333.1007.top_right_bar_window_history.content.click 代码简要解析 导入模块 导入PyTorch 导入Torch中的nn模块 导入d2l中torch模块 并命名为d2l import torch from torch import nn…

pytorch CV入门3-预训练模型与迁移学习.md

专栏链接&#xff1a;https://blog.csdn.net/qq_33345365/category_12578430.html 初次编辑&#xff1a;2024/3/7&#xff1b;最后编辑&#xff1a;2024/3/8 参考网站-微软教程&#xff1a;https://learn.microsoft.com/en-us/training/modules/intro-computer-vision-pytorc…

【Linux】文件周边003之文件系统

&#x1f440;樊梓慕&#xff1a;个人主页 &#x1f3a5;个人专栏&#xff1a;《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C》《Linux》《算法》 &#x1f31d;每一个不曾起舞的日子&#xff0c;都是对生命的辜负 目录 1.磁盘引入 2.文件系统 …

构建留学平台技术架构:从设计到实现

随着全球化进程的加速和人们对国际教育的需求不断增长&#xff0c;留学行业也迎来了快速发展的机遇。作为留学服务的重要组成部分&#xff0c;留学平台的技术架构设计至关重要。本文将探讨留学平台技术架构的设计和实现过程&#xff0c;以及相关的技术选择、挑战和解决方案。 …

NodeJS实现堆排序算法

NodeJS实现堆排序算法 以下是使用 Node.js 实现堆排序算法的示例代码&#xff1a; // 堆排序函数 function heapSort(arr) {// 构建最大堆buildMaxHeap(arr);// 依次取出最大堆的根节点&#xff08;最大值&#xff09;&#xff0c;并调整堆结构for (let i arr.length - 1; i…

18、电源管理入门之Power Domain管理

目录 1. 框架介绍 2. 如何使用power domain 3. provider 4. Consumer 参考: SoC中通常有很多IP,按逻辑可以把几个相关功能的IP划为一个电源域。一个电源域内的IP,通常按相同的方式由同一个硬件模块PMIC供电,电压一样并且电源管理例如休眠唤醒一致。 为什么有设备电源管…

HTML5+CSS3+JS小实例:暗紫色Tabbar

实例:暗紫色Tabbar 技术栈:HTML+CSS+JS 效果: 源码: 【HTML】 <!DOCTYPE html> <html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scal…