`
zhaoImpulse
  • 浏览: 79313 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

java 设计模式

 
阅读更多

 

l  创建模式

1.       工厂模式

简单工厂:又称静态工厂方法模式,它可以根据传进来的参数来选择创建哪些对象。这样方便,但有个缺点,因为工厂模式本来就是为了将对象的使用和创建脱藕,而如果使用简单工厂模式的话,那么客户端就需要知道要创建的对象的类型。

工厂方法:又称多态性工厂模式。工厂模式的核心是一个抽象工厂类,而简单工厂模式把核心放在一个具体类上。工厂方法模式可以允许很多具体工厂从抽象工厂类中将创建行为继承下来,从而可以成为多个简单工厂模式的统合,进而推广了简单工厂类。而且,当需要创建新的对象时,简单工厂需要直接改源代码,而工厂方法模式只需要再创建一个工厂类继续抽象工厂类就可以了。同时,如果要创建的对象具体很深的层次的话,对应的工厂也可以和对象建立同样的等级,这样使用起来更方便,而简单工厂就乱套的多。

抽象工厂:又称工具箱模式。抽象工厂不是为了创建单一的对象而存在的,它是为了创建一个产品族群。

2.      单例模式

单例模式分为两种,懒汉模式和饿汉模式。

饿汉模式:


Private static final EagerSingleton m_instance = new EagerSingleton();

当这个类被加载时,静态变量m_instance就会被初始化,此时类的私有构造子会被调用,这时候,单例类的惟一实例就被创建出来了。

懒汉模式:



synchronized public static LazySingleton getInstance(){

         If(m_instance == null){

                   m_instance= new LazySingleton();

}else

return m_instance;

}

synchronized是为了实现同步化。

饿汉模式在资源利用上不如懒汉模式,但因为没有它天然就是线程安全的,不需要synchronized,所以它在速度和反应时间上比饿汉好。

饿汉模式在java可以实现,但在c++中很难,因为静态初始化在c++中没有固定的顺序,因而静态的m_instance变量的初始化与类的加载顺序没有保证,可能会出问题。

单例模式分关有状态的和没状态的两种,当有状态时,它可以作为一个状态库使用,用来给系统提供一些信息。当用到分布式,比如ejb中时,需要跨jvm的运行,如果要用到单例类,那么会在每个jvm中都创建相应的实例,这时,就应该使用没有状态的单例。

3.       建造模式(builder




建造模式和抽象工厂模式有些相似,但它们的关注的方面不同。抽象工厂是创建一个产品族,所以它的创建一般只有一个方法,然后就返回创建好的产品,可以再用来加工,也可以直接使用。而建造模式,一般都有好几个创建方法,用来逐渐创建Product,它的所有创建方法必须都使用才能创建一个完整的产品,然后再用retrieveResult()方法来返回所创建的产品。

4.       原始模型(Prototype)模式

原始模型模式是给出一个原型对象来指明所要他要创建的对象类型,然后用复制这个原型对象的方法创建出更多同类型的对象。换句话说,就是克隆。

Java语言的构件模型直接支持原始模型模式。所有的javabean都继承自java.lang.Object,而Object类提供了一个clone()方法,可以将JavaBean对象复制一份。但是,这个javabean必须实现标识接口Cloneable,表明这个javabean支持复制。如果不实现就调用clone()方法,则抛出CloneNotSupportedException异常。(clone方法的返回类型是Object,所以在子类重写该方法的时候要注意一下)




通常来说,克隆要满足几个条件

                         i.             对任何的对象x,都有:x.clone()!=x。换言之,克隆对象与原对象不是同一个对象。

                       ii.              对任何的对象x,都有:x.clone().getClass == x.getClass(),换言之克隆对象与原始对象的类型一样。(即使重写clone()返回类型是Object,但它的getClass依然为创建时的class

                      iii.              如果对象xequals()方法定义恰当的话,那么x.clone().equals(x)应当成立(不要求必须,但最好)。

复制又分为两种,浅复制和深复制。浅复制只复制对象的所有变量,而引用的对象还是原来的对象,也就是说,当浅复制时,两个不同的对象,它们引用了同一个对象(如果有的话)。

深复制是指连引用的对象也一并复制。但引用的对象还可能引用别的对象,所以深复制要深入多少层,是一个不易确定的问题。而且可能出现循环引用,要小心使用。

可以利用串行化(Serilization)过程来深复制,把对象写进流里再读出来,那就是个完全的深复制,但效率太低。

l  结构模式

结构模式(Structural Pattern)描述如何将类或者对象结合在一起形成更大的结构。结构模式描述两种不同的东西:类与类的实例。根据这一不同,结构模式可以分为类的结构模式和对象的结构模式。

5.       适配器模式(Adapter

适配器模式是把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作。

它分为两种,类的适配器和对象的适配器




类的适配器,要适配的类含有目标接口的(SpecificRequest())方法,所以子类Adapter不用重写,只要把源(Adaptee)没有的方法,Request实现就可以了。




对象的适配器

适配器的特例:缺省适配模式(Default Adapter),为一个接口提供缺省实现,这样子类型可以从这个缺省实现进行扩展,而不必从原有接口进行扩展。作为适配器模式的一个特例,缺省适配模式在java语言中有着特殊的应用。




这样天星类就是一个抽象的适配器类,鲁智深现在不需要实现吃斋等方法,因为他不做,而是只要实现他做的方法就行了。

6.     合成模式(Composite

合成模式又叫做部分-整体模式,将对象组织到树结构中,可以用来描述整体与部分的关系。合成模式可以使客户端将单纯元素与复合元素同等看待。




合成模式分为两种,透明方式和安全方式。上图就是透明方式,在Component里声明所有的用来管理子类对象的方法,包括add()、remove()、getChild()方法。这样做的好处是所有的构件类都有相同的接口。在客户端看来,树叶类对象与合成类对象的区别起码在接口层次上消失了,客户端可以同等地对待所有的对象。

这个选择缺点是不够安全,因为树叶类对象和合成类对象本质上是有区别的。树叶类对象不可能有下一个层次的对象,因此add等方法是没有意义的,但在编译时期不会出错,而只会在运行时代奢会出错。

安全方式,是在Composite里声明所有的用来管理子类对象的方法,而Component里的addremovegetChild方法移到Composite里,这是安全的,因为树叶类型的对象根本没有管理子类对象的方法,因此,如果客户端对树叶类对象使用这些方法时,程序会在编译时期出错。这些的缺点是不够透明,因为树叶类和合成类将具有不同的接口。

7.      装饰模式(Decorator

装饰模式以对客户端透明的方式扩展对象的功能,是继承关系的一个替代方案。




装饰模式的上层可以看作是个合成模式。

装饰模式与继承模式都能扩展对象的功能,但装饰模式更灵活,它可以选择给对象装饰成不同的样式,比如用ConcreteDectorator1ConcreteDectorator2装饰,这些都可以由系统动态决定,而继承是静态的,编译时就决定了。

由于要面对抽象编程,所以透明的装饰模式应该这样用:

Component c = new ConcreteComonent();

Comoponent c1 = new ConcreteDectorator1(c);

Comoponent c2 = new ConcreteDectorator2(c1);

而下面的是不对的

ConcreteComonent c = new ConcreteDectorator();

装饰模式对客户端是完全透明的。它们一系列的方法都有最初的Component接口所定义。但是,纯粹的装饰模式很难找到。装饰模式的用意是在不改变接口的前提下,增强所考虑的类的性能。在增强性能的时候,往往需要那门新的公开的方法。比如在io系统中,BufferedInputStream是负责装饰InputStream的,但它为了增强处理缓存的功能,提供了额外的方法,比如ensureopen()fill()等。所以它是一个半透明的装饰类,如果想使用这些方法,就要将inputStream转化为BufferedInputStream

8.       代理模式(Proxy




代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用。通常情况下,如果被代理的对象需要很长的加载时间,比如数据库或远程方面的话,Proxy可以在被代理对象真正创建的时候再去实例化它。

Proxy类中有段代码:

Private RelalSubject realSubject;

 

Public void request(){

//方法调用前执行

preRequest();
if(realSubject == null){

                   realSubject = new RealSubject();

         }

         realSubject.request();

         //方法调用完后执行

         afterRequest();

}

这样不仅能延迟加载,也能面向切面编程,实际上,aop就是用了这个原理。

代理模式看起来和适配器模式很相似,但它们有质的区别。适配器模式是为了改变所考虑的对象的接口,而代理模式并不能改变所代理的对象的接口。虽然RealSubject看起来即使不继承Subject也没有关系,这样可以转化为委托模式,但系统可以选择使用代理,当然也可以选择不使用代理,直接使用RealSubject,这时,就需要它去实现Subject接口了。所以RealSubjectProxy的公用方法必须一致。

9.      享元模式(FlyWeight Pattern)

 

术语粗粒度和细粒度用来形容由组件的公共接口所暴露的细节层次的。细粒度组件通过其公共接口暴露了有关组件如何工作的大量细节,所以它的重用性比较好,但不灵活。提供公共接口但并不暴露其操作细节的组件则称为粗粒度,它重用性差,但更灵活。

细粒度的查询任务的接口
interface TaskService{
  public List getTaskById(int id);
  public List getTaskByName(String name);
  public List getTaskByAge(int age);

}

那么粗粒度的接口该是什么样的呢?
interface TaskService{
  public List getTask(Person person);
}

 

享元模式以共享的方式高效地支持大量的细粒度对象。能做到共享的关键是区分内蕴状态和外蕴状态。

一个内蕴状态是存储在享元对象内部的,并且是不会随环境改变而有所不同的。因此,一个享元可以具有内蕴状态并可以共享。

一个外蕴状态是随环境改变而改变的、不可以共享的状态。享元对象的外蕴状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候再传入到享元对象内部。外蕴状态不可能影响享元对象的内蕴状态。换句话说,它们是相互独立的。

java语言中,String类型就是使用了享元模式。String对象是不变对象,一旦创建出来就不能改变。如果需要改变一个字符串的值,就只好创建一个新的String对象。在jvm内部,String对象都是共享的,如果一个系统中有两个String对象所包含的字符串相同的话,jvm实际上只创建一个String对象提供给两个引用。从而实现String对象的共享。Stringintern()方法给出这个字符串的共享池中的惟一实例。



客户端不能将具体享元对象实例化,而是必须通过工厂对象FlyweightFactory使用getFlyweight(key)来得到享元对象。并有一个map来存放这些对象,即如果已经创建过,就直接得到,如果没有创建过,那就新建。Flyweight提供了方法Operation(),这是可以通过参量方式传入一个外蕴状态。

class ConcreteFlyweight implements Flyweight{

         //内蕴状态

         private Character intrinsicState = null;

         //构造子,内蕴状态作为参量传入,FlyweightFactory工厂创建

         Public ConcreteFlyweight(Charater state){

                   This.intrinsicState = state;

         }

         //外蕴状态作为参量传入方法中,改变方法的行为,但是并不改变对象的内蕴状态

         public void operation(String state){

                   System.out.println(“内蕴状态=”+  state+”外蕴状态=”+state);

         }

}

这样,通过调用operation方法,可以在不改变内蕴状态intrinsicState的情况下,任意改变外蕴状态。

享元模式优点在于能大同谋地降低内存中对象的数量。但是它使得系统更加复杂,为了使对象可以共享,需要将一些状态外部化,这使得程序的逻辑复杂化。

10.      门面模式(Facade

迪米特法则说:“只与你直接的朋友们通信”。迪米特法要求每一个对象与其他对象的相互作用均是第三种的,而不是长和的。只要可能,朋友的数目越少越好。换言之,一个对象只应当知道它的直接合作者的接口。

门面模式创建出一个门面对象,将客户端所涉及的属于一个子系统的协作伙伴的数目减到最少,使得客户端与子系统内部的对象的相互作用被门面对象所取代。显然,门面模式就是实现代码重构以便达到迪米特法则要求的一个强有国的武器。

11.      桥梁模式(Bridge

桥梁模式的用意是将抽象化与实现化脱耦,使得二者可以独立地变化。



找到系统的可变因素,将之封装起来,通常就叫做对变化的封装。对变化的封装实际上是达到“开-闭”原则的途径,与组合/聚合复用原则是相辅相成的。




一般来说,一个继承结构中的第一层是抽象角色,封装了抽象的商业逻辑,这是系统中不变的部分。第二层是实现角色,封装了设计中会变化的因素。这个实现请允许实现化角色有多态性变化。

当实现化需要改变时,就换个实现化;但当抽象化模块需要改变时,比如公共算法的改变,再添加一些其它功能,这样就得改整个系统。所以要将抽象化与实现化脱耦。




Jdbc驱动器就是用了桥接模式




对象形式的的适配器模式可能看上去很像桥梁模式。然而适配器模式的目的是要改变已有的接口,让它们可以相容,以使没有关系的两个类能在一起工作。而桥梁模式是分享抽象化和实现化,使得两者的接口可以不同。因此两个模式是相反方向努力。

行为模式

行为模式(Behavioral Pattern)是对在不同的对象之间划分责任和算法的抽象化。行为模式不仅仅是关于类和对象的,而且是关于它们之间的相互作用的。

12.       不变模式(Immutable

一个对象的状态在对象被创建之后就不再变化,这就是所谓的不变模式。

不变模式可增强对象的强壮性。不变模式允许多个对象共享某一对象,降低了对该对象发访问时的同步化开销。如果需要修改一个不变对象的状态,那么就需要建立一个新的同类的对象,并在创建时将这个新的状态存储在新对象里。

不变模式分为两种:弱不变,所考虑的对象没有任何方法会修改对象的状态;这样一来,当对象的构造子将对象的状态初始化之后,对象的状态便不再改变。所有属性都应当是私有的,不要声明任何的公开的属性,以防客户端对象直接修改任何的内部状态。这个对象所引用到的其它对象如果是可变对象的话,必须高潮限制外界改变这些引用的可变对象。最好将这可变对象复制一份,不要使用原来的拷贝。

但它的子对象可以是可变对象;子对象可能可以修改父对象的状态,从而可能会允许外界修改父对象的状态;

强不变:一个类的实例状态不会改变;同时它的子类的实例也具有不可变化的状态。这样的类符合强不变模式。必须满足下面条件之一。

<!--[if !supportLists]-->1.              <!--[endif]-->所考虑的类所有的方法都应当是final;这样这个类的子类不能够转换掉此类的方法;

<!--[if !supportLists]-->2.              <!--[endif]-->这个类本身就是final的,那么这个类就不可能会有子类,从而也就不可能有被子类修改的问题。

不变和之读是有区别的:比如一个人的出生年月是不变的,而一个人的年龄便是只读的,它会变化,但不能人为的改变。

不变模式的优点:1.因为不能修改一个不变对象的状态,所以可以避免由此引起的不必要的程序错误:换言之,一个不变对象要比可变对象的对象更加容易维护。2.因为没有任何一个线程能够修改不变对象的内部状态,一个不变对象自动就是线程安全的,这样就可以省掉处理同步化的开销。

13.       策略模式(Strategy

策略模式是针对一组算法,将每个算法封装到具有共同接口的独立的类中,从而使得它们可以相互替换。策略模式使得算法可以在不影响到客户的情况下发生变化。




策略模式是对算法的包装,是把使用算法的责任和算法本身分割开,委派给不同的对象管理。策略模式通常把一个系列的算法包装到一系列的策略类里面,作为一个抽象策略的子类。用一句话说,就是:“准备一组算法,并将每一个算法封装起来,使得它们可以互换”。

14.       模板方法模式(Template Method

准备一个抽象类,将部分逻辑以具体方法以及具体构造子的形式实现,然后声明一些抽象方法来迫使子类实现剩余的逻辑。不同的子类可以以不同的方式实现这些抽象方法,从而对剩余的逻辑有不同的实现。

15.      观察者模式

观察者模式分为两种,推模型和拉模型。




 

拉模型




推模型

通常情况下用拉模式更何情何理,既然是观察对象,那么观察者observer应该自己可以去把想要的数据弄过来。但是,拉模式中,观察都需要有个观察对象的引用。而这在推模型中是不需要的。

但推模型有个好处,就是当观察对象有一堆数据,但只有很少的几个做了变化时,拉模型的observer是不知道哪些起了变化的,它只能遍历subject的所有属性;而推模型就可以把起变化的数据推给观察者,同时能有个hint来做提示,当然,它可以是个参数、枚举、字符串等,它的值都可以推给观察者。

16.      迭代子模式(Iterator

迭代子模式又叫游标模式,可以顺序地访问一个聚集中的元素而不必暴露聚集的内部表象。




聚集对象必须提供适当的方法,允许客户端能够按照一个线性顺序遍历所有元素对象,把元素对象提取出来或者删掉掉等。那么就会出现两种情况:1.迭代逻辑没有改变,但是需要将一种聚集换成另一种聚集,如果它们有不同的遍历接口,那就要改客户端代码。2.聚集不改变,但迭代方式要改变,比如以前只要读取元素和删除元素,现在又加上添加元素,这时就只好修改聚集对象,修改已有的遍历方法。所以使用聚集时,就需要迭代子。

如果一个聚集的接口提供了可以用来修改聚集元素的方法,这个接口就是宽接口;如果一个聚集的接口没有提供修改聚集元素的方法,这样的接口就是窄接口。

上图中,Aggregateclient就是窄接口,显然不想让客户端能够直接改变聚集的内容;而ConcreteAggregateConcreateIterator就是宽接口,具体迭代子必须能够控制聚集的内容。

上图所示的是白箱聚集与外禀迭代子。

外禀指具体迭代子类是在外面,而内禀则是具体迭代子类是具体聚集的内部类,这样能够访问具体聚集的私有成员和方法。

白箱聚集向外界提供访问自己内部元素的接口,从而使外禀迭代子可以通过聚集的遍历方法实现迭代功能,如上图,但这样是不安全的,如果client没有使用迭代子,直接使用白箱聚集的操作接口,就可能出现问题。

黑箱聚集不向外部提供遍历自己元素对象的接口,因此,这些元素对象只可以被内部成员访问。这时就用到内禀迭代子了。

由于迭代子要存储当前遍历的游标(当前元素位置),所以白箱聚集使用外禀迭代子时,可以是不变对象,前提是它的聚集元素是不会改变的。但黑箱由于提供的内禀迭代子必须存储动态的游标,所以它不可能是不变对象。

AbstractList是白箱聚集,它向外部提供了自己的遍历方法,所以我们可以自定义自己的外禀迭代子,但它也使用了内禀迭代子,即内部类Itr,做为默认的迭代实现。

17.       责任链模式(Chain of Responsibility

在责任链模式里,很多对象由每一个对象对其下家的引用而连接起来形成一条链。请求在这个链上传递,直到链上的某个对象决定处理些请求。发出这个请求的客户端并不知道链上的哪一个对象最终处理这个请求,这使得系统可以在不影响客户端的情况下动态地重新组织链和分配责任。




具体处理者(ConcreteHandler)接到请求后,可以选择将请求处理掉,或者将请求传给下家。由于具体处理者持有对下家的引用,因此,如果需要,具体处理者可以访问下家。

责任链模式分为两种。一个纯的责任链模式要求一个具体的处理者对象只能在两个行为中选择一个:一个是承担责任,二是把责任推给下家。不允许出现某一个具体处理者对象在承担了一部分责任后又把责任向下传的情况。在一个纯的责任链模式里面,一个请求必须被某一个处理者对象所接收;在一个不纯的责任链模式里,一个请求可以最终不被任何接收端对象所接收。

链结构的由来:责任链模式并不创建出责任链,而是由系统的其它部分比如客户端来创建出来。客户端负责将每个责任对象创建出来,并手动指定责任对象的下家。

18.       命令模式

命令模式把一个请求或者操作封装到一个对象中。命令模式允许系统使用不同的请求把客户端参数化。



<!--[endif]-->

客户端代码:

Receiver receiver = new Receiver();

Command command = new ConcreteCommand(receiver);//决定接收者

Invoker invoker = new Invoker(command);

Invoker.action();

19. 备忘录模式(Memento

备忘录对象是个用来存储另外一个对象内部状态的快照的对象。备忘录模式的用意是在不破坏封装的条件下,将一个对象的状态捕捉住,并外部化,存储起来,从而可以在将来合适的时候把这个对象还原到存储起来的状态。




备忘录角色(Memento):1.将发起人(Originator)对象的内部状态存储起来。备忘录可以根据发起人对象的判断来决定存储多少发起人对象的内部状态。2,可以保护其内容不被发起人对象之外的对象所读取。备忘录有两个等效的接口:

a)         窄接口:负责人(Caretaker)对象(和其他除发起人对象之外的任何对象)看到的是备忘录的窄接口,这个窄接口只允许它把备忘录对象传给其他对象。

b)         宽接口:与负责人对象看到的窄接口相反的是,发起人对象可以看到一个宽接口,这个宽接口允许它读取所有的数据,以便根据这些数据恢复这个发起人对象的内部状态。

发起人角色(Originator):创建一个含有当前的内部状态的备忘录对象。使用备忘录对象存储其内部状态。

     负责人角色(Caretaker):负责保存备忘录对象。不检查备忘录对象的内容。

20. 状态模式(State

状态模式允许一个对象在其内部状态改变时候改变其行为。这个对象看上去就像是改变了它的类一样。




状态模式和策略模式非常像。如果环境角色只有一个状态,那么就应当用策略模式。策略模式的特点是:一旦环境角色选择了一个具体策略类,那么在整个环境类的生命周期里它都不会改变这个具体策略类。而状态模式则适用于另一个情况,即环境角色有状态转移。在环境类的生命周期里面,会有几个不同的状态对象被使用。

21. 访问者模式(visitor

访问者模式的目的是封装一些施加于某种数据结构元素之上的操作。一旦这些操作需要修改的话,接受这个操作的数据结构则可以保持不变。




访问者模式使得增加新的操作变得很容易。如果一些操作依赖于一个复杂的结构对象的话,那么一般而言,增加新的操作会很复杂。而使用访问者模式,增加新的操作就意味着增加一个新的访问者类,因此变得的很容易。访问者模式将有关的行为集中到一个访问者对象中,而不是分散到一个个的节点类中。访问者模式可以路过几个类的等级结构访问属于不两只的等级结构的成员类中。迭代子只能访问属于同一个类型等级结构的成员对象,而不能访问属于不同等级结构的对象。访问者模式可以做到这一点。

访问者模式的缺点:增加新的节点类变得很困难。每增加一个新的节点都意味着要在抽象访问者角色中增加一个新的抽象操作,并在每一个具体访问者类中增加相应的具体操作。破坏封装,访问者模式要求访问者对象访问并调用每个节点的对象操作,这隐含了一个对所有节点对象的要求:它们必须暴露一些自己的操作和内部状态。不然,访问者的访问就没有意义。由于访问者对象自己会积累访问操作所需要的状态,从而使这些状态不再存储在节点对象中,这也是破坏封装。

22. 解释器模式(Interpreter

给定一个语言之后,解释器模式可以定义出其文法的一种表示,并同时提供一个解释器。客商可以使用这个解释器来解释这个语言中的句子。




23. 调停者模式(Mediator

调停者模式包装了一系列对象相互作用的方式,使得这些对象不必互相明显引用。从而使它们可以较松散地耦合。当这些对象中的某些对象之间的相互作用发生改变时,不会立即影响到其他的一些对象之间的酵素作用。从而保证这些相互作用可以彼此独立地变化。




调停者和门面模式很相似。门面模式为一个子系统提供了一个简单的接口,其中消息的传送是单方向的,因为门面模式的客户端只通过门面类向子系统发出消息,而不是相反的情况。调停者模式则不同,它与同事对象的相互作用是多方向的。

 

  • 大小: 5.2 KB
  • 大小: 5.9 KB
  • 大小: 11.4 KB
  • 大小: 5.8 KB
  • 大小: 8.4 KB
  • 大小: 7.3 KB
  • 大小: 8.9 KB
  • 大小: 8.7 KB
  • 大小: 10.2 KB
  • 大小: 4.9 KB
  • 大小: 9.6 KB
  • 大小: 6.9 KB
  • 大小: 3.6 KB
  • 大小: 5.9 KB
  • 大小: 8.6 KB
  • 大小: 7.4 KB
  • 大小: 46.1 KB
  • 大小: 44.7 KB
  • 大小: 8.5 KB
  • 大小: 5.6 KB
  • 大小: 8.4 KB
  • 大小: 6.8 KB
  • 大小: 7.5 KB
  • 大小: 11.9 KB
  • 大小: 8.4 KB
  • 大小: 5.4 KB
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics